Merge pull request #480 from open-pencil/storage-workspace-previews
feat(storage): show workspace document previews
This commit is contained in:
commit
b6bf9e9d6b
|
|
@ -32,10 +32,10 @@
|
|||
- Test OpenAI-compatible provider connections from AI settings with clearer setup errors.
|
||||
- Configure separate Design, Review, Fast, and Vision models, providers, endpoints, and credentials from AI settings.
|
||||
- Manage AI, agent, media, and storage credentials from unified Settings, using the system credential store on desktop and encrypted browser storage by default, with a session-only browser option.
|
||||
- Connect an S3-compatible storage workspace with local-first saves and background synchronization.
|
||||
- Connect an S3-compatible storage workspace with local-first saves, background synchronization, embedded `.fig` previews loaded without downloading full documents, and automatic refresh while the workspace is active.
|
||||
- Add Japanese localization and improve menu translations across the existing supported languages. (#367)
|
||||
- Author richer Design JSX with components, instances, variables, gradients, structured fills, shadows, blur effects, masks, and inline SVG vectors.
|
||||
- Build custom property panels with new Vue SDK number fields, bindable values, property sections, responsive grids, segmented controls, property lists, color models, fill controls, and gradient primitives.
|
||||
- Build custom property panels and document workspaces with new Vue SDK number fields, bindable values, property sections, responsive grids, segmented controls, property lists, color models, fill controls, gradient primitives, and the headless `useDocumentWorkspace()` composable.
|
||||
- Use `useColorModel()` in the Vue SDK for extensible color formats and shared RGB, HSL, HSB, and OkHCL channel behavior.
|
||||
- Add dedicated SceneGraph, Pen, Kiwi, Fig, and DOM/CSS packages with documented public entry points for building on OpenPencil.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
<script setup lang="ts">
|
||||
const { documents, previewDirective: vDocumentPreview, previewURL } = workspace
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article v-for="document in documents" :key="document.id">
|
||||
<div v-document-preview="document.id">
|
||||
<img v-if="previewURL(document.id)" :src="previewURL(document.id) ?? undefined" alt="" />
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
```
|
||||
|
||||
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.
|
||||
|
|
@ -6,6 +6,11 @@ export {
|
|||
type FigParseResult,
|
||||
type WriteFigArchiveInput
|
||||
} from './archive'
|
||||
export {
|
||||
extractFigThumbnailFromReader,
|
||||
type FigRangeReader,
|
||||
type FigThumbnailLimits
|
||||
} from './thumbnail'
|
||||
export {
|
||||
effectiveFigmaRawNodeFields,
|
||||
effectiveFigmaSourcePayload,
|
||||
|
|
|
|||
143
packages/fig/src/thumbnail.ts
Normal file
143
packages/fig/src/thumbnail.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import { inflateSync } from 'fflate'
|
||||
|
||||
export interface FigRangeReader {
|
||||
readonly size: number
|
||||
read(start: number, endExclusive: number): Promise<Uint8Array>
|
||||
}
|
||||
|
||||
export type FigThumbnailLimits = {
|
||||
maxTailBytes?: number
|
||||
maxCompressedBytes?: number
|
||||
maxOutputBytes?: number
|
||||
}
|
||||
|
||||
type ThumbnailEntry = {
|
||||
method: number
|
||||
compressedSize: number
|
||||
outputSize: number
|
||||
localOffset: number
|
||||
}
|
||||
|
||||
const EOCD_SIGNATURE = 0x06054b50
|
||||
const CENTRAL_SIGNATURE = 0x02014b50
|
||||
const LOCAL_SIGNATURE = 0x04034b50
|
||||
const EOCD_MIN_SIZE = 22
|
||||
const MAX_ZIP_COMMENT = 65_535
|
||||
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)
|
||||
}
|
||||
|
||||
function findEOCD(bytes: Uint8Array): number {
|
||||
const data = view(bytes)
|
||||
for (let offset = bytes.byteLength - EOCD_MIN_SIZE; offset >= 0; offset--) {
|
||||
if (data.getUint32(offset, true) === EOCD_SIGNATURE) return offset
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
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,
|
||||
maxOutput: number
|
||||
): ThumbnailEntry | null {
|
||||
const data = view(central)
|
||||
const decoder = new TextDecoder()
|
||||
for (let offset = 0; offset + 46 <= central.byteLength; ) {
|
||||
if (data.getUint32(offset, true) !== CENTRAL_SIGNATURE) return null
|
||||
const method = data.getUint16(offset + 10, true)
|
||||
const compressedSize = data.getUint32(offset + 20, true)
|
||||
const outputSize = data.getUint32(offset + 24, true)
|
||||
const nameLength = data.getUint16(offset + 28, true)
|
||||
const next =
|
||||
offset +
|
||||
46 +
|
||||
nameLength +
|
||||
data.getUint16(offset + 30, true) +
|
||||
data.getUint16(offset + 32, true)
|
||||
if (next > central.byteLength) return null
|
||||
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,
|
||||
outputSize,
|
||||
localOffset: data.getUint32(offset + 42, true)
|
||||
}
|
||||
}
|
||||
offset = next
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function readEntryPayload(
|
||||
reader: FigRangeReader,
|
||||
entry: ThumbnailEntry,
|
||||
maxOutput: number
|
||||
): Promise<Uint8Array | null> {
|
||||
const header = await reader.read(entry.localOffset, Math.min(reader.size, entry.localOffset + 30))
|
||||
if (header.byteLength < 30 || view(header).getUint32(0, true) !== LOCAL_SIGNATURE) return null
|
||||
const headerView = view(header)
|
||||
const dataStart =
|
||||
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 === entry.outputSize && hasPNGSignature(compressed)
|
||||
? compressed
|
||||
: null
|
||||
}
|
||||
if (entry.method !== 8) return null
|
||||
const output = (() => {
|
||||
try {
|
||||
return inflateSync(compressed, { out: new Uint8Array(maxOutput + 1) })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})()
|
||||
return output?.byteLength === entry.outputSize && hasPNGSignature(output) ? output : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract Figma's canonical `thumbnail.png` from a remote `.fig` ZIP through
|
||||
* bounded range reads. The complete document is never requested.
|
||||
*/
|
||||
export async function extractFigThumbnailFromReader(
|
||||
reader: FigRangeReader,
|
||||
limits: FigThumbnailLimits = {}
|
||||
): Promise<Uint8Array | null> {
|
||||
if (!Number.isSafeInteger(reader.size) || reader.size < EOCD_MIN_SIZE) return null
|
||||
const maxCentral = boundedLimit(limits.maxTailBytes, DEFAULT_MAX_TAIL)
|
||||
const maxCompressed = boundedLimit(limits.maxCompressedBytes, DEFAULT_MAX_COMPRESSED)
|
||||
const maxOutput = boundedLimit(limits.maxOutputBytes, DEFAULT_MAX_OUTPUT)
|
||||
const tailSize = Math.min(reader.size, EOCD_MIN_SIZE + MAX_ZIP_COMMENT)
|
||||
const tailStart = reader.size - tailSize
|
||||
const tail = await reader.read(tailStart, reader.size)
|
||||
const eocd = findEOCD(tail)
|
||||
if (eocd < 0) return null
|
||||
|
||||
const tailView = view(tail)
|
||||
const centralSize = tailView.getUint32(eocd + 12, true)
|
||||
const centralOffset = tailView.getUint32(eocd + 16, true)
|
||||
if (centralSize > maxCentral || centralOffset + centralSize > reader.size) return null
|
||||
const central =
|
||||
centralOffset >= tailStart && centralOffset + centralSize <= reader.size
|
||||
? tail.subarray(centralOffset - tailStart, centralOffset - tailStart + centralSize)
|
||||
: await reader.read(centralOffset, centralOffset + centralSize)
|
||||
const entry = findThumbnailEntry(central, maxCompressed, maxOutput)
|
||||
return entry ? readEntryPayload(reader, entry, maxOutput) : null
|
||||
}
|
||||
200
packages/vue/src/document/workspace/previews.ts
Normal file
200
packages/vue/src/document/workspace/previews.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import { readonly, ref, shallowRef, type Directive, type Ref } from 'vue'
|
||||
|
||||
import type {
|
||||
DocumentWorkspaceItem,
|
||||
DocumentWorkspaceSource,
|
||||
UseDocumentWorkspaceOptions
|
||||
} from './use'
|
||||
|
||||
type DocumentPreviewsOptions<Item extends DocumentWorkspaceItem> = Pick<
|
||||
UseDocumentWorkspaceOptions<Item>,
|
||||
'onPreviewError' | 'previewConcurrency' | 'previewMimeType'
|
||||
> & {
|
||||
documents: Readonly<Ref<readonly Item[]>>
|
||||
source: DocumentWorkspaceSource<Item>
|
||||
}
|
||||
|
||||
export function createDocumentPreviews<Item extends DocumentWorkspaceItem>(
|
||||
options: DocumentPreviewsOptions<Item>
|
||||
) {
|
||||
const previewUrls = ref<Record<string, string>>({})
|
||||
const previewErrors = shallowRef<Record<string, unknown>>({})
|
||||
const previewCleanups = new WeakMap<Element, () => void>()
|
||||
const previewGenerations = new Map<string, number>()
|
||||
const previewQueue: string[] = []
|
||||
const queued = new Set<string>()
|
||||
const active = new Set<string>()
|
||||
const requestedConcurrency = options.previewConcurrency ?? 6
|
||||
const concurrency = Number.isFinite(requestedConcurrency)
|
||||
? Math.max(1, Math.floor(requestedConcurrency))
|
||||
: 6
|
||||
let disposed = false
|
||||
|
||||
function removeURL(id: string): void {
|
||||
previewGenerations.set(id, (previewGenerations.get(id) ?? 0) + 1)
|
||||
const url = previewUrls.value[id]
|
||||
if (!url) return
|
||||
URL.revokeObjectURL(url)
|
||||
previewUrls.value = Object.fromEntries(
|
||||
Object.entries(previewUrls.value).filter(([previewId]) => previewId !== id)
|
||||
)
|
||||
}
|
||||
|
||||
function replaceURL(id: string, bytes: Uint8Array): void {
|
||||
if (disposed) return
|
||||
const previous = previewUrls.value[id]
|
||||
if (previous) URL.revokeObjectURL(previous)
|
||||
const blobBytes = Uint8Array.from(bytes)
|
||||
previewUrls.value = {
|
||||
...previewUrls.value,
|
||||
[id]: URL.createObjectURL(
|
||||
new Blob([blobBytes.buffer], { type: options.previewMimeType ?? 'image/png' })
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function clearError(id: string): void {
|
||||
if (!(id in previewErrors.value)) return
|
||||
previewErrors.value = Object.fromEntries(
|
||||
Object.entries(previewErrors.value).filter(([previewId]) => previewId !== id)
|
||||
)
|
||||
}
|
||||
|
||||
async function runLoad(id: string, generation: number): Promise<void> {
|
||||
try {
|
||||
const bytes = await options.source.loadPreview(id)
|
||||
if (bytes?.byteLength && generation === (previewGenerations.get(id) ?? 0)) {
|
||||
clearError(id)
|
||||
replaceURL(id, bytes)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (!disposed && generation === (previewGenerations.get(id) ?? 0)) {
|
||||
previewErrors.value = { ...previewErrors.value, [id]: error }
|
||||
try {
|
||||
options.onPreviewError?.(id, error)
|
||||
} catch (callbackError) {
|
||||
console.error('[Vue] Preview error callback failed:', callbackError)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
active.delete(id)
|
||||
if (
|
||||
!disposed &&
|
||||
generation !== (previewGenerations.get(id) ?? 0) &&
|
||||
options.documents.value.some((item) => item.id === id)
|
||||
) {
|
||||
loadPreview(id)
|
||||
}
|
||||
drainQueue()
|
||||
}
|
||||
}
|
||||
|
||||
function drainQueue(): void {
|
||||
while (active.size < concurrency) {
|
||||
const id = previewQueue.shift()
|
||||
if (!id) break
|
||||
if (!queued.delete(id)) continue
|
||||
if (active.has(id) || previewUrls.value[id]) continue
|
||||
active.add(id)
|
||||
void runLoad(id, previewGenerations.get(id) ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
function loadPreview(id: string): void {
|
||||
if (previewUrls.value[id] || active.has(id) || queued.has(id)) return
|
||||
clearError(id)
|
||||
queued.add(id)
|
||||
previewQueue.push(id)
|
||||
drainQueue()
|
||||
}
|
||||
|
||||
function reconcile(previousItems: readonly Item[], items: readonly Item[]): void {
|
||||
const previous = new Map(previousItems.map((item) => [item.id, item.updatedAt]))
|
||||
const current = new Map(items.map((item) => [item.id, item.updatedAt]))
|
||||
const trackedIds = new Set([
|
||||
...Object.keys(previewUrls.value),
|
||||
...active,
|
||||
...queued,
|
||||
...Object.keys(previewErrors.value)
|
||||
])
|
||||
for (const id of trackedIds) {
|
||||
if (previous.get(id) === current.get(id)) continue
|
||||
removeURL(id)
|
||||
clearError(id)
|
||||
if (!current.has(id)) {
|
||||
queued.delete(id)
|
||||
continue
|
||||
}
|
||||
if (active.has(id) || queued.has(id)) continue
|
||||
queued.add(id)
|
||||
previewQueue.push(id)
|
||||
}
|
||||
drainQueue()
|
||||
}
|
||||
|
||||
function clearPreviews(): void {
|
||||
const ids = new Set([
|
||||
...Object.keys(previewUrls.value),
|
||||
...active,
|
||||
...queued,
|
||||
...previewGenerations.keys()
|
||||
])
|
||||
for (const id of ids) removeURL(id)
|
||||
previewQueue.length = 0
|
||||
queued.clear()
|
||||
previewErrors.value = {}
|
||||
}
|
||||
|
||||
function observePreview(element: Element | null, id: string): () => void {
|
||||
if (!element || typeof IntersectionObserver === 'undefined') {
|
||||
loadPreview(id)
|
||||
return () => undefined
|
||||
}
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
loadPreview(id)
|
||||
observer.disconnect()
|
||||
}
|
||||
},
|
||||
{ rootMargin: '240px' }
|
||||
)
|
||||
observer.observe(element)
|
||||
return () => observer.disconnect()
|
||||
}
|
||||
|
||||
function stopObserving(element: Element): void {
|
||||
previewCleanups.get(element)?.()
|
||||
previewCleanups.delete(element)
|
||||
}
|
||||
|
||||
const previewDirective: Directive<Element, string> = {
|
||||
mounted(element, binding) {
|
||||
previewCleanups.set(element, observePreview(element, binding.value))
|
||||
},
|
||||
updated(element, binding) {
|
||||
if (binding.value === binding.oldValue) return
|
||||
stopObserving(element)
|
||||
previewCleanups.set(element, observePreview(element, binding.value))
|
||||
},
|
||||
unmounted(element) {
|
||||
stopObserving(element)
|
||||
}
|
||||
}
|
||||
|
||||
function dispose(): void {
|
||||
disposed = true
|
||||
clearPreviews()
|
||||
}
|
||||
|
||||
return {
|
||||
previewUrls: readonly(previewUrls),
|
||||
previewErrors: readonly(previewErrors),
|
||||
clearPreviews,
|
||||
dispose,
|
||||
loadPreview,
|
||||
previewDirective,
|
||||
previewURL: (id: string) => previewUrls.value[id] ?? null,
|
||||
reconcile
|
||||
}
|
||||
}
|
||||
129
packages/vue/src/document/workspace/use.ts
Normal file
129
packages/vue/src/document/workspace/use.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import { useEventListener, useIntervalFn } from '@vueuse/core'
|
||||
import { computed, onBeforeUnmount, onMounted, readonly, ref, shallowRef, type Ref } from 'vue'
|
||||
|
||||
import { IS_BROWSER } from '@open-pencil/core/constants'
|
||||
|
||||
import { createDocumentPreviews } from './previews'
|
||||
|
||||
export type DocumentWorkspaceItem = {
|
||||
id: string
|
||||
name: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface DocumentWorkspaceSource<Item extends DocumentWorkspaceItem> {
|
||||
refresh(): Promise<Item[] | null>
|
||||
loadPreview(id: string): Promise<Uint8Array | null>
|
||||
subscribe?(listener: () => void): () => void
|
||||
}
|
||||
|
||||
export type UseDocumentWorkspaceOptions<Item extends DocumentWorkspaceItem> = {
|
||||
source: DocumentWorkspaceSource<Item>
|
||||
refreshInterval?: number
|
||||
refreshOnFocus?: boolean
|
||||
refreshOnReconnect?: boolean
|
||||
previewConcurrency?: number
|
||||
previewMimeType?: string
|
||||
onPreviewError?: (id: string, error: unknown) => void
|
||||
}
|
||||
|
||||
export function useDocumentWorkspace<Item extends DocumentWorkspaceItem>(
|
||||
options: UseDocumentWorkspaceOptions<Item>
|
||||
) {
|
||||
const documents = shallowRef<Item[]>([])
|
||||
const loading = ref(false)
|
||||
const error = shallowRef<unknown>(null)
|
||||
const lastRefreshedAt = shallowRef<Date | null>(null)
|
||||
const previews = createDocumentPreviews({
|
||||
documents,
|
||||
source: options.source,
|
||||
previewConcurrency: options.previewConcurrency,
|
||||
previewMimeType: options.previewMimeType,
|
||||
onPreviewError: options.onPreviewError
|
||||
? (id, error) => options.onPreviewError?.(id, error)
|
||||
: undefined
|
||||
})
|
||||
let refreshPromise: Promise<void> | null = null
|
||||
let refreshQueued = false
|
||||
let disposed = false
|
||||
|
||||
function refresh(): Promise<void> {
|
||||
if (refreshPromise) return refreshPromise
|
||||
loading.value = true
|
||||
error.value = null
|
||||
const nextRefresh = options.source
|
||||
.refresh()
|
||||
.then((items) => {
|
||||
if (!disposed && items) {
|
||||
previews.reconcile(documents.value, items)
|
||||
documents.value = items
|
||||
lastRefreshedAt.value = new Date()
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (!disposed) error.value = reason
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false
|
||||
refreshPromise = null
|
||||
if (refreshQueued && !disposed) {
|
||||
refreshQueued = false
|
||||
void refresh()
|
||||
}
|
||||
})
|
||||
refreshPromise = nextRefresh
|
||||
return nextRefresh
|
||||
}
|
||||
|
||||
function invalidate(): Promise<void> {
|
||||
if (!refreshPromise) return refresh()
|
||||
refreshQueued = true
|
||||
return refreshPromise
|
||||
}
|
||||
|
||||
if (options.refreshOnFocus !== false && IS_BROWSER) {
|
||||
useEventListener(window, 'focus', () => void invalidate())
|
||||
}
|
||||
if (options.refreshOnReconnect !== false && IS_BROWSER) {
|
||||
useEventListener(window, 'online', () => void invalidate())
|
||||
}
|
||||
if (options.refreshInterval && options.refreshInterval > 0) {
|
||||
useIntervalFn(
|
||||
() => {
|
||||
if (typeof document === 'undefined' || document.visibilityState === 'visible') {
|
||||
void invalidate()
|
||||
}
|
||||
},
|
||||
options.refreshInterval,
|
||||
{ immediate: true, immediateCallback: false }
|
||||
)
|
||||
}
|
||||
|
||||
let unsubscribeSource: (() => void) | null = null
|
||||
onMounted(() => {
|
||||
unsubscribeSource = options.source.subscribe?.(() => void invalidate()) ?? null
|
||||
void refresh()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
unsubscribeSource?.()
|
||||
disposed = true
|
||||
previews.dispose()
|
||||
})
|
||||
|
||||
return {
|
||||
documents: readonly(documents) as Readonly<Ref<readonly Item[]>>,
|
||||
loading: readonly(loading),
|
||||
error: readonly(error),
|
||||
lastRefreshedAt: readonly(lastRefreshedAt),
|
||||
previewUrls: previews.previewUrls,
|
||||
previewErrors: previews.previewErrors,
|
||||
hasDocuments: computed(() => documents.value.length > 0),
|
||||
refresh,
|
||||
invalidate,
|
||||
clearPreviews: previews.clearPreviews,
|
||||
loadPreview: previews.loadPreview,
|
||||
previewDirective: previews.previewDirective,
|
||||
previewURL: previews.previewURL
|
||||
}
|
||||
}
|
||||
|
|
@ -82,6 +82,12 @@ export { useAppearance } from '#vue/controls/appearance/use'
|
|||
export { useMask } from '#vue/controls/mask/use'
|
||||
export { useTypography } from '#vue/controls/typography/use'
|
||||
export type { UseTypographyOptions } from '#vue/controls/typography/use'
|
||||
export { useDocumentWorkspace } from '#vue/document/workspace/use'
|
||||
export type {
|
||||
DocumentWorkspaceItem,
|
||||
DocumentWorkspaceSource,
|
||||
UseDocumentWorkspaceOptions
|
||||
} from '#vue/document/workspace/use'
|
||||
export { useExport } from '#vue/document/export/use'
|
||||
export type { ExportFormatId, ExportSetting } from '#vue/document/export/use'
|
||||
export { useFillControls } from '#vue/controls/fill/use'
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { extractFigThumbnailFromReader } from '@open-pencil/fig'
|
||||
|
||||
import { isTauri } from '@/app/tauri/env'
|
||||
|
||||
import {
|
||||
|
|
@ -16,7 +18,16 @@ import type {
|
|||
StorageDocumentMetadata,
|
||||
StorageProviderRuntime
|
||||
} from '../types'
|
||||
import { S3HttpError, deleteObject, getObject, headObject, listObjects, putObject } from './client'
|
||||
import {
|
||||
S3HttpError,
|
||||
deleteObject,
|
||||
getObject,
|
||||
getObjectRange,
|
||||
headObject,
|
||||
headObjectSize,
|
||||
listObjects,
|
||||
putObject
|
||||
} from './client'
|
||||
import { CloudCORSError, formatBrowserCORSHelpMessage, isLikelyCORSOrNetworkError } from './cors'
|
||||
import type { S3CompatibleConfig, S3ConnectionResult } from './types'
|
||||
|
||||
|
|
@ -244,7 +255,15 @@ export function createS3StorageAdapter(runtime: StorageProviderRuntime): S3Stora
|
|||
|
||||
async getThumbnail(id) {
|
||||
const config = await resolveConfig(runtime)
|
||||
return getObject(config, documentThumbnailKey(id))
|
||||
const figKey = documentFigKey(id)
|
||||
const size = await headObjectSize(config, figKey)
|
||||
if (size == null) return null
|
||||
return extractFigThumbnailFromReader({
|
||||
size,
|
||||
async read(start: number, endExclusive: number) {
|
||||
return (await getObjectRange(config, figKey, start, endExclusive)) ?? new Uint8Array()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -164,6 +164,41 @@ export async function headObject(config: S3CompatibleConfig, key: string): Promi
|
|||
return true
|
||||
}
|
||||
|
||||
export async function headObjectSize(
|
||||
config: S3CompatibleConfig,
|
||||
key: string
|
||||
): Promise<number | null> {
|
||||
const res = await s3Request(config, objectURL(config, key), { method: 'HEAD' })
|
||||
if (res.status === 404) return null
|
||||
const sizeHeader = res.headers.get('content-length')
|
||||
if (sizeHeader == null) return null
|
||||
const size = Number(sizeHeader)
|
||||
return Number.isSafeInteger(size) && size >= 0 ? size : null
|
||||
}
|
||||
|
||||
export async function getObjectRange(
|
||||
config: S3CompatibleConfig,
|
||||
key: string,
|
||||
start: number,
|
||||
endExclusive: number
|
||||
): Promise<Uint8Array | null> {
|
||||
if (
|
||||
!Number.isSafeInteger(start) ||
|
||||
start < 0 ||
|
||||
!Number.isSafeInteger(endExclusive) ||
|
||||
endExclusive <= start
|
||||
) {
|
||||
throw new Error('Invalid S3 byte range')
|
||||
}
|
||||
const res = await s3Request(config, objectURL(config, key), {
|
||||
method: 'GET',
|
||||
headers: { Range: `bytes=${start}-${endExclusive - 1}` }
|
||||
})
|
||||
if (res.status === 404) return null
|
||||
if (res.status !== 206) throw new Error('Storage provider did not honor the thumbnail byte range')
|
||||
return new Uint8Array(await res.arrayBuffer())
|
||||
}
|
||||
|
||||
export async function putObject(
|
||||
config: S3CompatibleConfig,
|
||||
key: string,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { getOutbox } from '@/app/storage/sync/outbox'
|
|||
import { setUploadProgress } from '@/app/storage/sync/progress'
|
||||
import { setPendingSyncCount, setSyncUI } from '@/app/storage/sync/status'
|
||||
import type { OutboxJob } from '@/app/storage/sync/types'
|
||||
import { emitStorageWorkspaceEvent } from '@/app/storage/workspace/events'
|
||||
|
||||
const MAX_ATTEMPTS = 8
|
||||
const BASE_BACKOFF_MS = 1500
|
||||
|
|
@ -124,6 +125,11 @@ async function runJob(job: OutboxJob): Promise<void> {
|
|||
{ expectedRevision: job.revision }
|
||||
)
|
||||
await evictLocalFigCache(new Set([job.canvasId]))
|
||||
emitStorageWorkspaceEvent({
|
||||
providerId: providerID,
|
||||
documentId: job.canvasId,
|
||||
kind: 'synced'
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import { extractFigThumbnailFromReader } from '@open-pencil/fig'
|
||||
|
||||
import type { StorageProviderID } from '@/app/integrations/storage/types'
|
||||
import { evictLocalFigCache } from '@/app/storage/cache-eviction'
|
||||
import { getLocalCanvasStore } from '@/app/storage/local-store'
|
||||
import type { LocalCanvasStore } from '@/app/storage/local-store/store'
|
||||
import { enqueuePutCanvas } from '@/app/storage/sync/engine'
|
||||
import { emitStorageWorkspaceEvent } from '@/app/storage/workspace/events'
|
||||
|
||||
export type StoragePersistenceDependencies = {
|
||||
store: LocalCanvasStore
|
||||
|
|
@ -25,14 +28,26 @@ export async function persistStorageCanvasLocally(
|
|||
store: getLocalCanvasStore(),
|
||||
enqueueCanvas: enqueuePutCanvas
|
||||
}
|
||||
const thumbnailBytes = await extractFigThumbnailFromReader({
|
||||
size: options.figBytes.byteLength,
|
||||
async read(start, endExclusive) {
|
||||
return options.figBytes.subarray(start, endExclusive)
|
||||
}
|
||||
})
|
||||
const metadata = await runtime.store.writeCanvas({
|
||||
id: options.canvasId,
|
||||
providerId: options.providerId,
|
||||
name: options.name,
|
||||
figBytes: options.figBytes,
|
||||
thumbBytes: thumbnailBytes,
|
||||
syncStatus: 'pending'
|
||||
})
|
||||
await runtime.enqueueCanvas(options.canvasId, metadata.revision)
|
||||
emitStorageWorkspaceEvent({
|
||||
providerId: options.providerId,
|
||||
documentId: options.canvasId,
|
||||
kind: 'changed'
|
||||
})
|
||||
return { revision: metadata.revision }
|
||||
}
|
||||
|
||||
|
|
|
|||
26
src/app/storage/workspace/events.ts
Normal file
26
src/app/storage/workspace/events.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import type { StorageProviderID } from '@/app/integrations/storage/types'
|
||||
|
||||
export type StorageWorkspaceEvent = {
|
||||
providerId: StorageProviderID
|
||||
documentId?: string
|
||||
kind: 'changed' | 'synced'
|
||||
}
|
||||
|
||||
type StorageWorkspaceListener = (event: StorageWorkspaceEvent) => void
|
||||
|
||||
const listeners = new Set<StorageWorkspaceListener>()
|
||||
|
||||
export function emitStorageWorkspaceEvent(event: StorageWorkspaceEvent): void {
|
||||
for (const listener of listeners) {
|
||||
try {
|
||||
listener(event)
|
||||
} catch (error) {
|
||||
console.error('[Storage] Workspace event listener failed:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function onStorageWorkspaceEvent(listener: StorageWorkspaceListener): () => void {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
}
|
||||
87
src/app/storage/workspace/source.ts
Normal file
87
src/app/storage/workspace/source.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import type { StorageDocument } from '@/app/integrations/storage'
|
||||
import {
|
||||
activeStorageProviderID,
|
||||
createActiveStorageAdapter,
|
||||
storageCredentialStatuses,
|
||||
storagePreferencesComplete,
|
||||
storageProviderRegistry
|
||||
} from '@/app/integrations/storage'
|
||||
import { getLocalCanvasStore } from '@/app/storage/local-store'
|
||||
import { reconcileStorageDocuments } from '@/app/storage/reconcile'
|
||||
import { onStorageWorkspaceEvent } from '@/app/storage/workspace/events'
|
||||
|
||||
export type StorageWorkspaceSnapshot = {
|
||||
documents: StorageDocument[]
|
||||
configured: boolean
|
||||
}
|
||||
|
||||
export function createStorageWorkspaceSource(
|
||||
onSnapshot: (snapshot: StorageWorkspaceSnapshot) => void
|
||||
) {
|
||||
return {
|
||||
subscribe(listener: () => void): () => void {
|
||||
return onStorageWorkspaceEvent((event) => {
|
||||
if (event.providerId === activeStorageProviderID.value) listener()
|
||||
})
|
||||
},
|
||||
|
||||
async refresh(): Promise<StorageDocument[] | null> {
|
||||
const providerID = activeStorageProviderID.value
|
||||
const provider = storageProviderRegistry.get(providerID)
|
||||
const statuses = await storageCredentialStatuses(providerID)
|
||||
const configured =
|
||||
storagePreferencesComplete(providerID) &&
|
||||
provider.credentialFields.every(
|
||||
(field) => !field.required || statuses[field.id] === 'configured'
|
||||
)
|
||||
const localStore = getLocalCanvasStore()
|
||||
const local = (await localStore.listMetas(true)).filter(
|
||||
(metadata) => metadata.providerId === providerID
|
||||
)
|
||||
if (!configured) {
|
||||
const documents = local
|
||||
.filter((metadata) => !metadata.tombstoned)
|
||||
.map((metadata) => ({
|
||||
id: metadata.id,
|
||||
name: metadata.name,
|
||||
updatedAt: metadata.updatedAt,
|
||||
metadataAuthoritative: true
|
||||
}))
|
||||
if (activeStorageProviderID.value !== providerID) return null
|
||||
onSnapshot({ documents, configured })
|
||||
return documents
|
||||
}
|
||||
|
||||
const remote = await createActiveStorageAdapter(providerID).listDocuments()
|
||||
const reconciliation = reconcileStorageDocuments(local, remote)
|
||||
for (const id of reconciliation.localIdsToPurge) await localStore.remove(id)
|
||||
for (const document of reconciliation.remoteDocumentsToSeed) {
|
||||
await localStore.upsertIndexMeta({
|
||||
id: document.id,
|
||||
providerId: providerID,
|
||||
name: document.name,
|
||||
updatedAt: document.updatedAt,
|
||||
syncStatus: 'synced',
|
||||
lastSyncedAt: document.updatedAt,
|
||||
lastSyncError: null
|
||||
})
|
||||
}
|
||||
if (activeStorageProviderID.value !== providerID) return null
|
||||
onSnapshot({ documents: reconciliation.documents, configured })
|
||||
return reconciliation.documents
|
||||
},
|
||||
|
||||
async loadPreview(id: string): Promise<Uint8Array | null> {
|
||||
const providerID = activeStorageProviderID.value
|
||||
const localStore = getLocalCanvasStore()
|
||||
const local = await localStore.readThumb(id)
|
||||
if (local?.byteLength) return local
|
||||
const adapter = createActiveStorageAdapter(providerID)
|
||||
if (!adapter.getThumbnail) return null
|
||||
const remote = await adapter.getThumbnail(id)
|
||||
if (!remote?.byteLength) return null
|
||||
if (activeStorageProviderID.value === providerID) await localStore.writeThumb(id, remote)
|
||||
return remote
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,87 +1,38 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from '@open-pencil/vue'
|
||||
import { useDocumentWorkspace, useI18n } from '@open-pencil/vue'
|
||||
|
||||
import {
|
||||
activeStorageProviderID,
|
||||
createActiveStorageAdapter,
|
||||
storageCredentialStatuses,
|
||||
storagePreferencesComplete,
|
||||
storageProviderRegistry,
|
||||
type StorageDocument
|
||||
} from '@/app/integrations/storage'
|
||||
import { activeStorageProviderID, type StorageDocument } from '@/app/integrations/storage'
|
||||
import { openSettingsDialog, settingsDialogOpen } from '@/app/settings/dialog'
|
||||
import type { CredentialStatus } from '@/app/settings/credentials/types'
|
||||
import { createCanvasId } from '@/app/storage/id'
|
||||
import { reconcileStorageDocuments } from '@/app/storage/reconcile'
|
||||
import { createStorageWorkspaceSource } from '@/app/storage/workspace/source'
|
||||
import AppPlaceholder from '@/components/ui/AppPlaceholder.vue'
|
||||
import { getLocalCanvasStore } from '@/app/storage/local-store'
|
||||
import Tip from '@/components/ui/Tip.vue'
|
||||
import { activeTab, createTab, openStorageDocumentInNewTab } from '@/app/tabs'
|
||||
|
||||
const { dialogs } = useI18n()
|
||||
const router = useRouter()
|
||||
const provider = computed(() => storageProviderRegistry.get(activeStorageProviderID.value))
|
||||
const documents = ref<StorageDocument[]>([])
|
||||
const credentialStatuses = ref<Record<string, CredentialStatus>>({})
|
||||
const configured = computed(
|
||||
() =>
|
||||
storagePreferencesComplete(provider.value.id) &&
|
||||
provider.value.credentialFields.every(
|
||||
(field) => !field.required || credentialStatuses.value[field.id] === 'configured'
|
||||
)
|
||||
)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function paintLocalDocuments(): Promise<void> {
|
||||
const local = (await getLocalCanvasStore().listMetas()).filter(
|
||||
(metadata) => metadata.providerId === activeStorageProviderID.value
|
||||
)
|
||||
documents.value = local.map((metadata) => ({
|
||||
id: metadata.id,
|
||||
name: metadata.name,
|
||||
updatedAt: metadata.updatedAt,
|
||||
metadataAuthoritative: true
|
||||
}))
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
await paintLocalDocuments()
|
||||
try {
|
||||
credentialStatuses.value = await storageCredentialStatuses(provider.value.id)
|
||||
if (!configured.value) {
|
||||
error.value = dialogs.value.storageNotConfigured
|
||||
return
|
||||
}
|
||||
const remote = await createActiveStorageAdapter().listDocuments()
|
||||
const localStore = getLocalCanvasStore()
|
||||
const local = (await localStore.listMetas(true)).filter(
|
||||
(metadata) => metadata.providerId === activeStorageProviderID.value
|
||||
)
|
||||
const reconciliation = reconcileStorageDocuments(local, remote)
|
||||
documents.value = reconciliation.documents
|
||||
|
||||
for (const id of reconciliation.localIdsToPurge) await localStore.remove(id)
|
||||
for (const document of reconciliation.remoteDocumentsToSeed) {
|
||||
await localStore.upsertIndexMeta({
|
||||
id: document.id,
|
||||
providerId: activeStorageProviderID.value,
|
||||
name: document.name,
|
||||
updatedAt: document.updatedAt,
|
||||
syncStatus: 'synced',
|
||||
lastSyncedAt: document.updatedAt,
|
||||
lastSyncError: null
|
||||
})
|
||||
}
|
||||
} catch (reason) {
|
||||
error.value = reason instanceof Error ? reason.message : String(reason)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
const configured = ref(false)
|
||||
const workspace = useDocumentWorkspace({
|
||||
source: createStorageWorkspaceSource((snapshot) => {
|
||||
configured.value = snapshot.configured
|
||||
}),
|
||||
refreshInterval: 60_000,
|
||||
previewConcurrency: 6
|
||||
})
|
||||
const documents = workspace.documents
|
||||
const loading = workspace.loading
|
||||
const errorMessage = computed(() => {
|
||||
const reason = workspace.error.value
|
||||
if (reason == null) return null
|
||||
return reason instanceof Error ? reason.message : String(reason)
|
||||
})
|
||||
const refresh = workspace.refresh
|
||||
const invalidate = workspace.invalidate
|
||||
const clearPreviews = workspace.clearPreviews
|
||||
const previewURL = workspace.previewURL
|
||||
const vWorkspacePreview = workspace.previewDirective
|
||||
|
||||
async function openDocument(document: StorageDocument): Promise<void> {
|
||||
await router.push('/')
|
||||
|
|
@ -106,14 +57,13 @@ async function createDocument(): Promise<void> {
|
|||
await store.saveFigFile()
|
||||
}
|
||||
|
||||
watch(activeStorageProviderID, () => void refresh())
|
||||
|
||||
watch(settingsDialogOpen, (open, wasOpen) => {
|
||||
if (wasOpen && !open) void refresh()
|
||||
watch(activeStorageProviderID, () => {
|
||||
clearPreviews()
|
||||
void invalidate()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
void refresh()
|
||||
watch(settingsDialogOpen, (open, wasOpen) => {
|
||||
if (wasOpen && !open) void invalidate()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
@ -146,18 +96,21 @@ onMounted(() => {
|
|||
|
||||
<section class="mx-auto flex min-h-0 w-full max-w-6xl flex-1 flex-col p-6">
|
||||
<div class="mb-4 flex shrink-0 items-center justify-between">
|
||||
<p v-if="error && configured" class="text-xs text-danger" role="alert">
|
||||
{{ error }}
|
||||
<p v-if="errorMessage && configured" class="text-xs text-danger" role="alert">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
<span v-else />
|
||||
<button
|
||||
v-if="configured"
|
||||
type="button"
|
||||
class="rounded px-2 py-1 text-xs text-muted hover:bg-hover hover:text-surface"
|
||||
@click="refresh"
|
||||
>
|
||||
{{ dialogs.refresh }}
|
||||
</button>
|
||||
<Tip v-if="configured" :label="dialogs.refresh">
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-7 items-center justify-center rounded text-muted hover:bg-hover hover:text-surface disabled:opacity-50"
|
||||
:aria-label="dialogs.refresh"
|
||||
:disabled="loading"
|
||||
@click="refresh"
|
||||
>
|
||||
<icon-lucide-refresh-cw class="size-3.5" :class="{ 'animate-spin': loading }" />
|
||||
</button>
|
||||
</Tip>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
|
@ -172,7 +125,18 @@ onMounted(() => {
|
|||
:data-document-id="document.id"
|
||||
@click="openDocument(document)"
|
||||
>
|
||||
<div class="aspect-[4/3] bg-panel-field" />
|
||||
<div
|
||||
v-workspace-preview="document.id"
|
||||
class="flex aspect-[4/3] items-center justify-center bg-panel-field"
|
||||
>
|
||||
<img
|
||||
v-if="previewURL(document.id)"
|
||||
:src="previewURL(document.id) ?? undefined"
|
||||
alt=""
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
<icon-lucide-file-image v-else class="size-6 text-muted/50" />
|
||||
</div>
|
||||
<div class="border-t border-border p-3">
|
||||
<p class="truncate text-xs font-medium">{{ document.name }}</p>
|
||||
<p class="mt-1 text-[10px] text-muted">
|
||||
|
|
|
|||
|
|
@ -4,8 +4,12 @@ import { expect, test } from '@playwright/test'
|
|||
|
||||
import { CanvasHelper } from '#tests/helpers/canvas'
|
||||
|
||||
test('configured storage lists and opens a remote document', async ({ page }) => {
|
||||
test('configured storage lists previews through ranges before opening the document', async ({
|
||||
page
|
||||
}) => {
|
||||
const fixture = readFileSync('tests/fixtures/gold-preview.fig')
|
||||
let fullDocumentGets = 0
|
||||
let rangeGets = 0
|
||||
await page.route('https://s3.example.com/**', async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.searchParams.get('list-type') === '2') {
|
||||
|
|
@ -29,7 +33,47 @@ test('configured storage lists and opens a remote document', async ({ page }) =>
|
|||
})
|
||||
return
|
||||
}
|
||||
if (url.pathname.endsWith('/remote-1.fig') && route.request().headers().range) {
|
||||
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
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 206,
|
||||
headers: {
|
||||
'Content-Range': `bytes ${start}-${end}/${fixture.byteLength}`
|
||||
},
|
||||
contentType: 'application/octet-stream',
|
||||
body: fixture.subarray(start, end + 1)
|
||||
})
|
||||
rangeGets++
|
||||
return
|
||||
}
|
||||
if (url.pathname.endsWith('/remote-1.fig') && route.request().method() === 'HEAD') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'Content-Length': String(fixture.byteLength) }
|
||||
})
|
||||
return
|
||||
}
|
||||
if (url.pathname.endsWith('/remote-1.fig')) {
|
||||
fullDocumentGets++
|
||||
await route.fulfill({ contentType: 'application/octet-stream', body: fixture })
|
||||
return
|
||||
}
|
||||
|
|
@ -54,11 +98,17 @@ test('configured storage lists and opens a remote document', async ({ page }) =>
|
|||
await page.getByTestId('settings-storage-open-workspace').click()
|
||||
await expect(page.getByTestId('storage-workspace')).toBeVisible()
|
||||
await expect(page.getByText('Remote design')).toBeVisible()
|
||||
const preview = page.locator('[data-document-id="remote-1"] img')
|
||||
await expect(preview).toBeVisible()
|
||||
await expect(preview).toHaveAttribute('src', /^blob:/)
|
||||
expect(rangeGets).toBe(3)
|
||||
expect(fullDocumentGets).toBe(0)
|
||||
|
||||
await page.locator('[data-document-id="remote-1"]').click()
|
||||
await expect(page).toHaveURL(/\/$/)
|
||||
await canvas.waitForInit()
|
||||
await expect(page.getByText('Remote design').first()).toBeVisible()
|
||||
expect(fullDocumentGets).toBe(1)
|
||||
})
|
||||
|
||||
test('storage workspace directs unconfigured users to Settings', async ({ page }) => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { describe, expect, test, vi } from 'bun:test'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { createMemoryLocalCanvasStore } from '@/app/storage/local-store'
|
||||
import { persistStorageCanvasLocally } from '@/app/storage/sync/persist'
|
||||
|
|
@ -30,4 +31,26 @@ describe('local-first storage persistence', () => {
|
|||
providerId: 's3-compatible'
|
||||
})
|
||||
})
|
||||
|
||||
test('stores the embedded preview with the document', async () => {
|
||||
const store = createMemoryLocalCanvasStore()
|
||||
const enqueueCanvas = vi.fn(() => Promise.resolve())
|
||||
const figBytes = new Uint8Array(readFileSync('tests/fixtures/gold-preview.fig'))
|
||||
|
||||
await persistStorageCanvasLocally(
|
||||
{
|
||||
providerId: 's3-compatible',
|
||||
canvasId: 'canvas-preview',
|
||||
name: 'Preview design',
|
||||
figBytes
|
||||
},
|
||||
{ store, enqueueCanvas }
|
||||
)
|
||||
|
||||
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])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
77
tests/engine/io/fig/thumbnail-range.test.ts
Normal file
77
tests/engine/io/fig/thumbnail-range.test.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
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]>) {
|
||||
return {
|
||||
size: bytes.byteLength,
|
||||
async read(start: number, endExclusive: number) {
|
||||
ranges.push([start, endExclusive])
|
||||
return bytes.slice(start, endExclusive)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('fig ranged thumbnail extraction', () => {
|
||||
test('extracts thumbnail.png without reading the complete fig', async () => {
|
||||
const bytes = new Uint8Array(readFileSync('tests/fixtures/gold-preview.fig'))
|
||||
const ranges: Array<[number, number]> = []
|
||||
const thumbnail = await extractFigThumbnailFromReader(memoryReader(bytes, ranges), {
|
||||
maxTailBytes: 4 * 1024 * 1024
|
||||
})
|
||||
|
||||
expect(thumbnail?.subarray(0, 8)).toEqual(
|
||||
new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||
)
|
||||
expect(ranges.length).toBeGreaterThanOrEqual(2)
|
||||
expect(ranges.every(([start, end]) => start !== 0 || end !== bytes.byteLength)).toBe(true)
|
||||
expect(ranges.reduce((total, [start, end]) => total + end - start, 0)).toBeLessThan(
|
||||
bytes.byteLength
|
||||
)
|
||||
})
|
||||
|
||||
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('returns null when deflated thumbnail data is corrupt', async () => {
|
||||
const png = new Uint8Array([
|
||||
0x89,
|
||||
0x50,
|
||||
0x4e,
|
||||
0x47,
|
||||
0x0d,
|
||||
0x0a,
|
||||
0x1a,
|
||||
0x0a,
|
||||
...Array.from({ length: 64 }, () => 1)
|
||||
])
|
||||
const bytes = zipSync({ 'thumbnail.png': png })
|
||||
const name = new TextEncoder().encode('thumbnail.png')
|
||||
const nameOffset = bytes.findIndex((byte, index) =>
|
||||
name.every((nameByte, nameIndex) => bytes[index + nameIndex] === nameByte)
|
||||
)
|
||||
const headerOffset = nameOffset - 30
|
||||
const header = new DataView(bytes.buffer, bytes.byteOffset + headerOffset, 30)
|
||||
expect(header.getUint16(8, true)).toBe(8)
|
||||
const dataOffset = nameOffset + name.byteLength + header.getUint16(28, true)
|
||||
bytes.fill(0xff, dataOffset, dataOffset + header.getUint32(18, true))
|
||||
|
||||
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, []), {
|
||||
maxOutputBytes: 32
|
||||
})
|
||||
expect(thumbnail).toBeNull()
|
||||
})
|
||||
})
|
||||
257
tests/engine/vue/document-workspace.test.ts
Normal file
257
tests/engine/vue/document-workspace.test.ts
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
import { afterEach, describe, expect, test, vi } from 'bun:test'
|
||||
|
||||
import { createRenderer, defineComponent, h, type ComponentPublicInstance } from 'vue'
|
||||
|
||||
import {
|
||||
useDocumentWorkspace,
|
||||
type DocumentWorkspaceItem,
|
||||
type DocumentWorkspaceSource
|
||||
} from '@open-pencil/vue'
|
||||
|
||||
type HostNode = {
|
||||
children: HostNode[]
|
||||
parent: HostNode | null
|
||||
text: string
|
||||
}
|
||||
|
||||
type Deferred<Value> = {
|
||||
promise: Promise<Value>
|
||||
resolve(value: Value): void
|
||||
}
|
||||
|
||||
function deferred<Value>(): Deferred<Value> {
|
||||
let resolvePromise: ((value: Value) => void) | null = null
|
||||
const promise = new Promise<Value>((resolve) => {
|
||||
resolvePromise = resolve
|
||||
})
|
||||
return {
|
||||
promise,
|
||||
resolve(value) {
|
||||
resolvePromise?.(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hostNode(text = ''): HostNode {
|
||||
return { children: [], parent: null, text }
|
||||
}
|
||||
|
||||
const renderer = createRenderer<HostNode, HostNode>({
|
||||
patchProp() {
|
||||
return undefined
|
||||
},
|
||||
insert(child, parent, anchor) {
|
||||
child.parent = parent
|
||||
const index = anchor ? parent.children.indexOf(anchor) : -1
|
||||
if (index < 0) parent.children.push(child)
|
||||
else parent.children.splice(index, 0, child)
|
||||
},
|
||||
remove(child) {
|
||||
const parent = child.parent
|
||||
if (!parent) return
|
||||
const index = parent.children.indexOf(child)
|
||||
if (index !== -1) parent.children.splice(index, 1)
|
||||
child.parent = null
|
||||
},
|
||||
createElement() {
|
||||
return hostNode()
|
||||
},
|
||||
createText: hostNode,
|
||||
createComment: hostNode,
|
||||
setText(node, text) {
|
||||
node.text = text
|
||||
},
|
||||
setElementText(node, text) {
|
||||
node.text = text
|
||||
},
|
||||
parentNode(node) {
|
||||
return node.parent
|
||||
},
|
||||
nextSibling(node) {
|
||||
const parent = node.parent
|
||||
if (!parent) return null
|
||||
return parent.children[parent.children.indexOf(node) + 1] ?? null
|
||||
},
|
||||
querySelector() {
|
||||
return null
|
||||
},
|
||||
setScopeId() {
|
||||
return undefined
|
||||
},
|
||||
insertStaticContent(content, parent, anchor) {
|
||||
const node = hostNode(content)
|
||||
this.insert(node, parent, anchor)
|
||||
return [node, node]
|
||||
}
|
||||
})
|
||||
|
||||
type Workspace = ReturnType<typeof useDocumentWorkspace<DocumentWorkspaceItem>>
|
||||
|
||||
type WorkspaceHolder = { current: Workspace | null }
|
||||
|
||||
async function flushTasks(): Promise<void> {
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 0)
|
||||
})
|
||||
}
|
||||
|
||||
function mountWorkspace(
|
||||
source: DocumentWorkspaceSource<DocumentWorkspaceItem>,
|
||||
options: { previewConcurrency?: number } = {}
|
||||
): { workspace: Workspace; unmount(): void } {
|
||||
const holder: WorkspaceHolder = { current: null }
|
||||
const component = defineComponent({
|
||||
setup() {
|
||||
holder.current = useDocumentWorkspace({
|
||||
source,
|
||||
previewConcurrency: options.previewConcurrency,
|
||||
refreshOnFocus: false,
|
||||
refreshOnReconnect: false
|
||||
})
|
||||
return () => h('div')
|
||||
}
|
||||
})
|
||||
const app = renderer.createApp(component)
|
||||
app.mount(hostNode()) as ComponentPublicInstance
|
||||
const workspace = holder.current
|
||||
if (workspace == null) throw new Error('Workspace composable did not initialize')
|
||||
return { workspace, unmount: () => app.unmount() }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('useDocumentWorkspace', () => {
|
||||
test('shares an in-flight refresh and responds to source events', async () => {
|
||||
const firstRefresh = deferred<DocumentWorkspaceItem[]>()
|
||||
let sourceListener: (() => void) | null = null
|
||||
const refresh = vi
|
||||
.fn<() => Promise<DocumentWorkspaceItem[]>>()
|
||||
.mockImplementationOnce(() => firstRefresh.promise)
|
||||
.mockResolvedValueOnce([{ id: 'second', name: 'Second', updatedAt: '2026-08-10' }])
|
||||
const mounted = mountWorkspace({
|
||||
refresh,
|
||||
loadPreview: async () => null,
|
||||
subscribe(listener) {
|
||||
sourceListener = listener
|
||||
return () => {
|
||||
sourceListener = null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const inFlightA = mounted.workspace.refresh()
|
||||
const inFlightB = mounted.workspace.refresh()
|
||||
expect(inFlightA).toBe(inFlightB)
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
|
||||
sourceListener?.()
|
||||
sourceListener?.()
|
||||
firstRefresh.resolve([{ id: 'first', name: 'First', updatedAt: '2026-08-09' }])
|
||||
await inFlightA
|
||||
await flushTasks()
|
||||
expect(refresh).toHaveBeenCalledTimes(2)
|
||||
expect(mounted.workspace.documents.value.map(({ id }) => id)).toEqual(['second'])
|
||||
|
||||
mounted.unmount()
|
||||
expect(sourceListener).toBeNull()
|
||||
})
|
||||
|
||||
test('invalidates an in-flight preview when the document changes', async () => {
|
||||
const previewLoad = deferred<Uint8Array | null>()
|
||||
const freshPreviewLoad = deferred<Uint8Array | null>()
|
||||
const refresh = vi
|
||||
.fn<() => Promise<DocumentWorkspaceItem[]>>()
|
||||
.mockResolvedValueOnce([{ id: 'one', name: 'One', updatedAt: 'first' }])
|
||||
.mockResolvedValueOnce([{ id: 'one', name: 'One', updatedAt: 'second' }])
|
||||
const loadPreview = vi
|
||||
.fn<() => Promise<Uint8Array | null>>()
|
||||
.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 () => {
|
||||
throw 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<string, Deferred<Uint8Array | null>>()
|
||||
const loadPreview = vi.fn((id: string) => {
|
||||
const load = deferred<Uint8Array | null>()
|
||||
loads.set(id, load)
|
||||
return load.promise
|
||||
})
|
||||
const createObjectURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:preview')
|
||||
const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined)
|
||||
const mounted = mountWorkspace(
|
||||
{ refresh: async () => [], loadPreview },
|
||||
{ previewConcurrency: 2 }
|
||||
)
|
||||
|
||||
mounted.workspace.loadPreview('one')
|
||||
mounted.workspace.loadPreview('one')
|
||||
mounted.workspace.loadPreview('two')
|
||||
mounted.workspace.loadPreview('three')
|
||||
expect(loadPreview.mock.calls.map((call) => call[0])).toEqual(['one', 'two'])
|
||||
|
||||
loads.get('one')?.resolve(new Uint8Array([1]))
|
||||
await loads.get('one')?.promise
|
||||
await flushTasks()
|
||||
expect(loadPreview.mock.calls.map((call) => call[0])).toEqual(['one', 'two', 'three'])
|
||||
expect(createObjectURL).toHaveBeenCalledTimes(1)
|
||||
|
||||
mounted.unmount()
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith('blob:preview')
|
||||
|
||||
loads.get('two')?.resolve(new Uint8Array([2]))
|
||||
loads.get('three')?.resolve(new Uint8Array([3]))
|
||||
await Promise.all([loads.get('two')?.promise, loads.get('three')?.promise])
|
||||
await flushTasks()
|
||||
expect(createObjectURL).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue