diff --git a/packages/docs/programmable/sdk/api/composables/index.md b/packages/docs/programmable/sdk/api/composables/index.md
index 925e916d0..ff08b7d8f 100644
--- a/packages/docs/programmable/sdk/api/composables/index.md
+++ b/packages/docs/programmable/sdk/api/composables/index.md
@@ -38,6 +38,10 @@ These are the main composables most `@open-pencil/vue` consumers will use.
- [useStrokeControls](./use-stroke-controls)
- [useEffectsControls](./use-effects-controls)
+## Document workspaces
+
+- [useDocumentWorkspace](./use-document-workspace)
+
## Variables, navigation, and localization
- [useVariablesEditor](./use-variables-editor)
diff --git a/packages/docs/programmable/sdk/api/composables/use-document-workspace.md b/packages/docs/programmable/sdk/api/composables/use-document-workspace.md
new file mode 100644
index 000000000..dfca6ad1b
--- /dev/null
+++ b/packages/docs/programmable/sdk/api/composables/use-document-workspace.md
@@ -0,0 +1,72 @@
+---
+title: useDocumentWorkspace
+description: Manage document lists, refreshes, lazy previews, and workspace events.
+---
+
+# useDocumentWorkspace
+
+`useDocumentWorkspace()` provides headless state for document browsers backed by local or remote storage.
+
+It manages:
+
+- initial, manual, focused-window, reconnect, and interval refreshes
+- deduplicated refresh requests and source invalidation events
+- lazy preview loading with bounded concurrency
+- preview object URL creation and cleanup
+- per-preview errors
+
+## Usage
+
+```ts
+import { useDocumentWorkspace } from '@open-pencil/vue'
+
+const workspace = useDocumentWorkspace({
+ source: {
+ refresh: () => documentService.list(),
+ loadPreview: (id) => documentService.loadPreview(id),
+ subscribe: (listener) => documentService.subscribe(listener),
+ },
+ refreshInterval: 60_000,
+})
+```
+
+The source must return items with `id`, `name`, and `updatedAt`. A changed `updatedAt` value invalidates an existing or in-flight preview.
+
+## Lazy previews
+
+Apply `previewDirective` to the element that should trigger loading, then resolve the current object URL with `previewURL()`:
+
+```vue
+
+
+
+
+
+
![]()
+
+
+
+```
+
+When `IntersectionObserver` is unavailable, the directive loads the preview immediately.
+
+## Errors
+
+`error` contains the latest document-list refresh failure. Preview failures are available by document ID through `previewErrors`; use `onPreviewError` when errors should also be reported to an application service.
+
+```ts
+const workspace = useDocumentWorkspace({
+ source,
+ onPreviewError(id, error) {
+ reportPreviewError({ id, error })
+ },
+})
+```
+
+Calling `loadPreview(id)` retries a failed preview. Successful loads clear the corresponding preview error.
+
+## Cleanup
+
+The composable revokes generated object URLs and unsubscribes from the source when its component unmounts. Call `clearPreviews()` when switching an external workspace or provider without unmounting the component.
diff --git a/packages/fig/src/thumbnail.ts b/packages/fig/src/thumbnail.ts
index eb495b863..c181d7654 100644
--- a/packages/fig/src/thumbnail.ts
+++ b/packages/fig/src/thumbnail.ts
@@ -14,6 +14,7 @@ export type FigThumbnailLimits = {
type ThumbnailEntry = {
method: number
compressedSize: number
+ outputSize: number
localOffset: number
}
@@ -26,6 +27,7 @@ const DEFAULT_MAX_TAIL = 4 * 1024 * 1024
const DEFAULT_MAX_COMPRESSED = 8 * 1024 * 1024
const DEFAULT_MAX_OUTPUT = 16 * 1024 * 1024
const THUMBNAIL_NAME = 'thumbnail.png'
+const PNG_SIGNATURE = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
function view(bytes: Uint8Array): DataView {
return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
@@ -43,6 +45,10 @@ function boundedLimit(value: number | undefined, fallback: number): number {
return Number.isFinite(value) && value && value > 0 ? value : fallback
}
+function hasPNGSignature(bytes: Uint8Array): boolean {
+ return PNG_SIGNATURE.every((byte, index) => bytes[index] === byte)
+}
+
function findThumbnailEntry(
central: Uint8Array,
maxCompressed: number,
@@ -66,7 +72,12 @@ function findThumbnailEntry(
const name = decoder.decode(central.subarray(offset + 46, offset + 46 + nameLength))
if (name === THUMBNAIL_NAME) {
if (compressedSize > maxCompressed || outputSize > maxOutput) return null
- return { method, compressedSize, localOffset: data.getUint32(offset + 42, true) }
+ return {
+ method,
+ compressedSize,
+ outputSize,
+ localOffset: data.getUint32(offset + 42, true)
+ }
}
offset = next
}
@@ -85,10 +96,14 @@ async function readEntryPayload(
entry.localOffset + 30 + headerView.getUint16(26, true) + headerView.getUint16(28, true)
if (dataStart + entry.compressedSize > reader.size) return null
const compressed = await reader.read(dataStart, dataStart + entry.compressedSize)
- if (entry.method === 0) return compressed.byteLength <= maxOutput ? compressed : null
+ if (entry.method === 0) {
+ return compressed.byteLength === entry.outputSize && hasPNGSignature(compressed)
+ ? compressed
+ : null
+ }
if (entry.method !== 8) return null
const output = inflateSync(compressed, { out: new Uint8Array(maxOutput + 1) })
- return output.byteLength <= maxOutput ? output : null
+ return output.byteLength === entry.outputSize && hasPNGSignature(output) ? output : null
}
/**
diff --git a/packages/vue/src/document/workspace/use.ts b/packages/vue/src/document/workspace/use.ts
index 9745c078c..100b6f223 100644
--- a/packages/vue/src/document/workspace/use.ts
+++ b/packages/vue/src/document/workspace/use.ts
@@ -31,6 +31,7 @@ export type UseDocumentWorkspaceOptions- = {
refreshOnReconnect?: boolean
previewConcurrency?: number
previewMimeType?: string
+ onPreviewError?(id: string, error: unknown): void
}
export function useDocumentWorkspace
- (
@@ -41,6 +42,7 @@ export function useDocumentWorkspace
- (
const error = shallowRef(null)
const lastRefreshedAt = shallowRef(null)
const previewUrls = ref>({})
+ const previewErrors = shallowRef>({})
const previewCleanups = new WeakMap void>()
const previewGenerations = new Map()
const previewQueue: string[] = []
@@ -64,9 +66,17 @@ export function useDocumentWorkspace
- (
function reconcilePreviewUrls(items: readonly Item[]): void {
const previousItems = new Map(documents.value.map((item) => [item.id, item.updatedAt]))
const currentItems = new Map(items.map((item) => [item.id, item.updatedAt]))
- for (const id of Object.keys(previewUrls.value)) {
- if (previousItems.get(id) !== currentItems.get(id)) removePreviewURL(id)
+ const trackedIds = new Set([...Object.keys(previewUrls.value), ...activePreviews, ...queued])
+ for (const id of trackedIds) {
+ if (previousItems.get(id) === currentItems.get(id)) continue
+ removePreviewURL(id)
+ if (!currentItems.has(id) || activePreviews.has(id)) continue
+ if (!queued.has(id)) {
+ queued.add(id)
+ previewQueue.push(id)
+ }
}
+ drainPreviewQueue()
}
function clearPreviews(): void {
@@ -94,6 +104,18 @@ export function useDocumentWorkspace
- (
}
}
+ function clearPreviewError(id: string): void {
+ if (!(id in previewErrors.value)) return
+ previewErrors.value = Object.fromEntries(
+ Object.entries(previewErrors.value).filter(([previewId]) => previewId !== id)
+ )
+ }
+
+ function recordPreviewError(id: string, error: unknown): void {
+ previewErrors.value = { ...previewErrors.value, [id]: error }
+ options.onPreviewError?.(id, error)
+ }
+
function drainPreviewQueue(): void {
while (activePreviews.size < concurrency) {
const id = previewQueue.shift()
@@ -106,13 +128,25 @@ export function useDocumentWorkspace
- (
.loadPreview(id)
.then((bytes) => {
if (bytes?.byteLength && generation === (previewGenerations.get(id) ?? 0)) {
+ clearPreviewError(id)
replacePreviewURL(id, bytes)
}
return undefined
})
- .catch(() => null)
+ .catch((error: unknown) => {
+ if (!disposed && generation === (previewGenerations.get(id) ?? 0)) {
+ recordPreviewError(id, error)
+ }
+ })
.finally(() => {
activePreviews.delete(id)
+ if (
+ !disposed &&
+ generation !== (previewGenerations.get(id) ?? 0) &&
+ documents.value.some((item) => item.id === id)
+ ) {
+ loadPreview(id)
+ }
drainPreviewQueue()
})
}
@@ -120,6 +154,7 @@ export function useDocumentWorkspace
- (
function loadPreview(id: string): void {
if (previewUrls.value[id] || activePreviews.has(id) || queued.has(id)) return
+ clearPreviewError(id)
queued.add(id)
previewQueue.push(id)
drainPreviewQueue()
@@ -236,6 +271,7 @@ export function useDocumentWorkspace
- (
error: readonly(error),
lastRefreshedAt: readonly(lastRefreshedAt),
previewUrls: readonly(previewUrls),
+ previewErrors: readonly(previewErrors),
hasDocuments: computed(() => documents.value.length > 0),
refresh,
invalidate,
diff --git a/src/app/integrations/storage/s3/client.ts b/src/app/integrations/storage/s3/client.ts
index a13ff39ca..9b8e9f7f3 100644
--- a/src/app/integrations/storage/s3/client.ts
+++ b/src/app/integrations/storage/s3/client.ts
@@ -170,7 +170,9 @@ export async function headObjectSize(
): Promise {
const res = await s3Request(config, objectURL(config, key), { method: 'HEAD' })
if (res.status === 404) return null
- const size = Number(res.headers.get('content-length'))
+ const sizeHeader = res.headers.get('content-length')
+ if (sizeHeader == null) return null
+ const size = Number(sizeHeader)
return Number.isSafeInteger(size) && size >= 0 ? size : null
}
@@ -182,6 +184,7 @@ export async function getObjectRange(
): Promise {
if (
!Number.isSafeInteger(start) ||
+ start < 0 ||
!Number.isSafeInteger(endExclusive) ||
endExclusive <= start
) {
diff --git a/src/app/storage/workspace/events.ts b/src/app/storage/workspace/events.ts
index 47589ff98..e6d112999 100644
--- a/src/app/storage/workspace/events.ts
+++ b/src/app/storage/workspace/events.ts
@@ -1,5 +1,7 @@
+import type { StorageProviderID } from '@/app/integrations/storage/types'
+
export type StorageWorkspaceEvent = {
- providerId: string
+ providerId: StorageProviderID
documentId?: string
kind: 'changed' | 'synced'
}
diff --git a/src/app/storage/workspace/source.ts b/src/app/storage/workspace/source.ts
index 6548ab36b..d93a50ee0 100644
--- a/src/app/storage/workspace/source.ts
+++ b/src/app/storage/workspace/source.ts
@@ -70,14 +70,15 @@ export function createStorageWorkspaceSource(
},
async loadPreview(id: string): Promise {
+ const providerID = activeStorageProviderID.value
const localStore = getLocalCanvasStore()
const local = await localStore.readThumb(id)
if (local?.byteLength) return local
- const adapter = createActiveStorageAdapter()
+ const adapter = createActiveStorageAdapter(providerID)
if (!adapter.getThumbnail) return null
const remote = await adapter.getThumbnail(id)
if (!remote?.byteLength) return null
- await localStore.writeThumb(id, remote)
+ if (activeStorageProviderID.value === providerID) await localStore.writeThumb(id, remote)
return remote
}
}
diff --git a/tests/e2e/storage/workspace.spec.ts b/tests/e2e/storage/workspace.spec.ts
index 157515045..232b1ed92 100644
--- a/tests/e2e/storage/workspace.spec.ts
+++ b/tests/e2e/storage/workspace.spec.ts
@@ -34,17 +34,26 @@ test('configured storage lists previews through ranges before opening the docume
return
}
if (url.pathname.endsWith('/remote-1.fig') && route.request().headers().range) {
- rangeGets++
- const match = route
- .request()
- .headers()
- .range?.match(/^bytes=(\d+)-(\d+)$/)
- if (!match) {
+ const range = route.request().headers().range
+ const explicit = range?.match(/^bytes=(\d+)-(\d+)$/)
+ const suffix = range?.match(/^bytes=-(\d+)$/)
+ let start: number
+ let end: number
+ if (explicit) {
+ start = Number(explicit[1])
+ end = Math.min(Number(explicit[2]), fixture.byteLength - 1)
+ } else if (suffix) {
+ const length = Math.min(Number(suffix[1]), fixture.byteLength)
+ start = fixture.byteLength - length
+ end = fixture.byteLength - 1
+ } else {
+ await route.fulfill({ status: 416 })
+ return
+ }
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start) {
await route.fulfill({ status: 416 })
return
}
- const start = Number(match[1])
- const end = Number(match[2])
await route.fulfill({
status: 206,
headers: {
@@ -53,6 +62,7 @@ test('configured storage lists previews through ranges before opening the docume
contentType: 'application/octet-stream',
body: fixture.subarray(start, end + 1)
})
+ rangeGets++
return
}
if (url.pathname.endsWith('/remote-1.fig') && route.request().method() === 'HEAD') {
@@ -91,7 +101,7 @@ test('configured storage lists previews through ranges before opening the docume
const preview = page.locator('[data-document-id="remote-1"] img')
await expect(preview).toBeVisible()
await expect(preview).toHaveAttribute('src', /^blob:/)
- expect(rangeGets).toBeGreaterThan(0)
+ expect(rangeGets).toBe(3)
expect(fullDocumentGets).toBe(0)
await page.locator('[data-document-id="remote-1"]').click()
diff --git a/tests/engine/app/storage/persist.test.ts b/tests/engine/app/storage/persist.test.ts
index d4cc4ff8c..6061844cb 100644
--- a/tests/engine/app/storage/persist.test.ts
+++ b/tests/engine/app/storage/persist.test.ts
@@ -47,6 +47,10 @@ describe('local-first storage persistence', () => {
{ store, enqueueCanvas }
)
- expect((await store.readThumb('canvas-preview'))?.byteLength).toBeGreaterThan(0)
+ const thumbnail = await store.readThumb('canvas-preview')
+ expect(thumbnail?.byteLength).toBeGreaterThan(0)
+ expect(thumbnail?.subarray(0, 8)).toEqual(
+ new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
+ )
})
})
diff --git a/tests/engine/io/fig/thumbnail-range.test.ts b/tests/engine/io/fig/thumbnail-range.test.ts
index d652177c2..e40596715 100644
--- a/tests/engine/io/fig/thumbnail-range.test.ts
+++ b/tests/engine/io/fig/thumbnail-range.test.ts
@@ -1,6 +1,8 @@
import { describe, expect, test } from 'bun:test'
import { readFileSync } from 'node:fs'
+import { zipSync } from 'fflate'
+
import { extractFigThumbnailFromReader } from '@open-pencil/fig'
function memoryReader(bytes: Uint8Array, ranges: Array<[number, number]>) {
@@ -31,6 +33,14 @@ describe('fig ranged thumbnail extraction', () => {
)
})
+ test('rejects malformed thumbnail payloads', async () => {
+ const bytes = zipSync({
+ 'canvas.fig': new Uint8Array([1]),
+ 'thumbnail.png': new TextEncoder().encode('not a png')
+ })
+ expect(await extractFigThumbnailFromReader(memoryReader(bytes, []))).toBeNull()
+ })
+
test('rejects thumbnails above configured output limits', async () => {
const bytes = new Uint8Array(readFileSync('tests/fixtures/gold-preview.fig'))
const thumbnail = await extractFigThumbnailFromReader(memoryReader(bytes, []), {
diff --git a/tests/engine/vue/document-workspace.test.ts b/tests/engine/vue/document-workspace.test.ts
index a21765e63..1eb2523c5 100644
--- a/tests/engine/vue/document-workspace.test.ts
+++ b/tests/engine/vue/document-workspace.test.ts
@@ -158,6 +158,62 @@ describe('useDocumentWorkspace', () => {
expect(sourceListener).toBeNull()
})
+ test('invalidates an in-flight preview when the document changes', async () => {
+ const previewLoad = deferred()
+ const freshPreviewLoad = deferred()
+ const refresh = vi
+ .fn<() => Promise>()
+ .mockResolvedValueOnce([{ id: 'one', name: 'One', updatedAt: 'first' }])
+ .mockResolvedValueOnce([{ id: 'one', name: 'One', updatedAt: 'second' }])
+ const loadPreview = vi
+ .fn<() => Promise>()
+ .mockImplementationOnce(() => previewLoad.promise)
+ .mockImplementationOnce(() => freshPreviewLoad.promise)
+ const createObjectURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:preview')
+ const mounted = mountWorkspace({ refresh, loadPreview })
+ await flushTasks()
+
+ mounted.workspace.loadPreview('one')
+ expect(loadPreview).toHaveBeenCalledTimes(1)
+ await mounted.workspace.refresh()
+ previewLoad.resolve(new Uint8Array([1]))
+ await previewLoad.promise
+ await flushTasks()
+
+ expect(createObjectURL).not.toHaveBeenCalled()
+ expect(loadPreview).toHaveBeenCalledTimes(2)
+ freshPreviewLoad.resolve(new Uint8Array([2]))
+ await freshPreviewLoad.promise
+ await flushTasks()
+ expect(createObjectURL).toHaveBeenCalledTimes(1)
+ mounted.unmount()
+ })
+
+ test('surfaces preview failures to consumers', async () => {
+ const failure = new Error('preview failed')
+ const onPreviewError = vi.fn()
+ const holder: WorkspaceHolder = { current: null }
+ const component = defineComponent({
+ setup() {
+ holder.current = useDocumentWorkspace({
+ source: { refresh: async () => [], loadPreview: async () => Promise.reject(failure) },
+ refreshOnFocus: false,
+ refreshOnReconnect: false,
+ onPreviewError
+ })
+ return () => h('div')
+ }
+ })
+ const app = renderer.createApp(component)
+ app.mount(hostNode())
+ holder.current?.loadPreview('one')
+ await flushTasks()
+
+ expect(holder.current?.previewErrors.value.one).toBe(failure)
+ expect(onPreviewError).toHaveBeenCalledWith('one', failure)
+ app.unmount()
+ })
+
test('deduplicates previews, limits concurrency, and revokes URLs on unmount', async () => {
const loads = new Map>()
const loadPreview = vi.fn((id: string) => {