diff --git a/frontend/.changeset/funny-lemons-decide.md b/frontend/.changeset/funny-lemons-decide.md new file mode 100644 index 00000000..158ba41d --- /dev/null +++ b/frontend/.changeset/funny-lemons-decide.md @@ -0,0 +1,6 @@ +--- +"@liam-hq/erd-core": patch +"@liam-hq/cli": patch +--- + +feat: get hidden nodes via query parameter now compresses diff --git a/frontend/packages/erd-core/src/components/ERDRenderer/ERDContent/useInitialAutoLayout.ts b/frontend/packages/erd-core/src/components/ERDRenderer/ERDContent/useInitialAutoLayout.ts index 86e50375..b167c8b4 100644 --- a/frontend/packages/erd-core/src/components/ERDRenderer/ERDContent/useInitialAutoLayout.ts +++ b/frontend/packages/erd-core/src/components/ERDRenderer/ERDContent/useInitialAutoLayout.ts @@ -1,5 +1,6 @@ import type { QueryParam } from '@/schemas/queryParam' import { addHiddenNodeIds, updateActiveTableName } from '@/stores' +import { decompressFromEncodedURIComponent } from '@/utils' import { useNodesInitialized } from '@xyflow/react' import { useEffect } from 'react' import { useERDContentContext } from './ERDContentContext' @@ -13,10 +14,13 @@ const getActiveTableNameFromUrl = (): string | undefined => { return tableName || undefined } -const getHiddenNodeIdsFromUrl = (): string[] => { +const getHiddenNodeIdsFromUrl = async (): Promise => { const urlParams = new URLSearchParams(window.location.search) const hiddenQueryParam: QueryParam = 'hidden' - const hiddenNodeIds = urlParams.get(hiddenQueryParam) + const compressed = urlParams.get(hiddenQueryParam) + const hiddenNodeIds = compressed + ? await decompressFromEncodedURIComponent(compressed).catch(() => undefined) + : undefined return hiddenNodeIds ? hiddenNodeIds.split(',') : [] } @@ -29,21 +33,25 @@ export const useInitialAutoLayout = () => { const { handleLayout } = useAutoLayout() useEffect(() => { - if (initializeComplete) { - return + const initialize = async () => { + if (initializeComplete) { + return + } + + const tableNameFromUrl = getActiveTableNameFromUrl() + updateActiveTableName(tableNameFromUrl) + const hiddenNodeIds = await getHiddenNodeIdsFromUrl() + addHiddenNodeIds(hiddenNodeIds) + + const fitViewOptions = tableNameFromUrl + ? { maxZoom: 1, duration: 300, nodes: [{ id: tableNameFromUrl }] } + : undefined + + if (nodesInitialized) { + handleLayout(fitViewOptions, hiddenNodeIds) + } } - const tableNameFromUrl = getActiveTableNameFromUrl() - updateActiveTableName(tableNameFromUrl) - const hiddenNodeIds = getHiddenNodeIdsFromUrl() - addHiddenNodeIds(hiddenNodeIds) - - const fitViewOptions = tableNameFromUrl - ? { maxZoom: 1, duration: 300, nodes: [{ id: tableNameFromUrl }] } - : undefined - - if (nodesInitialized) { - handleLayout(fitViewOptions, hiddenNodeIds) - } + initialize() }, [nodesInitialized, initializeComplete, handleLayout]) } diff --git a/frontend/packages/erd-core/src/stores/userEditing/store.ts b/frontend/packages/erd-core/src/stores/userEditing/store.ts index 8f8d547c..edabad13 100644 --- a/frontend/packages/erd-core/src/stores/userEditing/store.ts +++ b/frontend/packages/erd-core/src/stores/userEditing/store.ts @@ -1,5 +1,6 @@ import type { QueryParam } from '@/schemas/queryParam' import type { ShowMode } from '@/schemas/showMode' +import { compressToEncodedURIComponent } from '@/utils' import { proxy, subscribe } from 'valtio' import { proxySet } from 'valtio/utils' @@ -33,14 +34,15 @@ subscribe(userEditingStore.active, () => { window.history.pushState({}, '', url) }) -subscribe(userEditingStore.hiddenNodeIds, () => { +subscribe(userEditingStore.hiddenNodeIds, async () => { const url = new URL(window.location.href) const activeQueryParam: QueryParam = 'hidden' const hiddenNodeIds = Array.from(userEditingStore.hiddenNodeIds).join(',') url.searchParams.delete(activeQueryParam) if (hiddenNodeIds) { - url.searchParams.set(activeQueryParam, hiddenNodeIds) + const compressed = await compressToEncodedURIComponent(hiddenNodeIds) + url.searchParams.set(activeQueryParam, compressed) } window.history.pushState({}, '', url) diff --git a/frontend/packages/erd-core/src/utils/compressionString.test.ts b/frontend/packages/erd-core/src/utils/compressionString.test.ts new file mode 100644 index 00000000..5fd69d6b --- /dev/null +++ b/frontend/packages/erd-core/src/utils/compressionString.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { + compressToEncodedURIComponent, + decompressFromEncodedURIComponent, +} from './compressionString' + +describe('compressionString', () => { + it('should compress and decompress a string correctly', async () => { + const input = 'Hello, world!' + const compressed = await compressToEncodedURIComponent(input) + const decompressed = await decompressFromEncodedURIComponent(compressed) + + expect(compressed).toMatchInlineSnapshot(`"eJzzSM3JyddRKM8vyklRBAAgXgSK"`) + expect(decompressed).toBe(input) + }) + + it('should handle empty string', async () => { + const input = '' + const compressed = await compressToEncodedURIComponent(input) + const decompressed = await decompressFromEncodedURIComponent(compressed) + + expect(compressed).toMatchInlineSnapshot(`"eJwDAAAAAAE"`) + expect(decompressed).toBe(input) + }) + + it('should handle long string', async () => { + const input = 'a'.repeat(1000) + const compressed = await compressToEncodedURIComponent(input) + const decompressed = await decompressFromEncodedURIComponent(compressed) + + expect(compressed).toMatchInlineSnapshot(`"eJxLTBwFo2AUDHcAAPnYevg"`) + expect(decompressed).toBe(input) + }) + + it('should handle special characters', async () => { + const input = 'こんにちは、世界!' + const compressed = await compressToEncodedURIComponent(input) + const decompressed = await decompressFromEncodedURIComponent(compressed) + + expect(compressed).toMatchInlineSnapshot( + `"eJwBGwDk_-OBk-OCk-OBq-OBoeOBr-OAgeS4lueVjO-8gQC2EmE"`, + ) + expect(decompressed).toBe(input) + }) + + it('should handle binary data', async () => { + const input = String.fromCharCode( + ...Array.from({ length: 256 }, (_, i) => i), + ) + const compressed = await compressToEncodedURIComponent(input) + const decompressed = await decompressFromEncodedURIComponent(compressed) + + expect(compressed).toMatchInlineSnapshot( + `"eJwFwQNXHQAABtBse9mu1bKWbbuWFpbN9arXy7Zt23XO9_2x7hUTl5CUkpaRlZNXUFRSVlFVU9fQ1NLW0dX7oW9gaGRsYmpmbmFpZW1ja2fv4Ojk_NPF9Zebu4enl7ePr59_wO_AoOCQ0LDwiMio6JjYuPiExKTklNS09IzMrOyc3Lz8gsI_RcUlpWV_yysqq_5V19TW1Tc0NjW3tLa1d3R2dff874UAfejHAIQYhAhDGMYIRjGGcUxgElOYxgxmMYd5LGARS1jGClaxhnVsYBNb2MYOdrGHfRzgEEc4xglOcYZzXOASV7jGDW5xh3s84BFPeMYLXvGGd3zgE18UsI_9HKCQgxRxiMMc4SjHOM4JTnKK05zhLOc4zwUuconLXOEq17jODW5yi9vc4S73uM8DHvKIxzzhKc94zgte8orXvOEt73jPBz7yic984Svf-M4PfvLrG5oE0ME"`, + ) + expect(decompressed).toBe(input) + }) +}) diff --git a/frontend/packages/erd-core/src/utils/compressionString.ts b/frontend/packages/erd-core/src/utils/compressionString.ts new file mode 100644 index 00000000..80445d6f --- /dev/null +++ b/frontend/packages/erd-core/src/utils/compressionString.ts @@ -0,0 +1,114 @@ +function createReadableStreamFromBytes( + bytes: Uint8Array, +): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(bytes) + controller.close() + }, + }) +} + +/** + * Perform Deflate compression and return a Uint8Array + */ +async function deflateCompress(inputBytes: Uint8Array): Promise { + const compression = new CompressionStream('deflate') + const stream = + createReadableStreamFromBytes(inputBytes).pipeThrough(compression) + const compressedBuffer = await new Response(stream).arrayBuffer() + return new Uint8Array(compressedBuffer) +} + +/** + * Perform Deflate decompression and return a Uint8Array + */ +async function deflateDecompress(inputBytes: Uint8Array): Promise { + const decompression = new DecompressionStream('deflate') + const stream = + createReadableStreamFromBytes(inputBytes).pipeThrough(decompression) + const decompressedBuffer = await new Response(stream).arrayBuffer() + return new Uint8Array(decompressedBuffer) +} + +/** + * Encode byte array to Base64 string + */ +function bytesToBase64(bytes: Uint8Array): string { + let binaryString = '' + for (const b of Array.from(bytes)) { + binaryString += String.fromCharCode(b) + } + return btoa(binaryString) +} + +/** + * Decode Base64 string to byte array + */ +function base64ToBytes(base64: string): Uint8Array { + const binaryString = atob(base64) + const length = binaryString.length + const bytes = new Uint8Array(length) + for (let i = 0; i < length; i++) { + bytes[i] = binaryString.charCodeAt(i) + } + return bytes +} + +/** + * Convert Base64 string to URL-safe string (`+` => `-`, `/` => `_`, remove `=`) + */ +function base64ToUrlSafe(base64: string): string { + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +/** + * Convert URL-safe string back to Base64 + */ +function urlSafeToBase64(urlSafe: string): string { + let base64 = urlSafe.replace(/-/g, '+').replace(/_/g, '/') + while (base64.length % 4) { + base64 += '=' + } + return base64 +} + +/** + * Compress string using Deflate and return as Base64 + */ +async function compressToBase64(input: string): Promise { + const textEncoder = new TextEncoder() + const inputBytes = textEncoder.encode(input) + const compressedBytes = await deflateCompress(inputBytes) + return bytesToBase64(compressedBytes) +} + +/** + * Decompress Base64 string and return the original string + */ +async function decompressFromBase64(input: string): Promise { + const textDecoder = new TextDecoder() + const compressedBytes = base64ToBytes(input) + const decompressedBytes = await deflateDecompress(compressedBytes) + return textDecoder.decode(decompressedBytes) +} + +/** + * Compress string using Deflate and return as URL-safe encoded string (based on Base64) + */ +export async function compressToEncodedURIComponent( + input: string, +): Promise { + const base64 = await compressToBase64(input) + return base64ToUrlSafe(base64) +} + +/** + * Restore original string from URL-safe encoded string + */ +export async function decompressFromEncodedURIComponent( + input: string, +): Promise { + const base64 = urlSafeToBase64(input) + return decompressFromBase64(base64) +} diff --git a/frontend/packages/erd-core/src/utils/index.ts b/frontend/packages/erd-core/src/utils/index.ts new file mode 100644 index 00000000..5c12e8b7 --- /dev/null +++ b/frontend/packages/erd-core/src/utils/index.ts @@ -0,0 +1 @@ +export * from './compressionString'