mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-01 14:59:19 +08:00
fix(files): reserve image layout space so images stop reflowing on load (#6299)
* fix(files): reserve image layout space so images stop reflowing on load
A markdown image with no stored dimensions reserved zero vertical space until
it downloaded, then snapped to its natural height and pushed content below it
down (cumulative layout shift). Reserve the box up front from the image's
intrinsic aspect ratio instead.
Store intrinsic width/height as workspace_file metadata (not in the markdown —
it stays clean ``), read it synchronously from the already-loaded file
list to reserve a responsive aspect-ratio box on first render, and lazily
backfill it once per image on first view via a write-gated, idempotent PATCH.
The node view falls back to on-load measurement for the first-ever view and for
external images. Images stay fluid (max-width:100%, height:auto).
* fix(files): address review — reserve on stale memo, clear dims on content swap
- onLoad guards on the memoized storedDimensions the render uses (not a fresh
cache read), so a sibling's non-reactive backfill can't leave a view unreserved.
- updateWorkspaceFileContent clears width/height when it swaps bytes, so stale
dimensions can't be reserved for new content (and the null re-enables backfill).
- Keep optimistically-cached dimensions when the PATCH fails (correct measurement;
a 403/transient error shouldn't wipe sibling reservations).
- Test imports the sibling via the absolute @/ path.
* fix(files): re-derive image dimensions on content swap instead of clearing
Clearing width/height to NULL on a content swap reopened the width IS NULL
backfill path, so a late fire-and-forget PATCH for the previous image could
write its stale size onto the new content. Instead, measure the new bytes'
intrinsic dimensions server-side (image-size, headers only) and store those
(or null for a non-image), so the row always matches the current content and a
stale backfill can't apply.
* fix(files): self-heal image dimensions from the browser instead of server-measuring
Round-3 review: server-side image-size returns raw (non-EXIF) dimensions, and
clearing dims on content swap reopened the stale-PATCH race for non-image or
unmeasurable content. Move authority to the browser's own naturalWidth/Height
(EXIF-correct): the node view reserves from it and reports on any mismatch, and
updateWorkspaceFileDimensions overwrites (no width IS NULL gate) so stale values
self-correct on the next view. Reverts the server-side measurement and the
content-swap dimension touch entirely.
* fix(files): clear image dimensions on content swap (completes self-heal)
The self-heal rework left the old image's dimensions in the row after a content
replacement, so the next view of the new bytes reserved a wrong-sized box before
correcting. Clear width/height on the content-swap write so the row never
describes stale content: the next view falls back to the baseline first-load
reflow and the browser's measurement backfills the correct size. No server-side
decode (EXIF-safe), and the client's overwrite-on-mismatch handles a late PATCH.
* fix(files): guard dimension writes by content key so a stale PATCH can't persist
Ties the dimensions write to the storage key the client measured. The key is
regenerated on every content replacement, so an in-flight PATCH measured against
superseded bytes is rejected at the DB (WHERE key = measured key) instead of
persisting the old aspect ratio for new content. Closes the last stale-ordering
window Greptile flagged — the write is now content-version-conditioned, not just
corrected on the next render.
* chore(files): fix stale route TSDoc and hoist a regex literal (cleanup pass)
Post-review /cleanup: the dimensions route TSDoc still described backfill-once
behavior (now overwrite-on-mismatch via the content-key CAS); the bare-pixel
width regex is hoisted to module scope. No behavior change.
* fix(files): reflect the content-version guard outcome in the dimensions response
The route returned success:true even when updateWorkspaceFileDimensions matched
0 rows (the CAS rejected a write whose measured key no longer matches the row).
Return success:<whether a row was written> and widen the contract response to
{ success: boolean }. Not an error path — the client's next measurement persists
once its file list has the new key; this just stops the API claiming a persist
that did not happen.
* fix(files): reconcile the cache when a dimension write is content-version-rejected
Previously the client discarded a success:false (CAS-rejected) response, leaving
its optimistic patch — which is for superseded bytes — lingering in the file-list
cache. On rejection, invalidate the list so the cache reconciles with the new
content (whose real size persists on its next load). Deliberately NOT a retry:
re-sending the old measurement under the new key would write the wrong size. A
transport error / read-only 403 still keeps the optimistic value (it's the real
displayed size).
* docs(files): align stale dimension docs with the overwrite/self-heal behavior
Cleanup audit: the ImageDimensionsSource/reportImageDimensions interface docs and
one route log string still said backfill-once/no-op; the mechanism overwrites on
mismatch to self-correct. Wording only, no behavior change.
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { authMockFns, permissionsMock, permissionsMockFns } from '@sim/testing'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockUpdateWorkspaceFileDimensions } = vi.hoisted(() => ({
|
||||
mockUpdateWorkspaceFileDimensions: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
|
||||
updateWorkspaceFileDimensions: mockUpdateWorkspaceFileDimensions,
|
||||
}))
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
|
||||
|
||||
const WS = '7727ef3f-8cf6-4686-b063-2bb006a10785'
|
||||
const FILE = 'wf_abc123'
|
||||
const KEY = 'workspace/7727ef3f/screenshot.png'
|
||||
|
||||
import { PATCH } from '@/app/api/workspaces/[id]/files/[fileId]/dimensions/route'
|
||||
|
||||
const routeContext = { params: Promise.resolve({ id: WS, fileId: FILE }) }
|
||||
|
||||
function buildRequest(body: unknown): NextRequest {
|
||||
return new NextRequest(`http://localhost/api/workspaces/${WS}/files/${FILE}/dimensions`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
describe('PATCH /api/workspaces/[id]/files/[fileId]/dimensions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
|
||||
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
|
||||
mockUpdateWorkspaceFileDimensions.mockResolvedValue(true)
|
||||
})
|
||||
|
||||
it('stores dimensions for a writer, keyed to the content version', async () => {
|
||||
const res = await PATCH(buildRequest({ key: KEY, width: 1600, height: 900 }), routeContext)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: true })
|
||||
expect(mockUpdateWorkspaceFileDimensions).toHaveBeenCalledWith(WS, FILE, {
|
||||
key: KEY,
|
||||
width: 1600,
|
||||
height: 900,
|
||||
})
|
||||
})
|
||||
|
||||
it('allows an admin', async () => {
|
||||
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('admin')
|
||||
const res = await PATCH(buildRequest({ key: KEY, width: 10, height: 20 }), routeContext)
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockUpdateWorkspaceFileDimensions).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('reports success:false when the content-version guard rejects the write (key changed)', async () => {
|
||||
mockUpdateWorkspaceFileDimensions.mockResolvedValue(false)
|
||||
const res = await PATCH(buildRequest({ key: KEY, width: 10, height: 20 }), routeContext)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: false })
|
||||
})
|
||||
|
||||
it('rejects an unauthenticated caller before touching the DB', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValue(null)
|
||||
const res = await PATCH(buildRequest({ key: KEY, width: 10, height: 10 }), routeContext)
|
||||
expect(res.status).toBe(401)
|
||||
expect(mockUpdateWorkspaceFileDimensions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a read-only member (backfill requires write)', async () => {
|
||||
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('read')
|
||||
const res = await PATCH(buildRequest({ key: KEY, width: 10, height: 10 }), routeContext)
|
||||
expect(res.status).toBe(403)
|
||||
expect(mockUpdateWorkspaceFileDimensions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a missing key or non-positive / non-integer dimensions', async () => {
|
||||
for (const body of [
|
||||
{ width: 10, height: 10 }, // missing key
|
||||
{ key: KEY, width: 0, height: 10 },
|
||||
{ key: KEY, width: 10, height: -5 },
|
||||
{ key: KEY, width: 10.5, height: 10 },
|
||||
{ key: KEY, width: 10 },
|
||||
]) {
|
||||
const res = await PATCH(buildRequest(body), routeContext)
|
||||
expect(res.status).toBe(400)
|
||||
}
|
||||
expect(mockUpdateWorkspaceFileDimensions).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { updateWorkspaceFileDimensionsContract } from '@/lib/api/contracts/workspace-files'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { updateWorkspaceFileDimensions } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('WorkspaceFileDimensionsAPI')
|
||||
|
||||
/**
|
||||
* PATCH /api/workspaces/[id]/files/[fileId]/dimensions
|
||||
*
|
||||
* Store an image file's intrinsic pixel dimensions — a pure rendering hint the editor uses to reserve
|
||||
* layout space before the image loads. Requires write permission. The write commits whenever the row
|
||||
* still holds the measured storage key, overwriting any stale value so a wrong size self-corrects; the
|
||||
* client reports only on a real mismatch, so this is not storm-y despite not being a backfill-once no-op.
|
||||
*/
|
||||
export const PATCH = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(updateWorkspaceFileDimensionsContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id: workspaceId, fileId } = parsed.data.params
|
||||
const { key, width, height } = parsed.data.body
|
||||
|
||||
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
|
||||
if (permission !== 'admin' && permission !== 'write') {
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
|
||||
}
|
||||
|
||||
try {
|
||||
// `written` is false when the content-version guard rejected the write (the row's storage key no
|
||||
// longer matches the key the client measured — the content was replaced since). That is not an
|
||||
// error; the client's next measurement, once its file list has the new key, persists correctly.
|
||||
const written = await updateWorkspaceFileDimensions(workspaceId, fileId, {
|
||||
key,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
return NextResponse.json({ success: written })
|
||||
} catch (error) {
|
||||
logger.error('Failed to store workspace file dimensions', {
|
||||
workspaceId,
|
||||
fileId,
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
return NextResponse.json({ error: 'Failed to update dimensions' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -5,7 +5,11 @@ import { Music } from '@sim/emcn/icons'
|
||||
import dynamic from 'next/dynamic'
|
||||
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
|
||||
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
|
||||
import { useWorkspaceFileBinary, useWorkspaceFileContent } from '@/hooks/queries/workspace-files'
|
||||
import {
|
||||
useWorkspaceFileBinary,
|
||||
useWorkspaceFileContent,
|
||||
useWorkspaceImageDimensionsAdapter,
|
||||
} from '@/hooks/queries/workspace-files'
|
||||
import {
|
||||
createWorkspaceFileContentSource,
|
||||
type FileContentSource,
|
||||
@@ -126,9 +130,10 @@ interface FileViewerProps {
|
||||
|
||||
export function FileViewer(props: FileViewerProps) {
|
||||
const { contentSource, workspaceId } = props
|
||||
const imageDimensions = useWorkspaceImageDimensionsAdapter(workspaceId)
|
||||
const source = useMemo(
|
||||
() => contentSource ?? createWorkspaceFileContentSource(workspaceId),
|
||||
[contentSource, workspaceId]
|
||||
() => contentSource ?? createWorkspaceFileContentSource(workspaceId, imageDimensions),
|
||||
[contentSource, workspaceId, imageDimensions]
|
||||
)
|
||||
return (
|
||||
<FileContentSourceProvider value={source}>
|
||||
|
||||
+64
-12
@@ -1,15 +1,18 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { type CSSProperties, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { cn } from '@sim/emcn'
|
||||
import { NodeSelection, Plugin } from '@tiptap/pm/state'
|
||||
import type { ReactNodeViewProps } from '@tiptap/react'
|
||||
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
|
||||
import { useFileContentSource } from '@/hooks/use-file-content-source'
|
||||
import { type ImageDimensions, useFileContentSource } from '@/hooks/use-file-content-source'
|
||||
import { MarkdownImage } from './image-schema'
|
||||
import { normalizeLinkHref } from './markdown-fidelity'
|
||||
import { useEditorEditable } from './use-editor-editable'
|
||||
|
||||
const MIN_WIDTH = 64
|
||||
|
||||
/** A bare pixel count (`"640"`) that needs a `px` suffix, vs. an already-unit'd width (`"50%"`). */
|
||||
const BARE_PIXEL_WIDTH = /^\d+$/
|
||||
|
||||
/**
|
||||
* Drag-to-resize image node view (handle at the bottom-right, revealed on selection). Dragging
|
||||
* commits the new pixel width to the `width` attribute, which serializes to `<img width>`.
|
||||
@@ -24,6 +27,11 @@ function ResizableImageView({ node, updateAttributes, selected, editor }: ReactN
|
||||
const [dragWidth, setDragWidth] = useState<number | null>(null)
|
||||
/** Whether the current src failed to load; reset on src change so a retried/edited src can load. */
|
||||
const [failed, setFailed] = useState(false)
|
||||
/**
|
||||
* Intrinsic dimensions measured from the loaded image — holds the aspect-ratio box for THIS view when
|
||||
* the content source has no stored dimensions yet (the first-ever view of an image). Reset on src change.
|
||||
*/
|
||||
const [measuredDimensions, setMeasuredDimensions] = useState<ImageDimensions | null>(null)
|
||||
const attrs = node.attrs as {
|
||||
src?: string
|
||||
alt?: string
|
||||
@@ -33,7 +41,16 @@ function ResizableImageView({ node, updateAttributes, selected, editor }: ReactN
|
||||
}
|
||||
|
||||
useEffect(() => () => dragAbortRef.current?.abort(), [])
|
||||
useEffect(() => setFailed(false), [attrs.src])
|
||||
|
||||
// Reset the load-failure flag and this-session measurement when the src changes — adjusted during
|
||||
// render (not in an effect) so the previous image's aspect-ratio box never paints for a frame. A `key`
|
||||
// remount isn't available here: TipTap owns this node view's instantiation.
|
||||
const [prevSrc, setPrevSrc] = useState(attrs.src)
|
||||
if (prevSrc !== attrs.src) {
|
||||
setPrevSrc(attrs.src)
|
||||
setFailed(false)
|
||||
setMeasuredDimensions(null)
|
||||
}
|
||||
|
||||
const startResize = (event: React.PointerEvent) => {
|
||||
event.preventDefault()
|
||||
@@ -69,16 +86,34 @@ function ResizableImageView({ node, updateAttributes, selected, editor }: ReactN
|
||||
}
|
||||
|
||||
const committedWidth = attrs.width
|
||||
? /^\d+$/.test(attrs.width)
|
||||
? BARE_PIXEL_WIDTH.test(attrs.width)
|
||||
? `${attrs.width}px`
|
||||
: attrs.width
|
||||
: undefined
|
||||
const widthStyle =
|
||||
// Stored intrinsic dimensions reserve the box on the very first render. Memoized on the src (not the
|
||||
// live drag width) so a resize drag never re-scans the file list. Falls back to what we measured on
|
||||
// load this session for a first-ever view the metadata hasn't caught up on.
|
||||
const storedDimensions = useMemo(
|
||||
() => source.getImageDimensions?.(attrs.src) ?? null,
|
||||
[source, attrs.src]
|
||||
)
|
||||
// The browser's post-load measurement is authoritative — EXIF-corrected, and correct even when the
|
||||
// stored value is stale (e.g. left over after the file's content was replaced) — so it wins once
|
||||
// available; stored metadata only reserves the box pre-load. Equal in the common case, so no shift.
|
||||
const intrinsicDimensions = measuredDimensions ?? storedDimensions
|
||||
const displayWidth =
|
||||
dragWidth !== null
|
||||
? { width: `${dragWidth}px` }
|
||||
: committedWidth
|
||||
? { width: committedWidth }
|
||||
: undefined
|
||||
? `${dragWidth}px`
|
||||
: (committedWidth ?? (intrinsicDimensions ? `${intrinsicDimensions.width}px` : undefined))
|
||||
// width + aspect-ratio (with `max-w-full`/`h-auto` from the class list) reserves a responsive box the
|
||||
// image can't reflow into, per the CLS-avoidance pattern for known-ratio responsive images. React drops
|
||||
// the undefined keys, so an unmeasured image simply gets no reservation (its prior behavior).
|
||||
const imageStyle: CSSProperties = {
|
||||
width: displayWidth,
|
||||
aspectRatio: intrinsicDimensions
|
||||
? `${intrinsicDimensions.width} / ${intrinsicDimensions.height}`
|
||||
: undefined,
|
||||
}
|
||||
|
||||
// Sanitize the linked-image target before rendering the anchor — a parsed markdown href is
|
||||
// untrusted and could be `javascript:`/`data:`; an unsafe value drops the link (image only).
|
||||
@@ -99,11 +134,28 @@ function ResizableImageView({ node, updateAttributes, selected, editor }: ReactN
|
||||
// the resize button sits outside this element, so it keeps its own pointer behavior.)
|
||||
draggable={editable}
|
||||
data-drag-handle={editable ? '' : undefined}
|
||||
style={widthStyle}
|
||||
style={imageStyle}
|
||||
onError={() => setFailed(true)}
|
||||
onLoad={() => setFailed(false)}
|
||||
onLoad={(event) => {
|
||||
setFailed(false)
|
||||
const { naturalWidth, naturalHeight } = event.currentTarget
|
||||
if (naturalWidth <= 0 || naturalHeight <= 0) return
|
||||
// The browser's measurement is authoritative. Reserve from it and persist whenever the stored
|
||||
// metadata is absent or disagrees (EXIF-rotated, or stale after a content swap), so a wrong value
|
||||
// self-corrects instead of sticking. Compare the memoized `storedDimensions` the render uses, NOT
|
||||
// a fresh cache read — the memo is non-reactive, and this keeps the guard consistent with render.
|
||||
if (
|
||||
storedDimensions &&
|
||||
storedDimensions.width === naturalWidth &&
|
||||
storedDimensions.height === naturalHeight
|
||||
) {
|
||||
return
|
||||
}
|
||||
setMeasuredDimensions({ width: naturalWidth, height: naturalHeight })
|
||||
source.reportImageDimensions?.(attrs.src, { width: naturalWidth, height: naturalHeight })
|
||||
}}
|
||||
className={cn(
|
||||
'block max-w-full rounded-lg border border-[var(--border)]',
|
||||
'block h-auto max-w-full rounded-lg border border-[var(--border)]',
|
||||
editable && 'cursor-grab',
|
||||
failed &&
|
||||
'min-h-[72px] min-w-[140px] bg-[var(--surface-5)] p-3 text-[var(--text-muted)] text-caption'
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
|
||||
import { findWorkspaceFileBySrc } from '@/hooks/queries/utils/find-workspace-file-by-src'
|
||||
|
||||
function record(over: Partial<WorkspaceFileRecord>): WorkspaceFileRecord {
|
||||
return { id: 'wf_x', key: 'workspace/ws1/x.png', ...over } as WorkspaceFileRecord
|
||||
}
|
||||
|
||||
const records = [
|
||||
record({ id: 'wf_a', key: 'workspace/ws1/a.png' }),
|
||||
record({ id: 'wf_b', key: 'workspace/ws1/b.png' }),
|
||||
]
|
||||
|
||||
const serveUrl = (key: string) => `/api/files/serve/${encodeURIComponent(key)}?context=workspace`
|
||||
|
||||
describe('findWorkspaceFileBySrc', () => {
|
||||
it('matches a serve URL by storage key', () => {
|
||||
expect(findWorkspaceFileBySrc(records, serveUrl('workspace/ws1/b.png'))?.id).toBe('wf_b')
|
||||
})
|
||||
|
||||
it('matches a /api/files/view/<id> URL by file id', () => {
|
||||
expect(findWorkspaceFileBySrc(records, '/api/files/view/wf_a')?.id).toBe('wf_a')
|
||||
})
|
||||
|
||||
it('matches a /workspace/<ws>/files/<id> URL by file id', () => {
|
||||
expect(findWorkspaceFileBySrc(records, '/workspace/ws1/files/wf_b')?.id).toBe('wf_b')
|
||||
})
|
||||
|
||||
it('returns undefined for a serve URL whose key is not in the list', () => {
|
||||
expect(findWorkspaceFileBySrc(records, serveUrl('workspace/ws1/missing.png'))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined for external, data:, and undefined srcs', () => {
|
||||
expect(findWorkspaceFileBySrc(records, 'https://example.com/x.png')).toBeUndefined()
|
||||
expect(findWorkspaceFileBySrc(records, 'data:image/png;base64,AAAA')).toBeUndefined()
|
||||
expect(findWorkspaceFileBySrc(records, undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined when the file list has not loaded yet', () => {
|
||||
expect(findWorkspaceFileBySrc(undefined, '/api/files/view/wf_a')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
|
||||
import { extractEmbeddedFileRef } from '@/lib/uploads/utils/embedded-image-ref'
|
||||
|
||||
/**
|
||||
* Resolve the workspace file record an embedded image `src` points at, matching the persisted serve-URL
|
||||
* shape by storage key or file id. Returns `undefined` for external / `data:` / unrecognized srcs, and
|
||||
* when the file list isn't loaded — callers then fall back to on-load measurement rather than reserving
|
||||
* from metadata.
|
||||
*/
|
||||
export function findWorkspaceFileBySrc(
|
||||
records: WorkspaceFileRecord[] | undefined,
|
||||
src: string | undefined
|
||||
): WorkspaceFileRecord | undefined {
|
||||
const ref = src ? extractEmbeddedFileRef(src) : null
|
||||
if (!ref || !records) return undefined
|
||||
return 'key' in ref
|
||||
? records.find((record) => record.key === ref.key)
|
||||
: records.find((record) => record.id === ref.fileId)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMemo } from 'react'
|
||||
import { toast } from '@sim/emcn'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
renameWorkspaceFileContract,
|
||||
restoreWorkspaceFileContract,
|
||||
updateWorkspaceFileContentContract,
|
||||
updateWorkspaceFileDimensionsContract,
|
||||
} from '@/lib/api/contracts/workspace-files'
|
||||
import {
|
||||
DirectUploadError,
|
||||
@@ -23,7 +25,8 @@ import {
|
||||
} from '@/lib/uploads/client/direct-upload'
|
||||
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
|
||||
import type { UserFile } from '@/executor/types'
|
||||
import { useFileContentSource } from '@/hooks/use-file-content-source'
|
||||
import { findWorkspaceFileBySrc } from '@/hooks/queries/utils/find-workspace-file-by-src'
|
||||
import { type ImageDimensionsSource, useFileContentSource } from '@/hooks/use-file-content-source'
|
||||
|
||||
const logger = createLogger('WorkspaceFilesQuery')
|
||||
|
||||
@@ -123,6 +126,62 @@ export function useWorkspaceFiles(
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Back the file content source's image-dimension capability with workspace file metadata. Reads intrinsic
|
||||
* dimensions synchronously from the already-loaded active file list (so a stored image reserves its box on
|
||||
* the first render), and persists the browser's measured dimensions when they're absent or disagree with
|
||||
* what's stored — an overwrite, so a stale value (left over after a content swap, or a non-EXIF-corrected
|
||||
* one) self-corrects rather than sticking. The write is fire-and-forget and de-duped (an exact-match cache
|
||||
* check plus mismatch-only reporting from the caller), so it never storms, never blocks render, and never
|
||||
* touches the collaborative document.
|
||||
*/
|
||||
export function useWorkspaceImageDimensionsAdapter(workspaceId: string): ImageDimensionsSource {
|
||||
const queryClient = useQueryClient()
|
||||
return useMemo<ImageDimensionsSource>(() => {
|
||||
const listKey = workspaceFilesKeys.list(workspaceId, 'active')
|
||||
const findRecord = (src: string | undefined): WorkspaceFileRecord | undefined =>
|
||||
findWorkspaceFileBySrc(queryClient.getQueryData<WorkspaceFileRecord[]>(listKey), src)
|
||||
return {
|
||||
getImageDimensions: (src) => {
|
||||
const record = findRecord(src)
|
||||
return record?.width != null && record.height != null
|
||||
? { width: record.width, height: record.height }
|
||||
: null
|
||||
},
|
||||
reportImageDimensions: (src, dimensions) => {
|
||||
const record = findRecord(src)
|
||||
// Skip when the file isn't one we can key (external/unlisted), or the cache already holds exactly
|
||||
// these dimensions. We do NOT skip merely because SOME dimensions are stored — they may be stale
|
||||
// (post content-swap / EXIF), and the caller only reports the browser's authoritative measurement
|
||||
// on a real mismatch, so we overwrite to self-correct.
|
||||
if (!record || (record.width === dimensions.width && record.height === dimensions.height))
|
||||
return
|
||||
// Populate the cache so this and sibling views reserve space immediately.
|
||||
queryClient.setQueryData<WorkspaceFileRecord[]>(listKey, (previous) =>
|
||||
previous?.map((entry) => (entry.id === record.id ? { ...entry, ...dimensions } : entry))
|
||||
)
|
||||
void requestJson(updateWorkspaceFileDimensionsContract, {
|
||||
params: { id: workspaceId, fileId: record.id },
|
||||
// Send the key we measured against; the server rejects the write if the row's content (key) has
|
||||
// since changed, so a stale in-flight PATCH for replaced bytes can't persist the old size.
|
||||
body: { key: record.key, ...dimensions },
|
||||
})
|
||||
.then((response) => {
|
||||
// The guard rejected the write because the file's content (key) changed since we measured —
|
||||
// our optimistic patch is now for superseded bytes, so refetch to reconcile the cache with the
|
||||
// new content (its real size is persisted when the replaced image next loads). Do NOT re-send
|
||||
// this measurement: it's of the old bytes and would write the wrong size under the new key.
|
||||
if (!response.success) void queryClient.invalidateQueries({ queryKey: listKey })
|
||||
})
|
||||
// A transport error / 403 for a read-only member leaves the optimistic value in place: the
|
||||
// measurement is the real displayed size, correct whether or not it persisted; a later list
|
||||
// refetch reconciles it.
|
||||
.catch(() => {})
|
||||
},
|
||||
}
|
||||
}, [queryClient, workspaceId])
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch file content as text via a content-source URL
|
||||
*/
|
||||
|
||||
@@ -21,6 +21,27 @@ function inlineRefQuery(ref: NonNullable<EmbeddedFileRef>): string {
|
||||
: `fileId=${encodeURIComponent(ref.fileId)}`
|
||||
}
|
||||
|
||||
export interface ImageDimensions {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional per-context capability: reserve layout space for an embedded image from its intrinsic size,
|
||||
* so it never reflows on load. The workspace source backs this with file-list metadata plus a self-
|
||||
* correcting metadata write; public/embedded sources omit it and fall back to on-load measurement (one
|
||||
* reflow, no persist).
|
||||
*/
|
||||
export interface ImageDimensionsSource {
|
||||
/** Intrinsic dimensions for an embedded image `src` if already known — read synchronously at render. */
|
||||
getImageDimensions: (src: string | undefined) => ImageDimensions | null
|
||||
/**
|
||||
* Persist an image's measured intrinsic dimensions (fire-and-forget). Overwrites a stored value that
|
||||
* disagrees so a stale size self-corrects; a no-op only when the stored value already matches.
|
||||
*/
|
||||
reportImageDimensions: (src: string | undefined, dimensions: ImageDimensions) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Seam for "where do a file's bytes come from". The in-app viewer resolves the
|
||||
* auth-gated workspace serve URL; the public share page swaps in a token-scoped
|
||||
@@ -35,6 +56,9 @@ export interface FileContentSource {
|
||||
* Non-workspace srcs (external, `data:`, public assets) pass through unchanged.
|
||||
*/
|
||||
resolveImageSrc: (src: string | undefined) => string | undefined
|
||||
/** Present only where intrinsic image dimensions are resolvable (the workspace viewer). */
|
||||
getImageDimensions?: ImageDimensionsSource['getImageDimensions']
|
||||
reportImageDimensions?: ImageDimensionsSource['reportImageDimensions']
|
||||
}
|
||||
|
||||
function buildServeUrl(key: string, opts?: FileContentUrlOptions): string {
|
||||
@@ -66,8 +90,14 @@ function inlineImageSource(
|
||||
* images route through `/api/workspaces/{workspaceId}/files/inline`, which resolves a reference only
|
||||
* within this workspace — a cross-workspace embed 404s and does not render.
|
||||
*/
|
||||
export function createWorkspaceFileContentSource(workspaceId: string): FileContentSource {
|
||||
return inlineImageSource(buildServeUrl, `/api/workspaces/${workspaceId}/files/inline`)
|
||||
export function createWorkspaceFileContentSource(
|
||||
workspaceId: string,
|
||||
imageDimensions?: ImageDimensionsSource
|
||||
): FileContentSource {
|
||||
return {
|
||||
...inlineImageSource(buildServeUrl, `/api/workspaces/${workspaceId}/files/inline`),
|
||||
...imageDimensions,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -56,6 +56,24 @@ export const updateWorkspaceFileContentBodySchema = z.object({
|
||||
encoding: z.enum(['base64', 'utf-8']).optional(),
|
||||
})
|
||||
|
||||
/** No real image approaches this; the bound rejects absurd or hostile values on the backfill path. */
|
||||
const IMAGE_DIMENSION_MAX = 100_000
|
||||
|
||||
export const updateWorkspaceFileDimensionsBodySchema = z.object({
|
||||
/**
|
||||
* The storage key the client measured. The write commits only if the row still has this key — a
|
||||
* content-version guard: the key changes on every content replacement, so a stale in-flight write for
|
||||
* superseded bytes is rejected rather than persisting the old aspect ratio.
|
||||
*/
|
||||
key: z.string().min(1, 'key is required'),
|
||||
width: z.number().int().positive().max(IMAGE_DIMENSION_MAX),
|
||||
height: z.number().int().positive().max(IMAGE_DIMENSION_MAX),
|
||||
})
|
||||
|
||||
export type UpdateWorkspaceFileDimensionsBody = z.input<
|
||||
typeof updateWorkspaceFileDimensionsBodySchema
|
||||
>
|
||||
|
||||
export const workspaceFileRecordSchema = z.object({
|
||||
id: z.string(),
|
||||
workspaceId: z.string(),
|
||||
@@ -65,6 +83,9 @@ export const workspaceFileRecordSchema = z.object({
|
||||
url: z.string().optional(),
|
||||
size: z.number(),
|
||||
type: z.string(),
|
||||
/** Intrinsic image dimensions (px), populated lazily; null for non-images/un-backfilled rows. */
|
||||
width: z.number().int().positive().nullable().optional(),
|
||||
height: z.number().int().positive().nullable().optional(),
|
||||
uploadedBy: z.string(),
|
||||
folderId: z.string().nullable(),
|
||||
folderPath: z.string().nullable().optional(),
|
||||
@@ -109,6 +130,19 @@ export const renameWorkspaceFileContract = defineRouteContract({
|
||||
},
|
||||
})
|
||||
|
||||
export const updateWorkspaceFileDimensionsContract = defineRouteContract({
|
||||
method: 'PATCH',
|
||||
path: '/api/workspaces/[id]/files/[fileId]/dimensions',
|
||||
params: workspaceFileParamsSchema,
|
||||
body: updateWorkspaceFileDimensionsBodySchema,
|
||||
response: {
|
||||
mode: 'json',
|
||||
// `success` reflects whether the row was actually written: false when the content-version guard
|
||||
// rejected the write (the storage key changed since the client measured), not just on error.
|
||||
schema: z.object({ success: z.boolean() }),
|
||||
},
|
||||
})
|
||||
|
||||
export const deleteWorkspaceFileContract = defineRouteContract({
|
||||
method: 'DELETE',
|
||||
path: '/api/workspaces/[id]/files/[fileId]',
|
||||
|
||||
@@ -67,6 +67,9 @@ export interface WorkspaceFileRecord {
|
||||
url?: string // Presigned URL for external access (optional, regenerated as needed)
|
||||
size: number
|
||||
type: string
|
||||
/** Intrinsic image pixel dimensions, populated lazily on first view. Null/absent for non-images. */
|
||||
width?: number | null
|
||||
height?: number | null
|
||||
uploadedBy: string
|
||||
folderId?: string | null
|
||||
folderPath?: string | null
|
||||
@@ -828,6 +831,8 @@ function mapWorkspaceFileRecord(
|
||||
path: `${pathPrefix}${encodeURIComponent(file.key)}?context=workspace`,
|
||||
size: file.size,
|
||||
type: file.contentType,
|
||||
width: file.width,
|
||||
height: file.height,
|
||||
uploadedBy: file.userId,
|
||||
folderId: file.folderId,
|
||||
folderPath: file.folderId ? (folderPaths.get(file.folderId) ?? null) : null,
|
||||
@@ -856,6 +861,38 @@ async function mapSingleWorkspaceFileRecord(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Store an image file's intrinsic pixel dimensions (a pure rendering hint used to reserve layout space
|
||||
* before the image loads). The client reports the browser's own EXIF-corrected `naturalWidth/Height`, and
|
||||
* only when it differs from what's stored, so this overwrites rather than backfilling once — a stale value
|
||||
* self-corrects on the next view instead of sticking behind a `width IS NULL` guard.
|
||||
*
|
||||
* `key` is a content-version guard: the write commits only if the row still has the storage key the
|
||||
* client measured. The key is regenerated on every content replacement, so an in-flight write measured
|
||||
* against superseded bytes is rejected here rather than persisting the old aspect ratio for new content.
|
||||
* Does NOT touch `updatedAt` — dimensions are not content and must not cache-bust the served image bytes.
|
||||
* Returns whether a live row was written.
|
||||
*/
|
||||
export async function updateWorkspaceFileDimensions(
|
||||
workspaceId: string,
|
||||
fileId: string,
|
||||
dimensions: { key: string; width: number; height: number }
|
||||
): Promise<boolean> {
|
||||
const updated = await db
|
||||
.update(workspaceFiles)
|
||||
.set({ width: dimensions.width, height: dimensions.height })
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceFiles.id, fileId),
|
||||
eq(workspaceFiles.workspaceId, workspaceId),
|
||||
eq(workspaceFiles.key, dimensions.key),
|
||||
isNull(workspaceFiles.deletedAt)
|
||||
)
|
||||
)
|
||||
.returning({ id: workspaceFiles.id })
|
||||
return updated.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a single active workspace file by its original name.
|
||||
* Returns the record if found, or null if no matching file exists.
|
||||
@@ -1280,6 +1317,13 @@ export async function updateWorkspaceFileContent(
|
||||
key: uploadResult.key,
|
||||
size: content.length,
|
||||
contentType: nextContentType,
|
||||
// Replaced bytes: drop the old image's dimensions so the row never describes stale content.
|
||||
// The next view reserves nothing (the baseline first-load reflow) rather than a wrong-sized
|
||||
// box, then the browser's measurement backfills the correct value. No server-side decode here
|
||||
// (avoids EXIF-orientation guesswork), and a late in-flight PATCH that lands after this is
|
||||
// corrected on the next view since the client overwrites on mismatch.
|
||||
width: null,
|
||||
height: null,
|
||||
updatedAt: now,
|
||||
contentUpdatedAt,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "workspace_files" ADD COLUMN "width" integer;--> statement-breakpoint
|
||||
ALTER TABLE "workspace_files" ADD COLUMN "height" integer;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1968,6 +1968,13 @@
|
||||
"when": 1785776917545,
|
||||
"tag": "0281_fixed_madame_web",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 282,
|
||||
"version": "7",
|
||||
"when": 1785968181949,
|
||||
"tag": "0282_chubby_psylocke",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1915,6 +1915,13 @@ export const workspaceFiles = pgTable(
|
||||
displayName: text('display_name'),
|
||||
contentType: text('content_type').notNull(),
|
||||
size: integer('size').notNull(),
|
||||
/**
|
||||
* Intrinsic pixel dimensions of an image file, captured lazily on first view (and stored so later
|
||||
* views reserve layout space before the image loads, via aspect-ratio). NULL for non-images and for
|
||||
* rows not yet backfilled. Purely a rendering hint — never affects stored file content.
|
||||
*/
|
||||
width: integer('width'),
|
||||
height: integer('height'),
|
||||
deletedAt: timestamp('deleted_at'),
|
||||
uploadedAt: timestamp('uploaded_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
|
||||
Reference in New Issue
Block a user