diff --git a/packages/headless/src/core/markdown.test.ts b/packages/headless/src/core/markdown.test.ts index 5afb24a..2114098 100644 --- a/packages/headless/src/core/markdown.test.ts +++ b/packages/headless/src/core/markdown.test.ts @@ -1562,10 +1562,12 @@ describe("markdown bridge", () => { }; const markdown = jsonToMarkdown(input); - expect(markdown).toContain("![Example](https://example.com/photo.jpg"); + // Size is native markdown (Obsidian's alt-size); the rest rides in metadata. + expect(markdown).toContain("![Example|720x405](https://example.com/photo.jpg"); expect(markdown).toContain("luthor:meta v1"); + expect(markdown).not.toContain('"width":720'); - const editedMarkdown = markdown.replace("![Example]", "![Updated]"); + const editedMarkdown = markdown.replace("![Example|720x405]", "![Updated|720x405]"); const roundTrip = markdownToJSON(editedMarkdown) as JsonDocument; const imageNode = findTopLevelNode(roundTrip, "image") as { src?: string; diff --git a/packages/headless/src/core/source-capability.ts b/packages/headless/src/core/source-capability.ts index a81fec3..2c7d73d 100644 --- a/packages/headless/src/core/source-capability.ts +++ b/packages/headless/src/core/source-capability.ts @@ -79,6 +79,9 @@ export const MARKDOWN_NATIVE_KEY_MAP: Readonly({ ); const [selection, setSelection] = useState(null); const viewportRafIdRef = useRef(null); + // The selection the person dismissed the bar for (an outside click). It stays + // dismissed for that selection — a collaborator's edit, a re-render, a scroll + // must not bring it back — until the selection itself changes. + const currentSignatureRef = useRef(null); + const dismissedSignatureRef = useRef(null); + // Which side of the selection the bar last sat on, for flip hysteresis. + const sideRef = useRef<"below" | "above">("below"); const getSelectionAnchorRect = (element: HTMLElement): DOMRect => { const anchorElement = element.querySelector( @@ -399,11 +406,21 @@ function FloatingToolbarPlugin({ positionFromRight = false; } - /* Vertical bounds check */ - if (y - toolbarHeight < scrollY) { - y = domRect.bottom + scrollY + offset.y; - } else if (y + toolbarHeight > scrollY + viewportHeight) { - y = domRect.top + scrollY - offset.y - toolbarHeight; + /* Vertical bounds check, with hysteresis */ + const hysteresis = 8; + const belowY = domRect.bottom + scrollY + offset.y; + const aboveY = domRect.top + scrollY - offset.y - toolbarHeight; + const overflowBelow = belowY + toolbarHeight - (scrollY + viewportHeight); + const overflowAbove = scrollY - aboveY; + if (strategy !== "above") { + if (sideRef.current === "below" && overflowBelow > hysteresis && overflowAbove < 0) { + sideRef.current = "above"; + } else if (sideRef.current === "above" && (overflowBelow < -hysteresis || overflowAbove > 0)) { + sideRef.current = "below"; + } + y = sideRef.current === "above" ? aboveY : belowY; + } else if (y - toolbarHeight < scrollY) { + y = belowY; } return { @@ -432,6 +449,19 @@ function FloatingToolbarPlugin({ let newRect: DOMRect | null = null; let rangeSelection: RangeSelection | null = null; + const signature = $isRangeSelection(sel) + ? `r:${sel.anchor.key}:${sel.anchor.offset}:${sel.focus.key}:${sel.focus.offset}` + : $isNodeSelection(sel) + ? `n:${sel.getNodes().map((n) => n.getKey()).sort().join(",")}` + : null; + currentSignatureRef.current = signature; + if (signature !== dismissedSignatureRef.current) { + dismissedSignatureRef.current = null; + } else if (signature !== null) { + setIsVisible(false); + return; + } + if ($isRangeSelection(sel) && !sel.isCollapsed()) { const domSelection = window.getSelection(); if (domSelection && domSelection.rangeCount > 0) { @@ -505,12 +535,13 @@ function FloatingToolbarPlugin({ }; }, [editor, config.debounceMs, createSelectionRect, extension]); - /* Hide callback */ - const hide = () => { + /* Hide callback — sticks until the selection changes */ + const hide = useCallback(() => { + dismissedSignatureRef.current = currentSignatureRef.current; setIsVisible(false); setSelectionRect(null); setSelection(null); - }; + }, []); /* Require render function to be provided */ if (!config.render) { diff --git a/packages/headless/src/extensions/embeds/EmbedResolverContext.tsx b/packages/headless/src/extensions/embeds/EmbedResolverContext.tsx index 9cab125..56e835d 100644 --- a/packages/headless/src/extensions/embeds/EmbedResolverContext.tsx +++ b/packages/headless/src/extensions/embeds/EmbedResolverContext.tsx @@ -42,6 +42,97 @@ export interface SavedCardMetadata { siteName?: string; } +/** + * What a host knows about an attachment before it loads — enough to reserve its + * box (no layout shift), pick a thumbnail, and label a file card. Every field is + * optional; a host that knows nothing returns `undefined` and the embed measures + * itself on load instead. + */ +export interface MediaMeta { + /** `image`, `gif`, `video`, `audio`, `document` or `file`. */ + kind?: string; + /** The type the file is served as (`image/png`, `application/pdf`). */ + mime?: string; + /** Size in bytes. */ + size?: number; + /** A content version; changes whenever the bytes do. */ + version?: string; + /** Intrinsic width in CSS pixels, as displayed (EXIF rotation applied). */ + width?: number | null; + /** Intrinsic height in CSS pixels, as displayed. */ + height?: number | null; + /** Running time of audio/video, in milliseconds. */ + durationMs?: number | null; + /** An animated image (GIF/WebP): never swap it for a still thumbnail. */ + animated?: boolean; + /** A poster frame exists for this video (`variant: "poster"`). */ + poster?: boolean; + /** Smaller renditions exist (`variant: "thumb"`). */ + thumb?: boolean; +} + +/** Which rendition of an attachment a URL should point at. */ +export interface MediaUrlOptions { + /** + * `original` (default) — the file itself. `thumb` — a smaller still, at least + * `width` wide when possible. `poster` — a video's poster frame. + */ + variant?: "original" | "thumb" | "poster"; + /** Desired width in CSS pixels for `thumb` / `poster`. */ + width?: number; +} + +/** What a host's file-card expansion renders from (see `renderFileExpansion`). */ +export interface FileExpansionContext { + target: string; + /** Text after `#` in the embed (`page=3`), or empty. */ + fragment: string; + url: string; + kind: string; + meta: MediaMeta | null | undefined; +} + +/** An edit to an embedded attachment; `null` clears a field. */ +export interface MediaEdit { + target?: string; + width?: number | null; + height?: number | null; + align?: "left" | "center" | "right" | null; + caption?: string | null; + alt?: string | null; +} + +/** What a media toolbar item acts on (see `mediaToolbar`). */ +export interface MediaToolbarContext { + target: string; + fragment: string; + /** `image`, `video`, `audio`, `pdf` or `file`. */ + kind: string; + url: string; + meta: MediaMeta | null | undefined; + width?: number; + height?: number; + align?: "left" | "center" | "right"; + caption?: string; + alt?: string; + /** Apply an edit to this embed (one undo step, one collaboration update). */ + update: (edit: MediaEdit) => void; + /** Remove this embed from the document. */ + remove: () => void; +} + +/** One button in the media toolbar. */ +export interface MediaToolbarItem { + id: string; + /** Accessible name, also the tooltip. */ + label: string; + /** Icon; the label is shown as text when there is none. */ + icon?: ReactNode; + active?: boolean; + disabled?: boolean; + onSelect: () => void; +} + /** * Callbacks an embed node uses to reach host services. Every member is optional * so the nodes degrade gracefully when a host wires only part of the surface (or @@ -54,7 +145,30 @@ export interface EmbedResolvers { * without a loading flash. When omitted, the file embed renders a reference * chip instead of loading media. */ - resolveMediaUrl?: (target: string) => string; + resolveMediaUrl?: (target: string, options?: MediaUrlOptions) => string; + /** + * Synchronous, cached metadata for a media target — `undefined` while unknown. + * Must return the *same object* for the same target until it changes (it is + * read through `useSyncExternalStore`). Pair with {@link subscribeMediaMeta} + * to have embeds re-render when a lookup lands. + */ + getMediaMeta?: (target: string) => MediaMeta | null | undefined; + /** Subscribe to metadata arriving/changing. Returns an unsubscribe function. */ + subscribeMediaMeta?: (listener: () => void) => () => void; + /** + * Extra content under a non-image file card — e.g. an inline PDF viewer the + * host renders on demand. Return `null` for files it has nothing to add to. + */ + renderFileExpansion?: (context: FileExpansionContext) => ReactNode; + /** + * The toolbar shown on a selected attachment. `items` adds the host's own + * buttons (replace, download, …) after the built-in ones; `builtIn: false` + * drops the built-ins (align, size, caption, alt, open, remove). + */ + mediaToolbar?: { + builtIn?: boolean; + items?: (context: MediaToolbarContext) => MediaToolbarItem[]; + }; /** * Navigate to a link target (the `Target` inside `[[Target]]`). Invoked when a * reader activates a wikilink. When omitted, the wikilink renders as inert diff --git a/packages/headless/src/extensions/embeds/FileDropUpload.test.ts b/packages/headless/src/extensions/embeds/FileDropUpload.test.ts index 72dfae3..1a0d3f2 100644 --- a/packages/headless/src/extensions/embeds/FileDropUpload.test.ts +++ b/packages/headless/src/extensions/embeds/FileDropUpload.test.ts @@ -12,34 +12,47 @@ import { $createParagraphNode, $createTextNode, $getRoot, + PASTE_COMMAND, createEditor, type LexicalEditor, } from "lexical"; -import { FileDropUploadExtension } from "./FileDropUploadExtension"; +import { + FileDropUploadExtension, + isRichTextPaste, + MEDIA_DROP_EVENT, + type FileDropUploadConfig, + type UploadFileOptions, +} from "./FileDropUploadExtension"; import { $isFileEmbedNode, FileEmbedNode } from "./FileEmbedNode"; +import { + $isUploadPlaceholderNode, + UPLOAD_PLACEHOLDER_MARKDOWN_TRANSFORMER, + UploadPlaceholderNode, +} from "./UploadPlaceholderNode"; +import { uploadRegistry } from "./uploads"; +import { jsonToMarkdown } from "../../core/markdown"; const disposers: Array<() => void> = []; - afterEach(() => { - while (disposers.length > 0) { - disposers.pop()?.(); - } + while (disposers.length > 0) disposers.pop()?.(); }); -function createHarness(uploadFile?: (file: File) => Promise<{ filename: string }>) { +type Upload = NonNullable; + +function createHarness(config: FileDropUploadConfig = {}) { const editor = createEditor({ namespace: "file-drop-upload-test", - nodes: [FileEmbedNode], + nodes: [FileEmbedNode, UploadPlaceholderNode], onError: (error) => { throw error; }, }); - const root = window.document.createElement("div"); + const root = document.createElement("div"); root.contentEditable = "true"; - window.document.body.appendChild(root); + document.body.appendChild(root); editor.setRootElement(root); - const extension = new FileDropUploadExtension({ uploadFile }); + const extension = new FileDropUploadExtension(config); const unregister = extension.register(editor); disposers.push(() => { unregister(); @@ -50,60 +63,378 @@ function createHarness(uploadFile?: (file: File) => Promise<{ filename: string } () => { const paragraph = $createParagraphNode(); const text = $createTextNode("hello"); - paragraph.append(text); - $getRoot().clear().append(paragraph); + paragraph.append(text, ); + $getRoot().clear().append(paragraph, $createParagraphNode().append($createTextNode("after"))); text.select(5, 5); }, { discrete: true }, ); - return { editor, commands: extension.getCommands(editor) }; + return { editor, extension, root, commands: extension.getCommands(editor) }; } -function embedTargets(editor: LexicalEditor): string[] { +async function settle(editor: LexicalEditor) { + for (let i = 0; i < 5; i++) await Promise.resolve(); editor.update(() => {}, { discrete: true }); +} + +/** Top-level blocks as short labels: text, `embed:x`, `uploading:x`. */ +function blocks(editor: LexicalEditor): string[] { return editor.getEditorState().read(() => $getRoot() .getChildren() - .filter($isFileEmbedNode) - .map((node) => node.getTarget()), + .map((node) => + $isFileEmbedNode(node) + ? `embed:${node.getTarget()}` + : $isUploadPlaceholderNode(node) + ? `uploading:${node.__name}` + : node.getTextContent(), + ), ); } -describe("uploadAndEmbedFile", () => { - it("uploads through the host callback and embeds the returned filename", async () => { - const uploadFile = vi.fn(async () => ({ filename: "photo.png" })); - const { editor, commands } = createHarness(uploadFile); - const file = new File(["x"], "photo.png", { type: "image/png" }); +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +const file = (name: string, type = "image/png") => new File(["x"], name, { type }); + +function clipboard(files: File[], data: Record = {}) { + return { + files, + types: [...(files.length ? ["Files"] : []), ...Object.keys(data)], + getData: (type: string) => data[type] ?? "", + } as unknown as DataTransfer; +} + +function paste(editor: LexicalEditor, data: DataTransfer): { handled: boolean; prevented: boolean } { + let prevented = false; + const event = { clipboardData: data, preventDefault: () => (prevented = true) } as unknown as ClipboardEvent; + let handled = false; + editor.update(() => { + handled = editor.dispatchCommand(PASTE_COMMAND, event); + }, { discrete: true }); + return { handled, prevented }; +} - await commands.uploadAndEmbedFile(file); +describe("upload pipeline", () => { + it("uploads through the host callback (with signal + progress) and embeds after the caret's block", async () => { + const uploadFile = vi.fn(async () => ({ filename: "photo.png" })); + const { editor, commands } = createHarness({ uploadFile }); + const picked = file("photo.png"); - expect(uploadFile).toHaveBeenCalledWith(file); - expect(embedTargets(editor)).toEqual(["photo.png"]); + await commands.uploadAndEmbedFile(picked); + await settle(editor); + + expect(uploadFile).toHaveBeenCalledWith(picked, expect.objectContaining({ signal: expect.any(AbortSignal), onProgress: expect.any(Function) })); + expect(blocks(editor)).toEqual(["hello", "embed:photo.png", "after"]); }); it("sanitizes the filename before it reaches the body", async () => { - const { editor, commands } = createHarness(async () => ({ filename: "a[b]#c.png" })); + const { editor, commands } = createHarness({ uploadFile: async () => ({ filename: "a[b]#c.png" }) }); + await commands.uploadAndEmbedFile(file("x.png")); + await settle(editor); + expect(blocks(editor)).toContain("embed:a-b--c.png"); + }); - await commands.uploadAndEmbedFile(new File(["x"], "x.png")); + it("places a batch in the order given, whatever order the uploads finish in", async () => { + const pending = new Map>>(); + const uploadFile: Upload = (f) => { + const d = deferred<{ filename: string }>(); + pending.set(f.name, d); + return d.promise; + }; + const { editor, commands } = createHarness({ uploadFile }); + const done = commands.uploadAndEmbedFiles([file("1.png"), file("2.png"), file("3.png")]); + await settle(editor); + expect(blocks(editor)).toEqual(["hello", "uploading:1.png", "uploading:2.png", "uploading:3.png", "after"]); + + for (const name of ["3.png", "1.png", "2.png"]) { + pending.get(name)!.resolve({ filename: `stored-${name}` }); + await settle(editor); + } + await done; + expect(blocks(editor)).toEqual(["hello", "embed:stored-1.png", "embed:stored-2.png", "embed:stored-3.png", "after"]); + }); - expect(embedTargets(editor)).toEqual(["a-b--c.png"]); + it("runs at most three uploads at once", async () => { + let inFlight = 0; + let peak = 0; + const releases: Array<() => void> = []; + const uploadFile: Upload = (f) => + new Promise((resolve) => { + inFlight++; + peak = Math.max(peak, inFlight); + releases.push(() => { + inFlight--; + resolve({ filename: f.name }); + }); + }); + const { editor, commands } = createHarness({ uploadFile }); + const done = commands.uploadAndEmbedFiles(["a", "b", "c", "d", "e", "f"].map((n) => file(`${n}.png`))); + await settle(editor); + expect(inFlight).toBe(3); + while (releases.length) { + releases.shift()!(); + await settle(editor); + } + await done; + expect(peak).toBe(3); + expect(blocks(editor).filter((b) => b.startsWith("embed:"))).toHaveLength(6); + }); + + it("reports progress into the local registry, and never puts a blob: URL in the document", async () => { + let options: UploadFileOptions | null = null; + const d = deferred<{ filename: string }>(); + const { editor, commands } = createHarness({ + uploadFile: (_f, o) => { + options = o; + return d.promise; + }, + }); + const done = commands.uploadAndEmbedFile(file("big.png")); + await settle(editor); + options!.onProgress(0.42); + const id = editor.getEditorState().read(() => $getRoot().getChildren().find($isUploadPlaceholderNode)!.getUploadId()); + expect(uploadRegistry.get(id)!.progress).toBeCloseTo(0.42); + expect(JSON.stringify(editor.getEditorState().toJSON())).not.toContain("blob:"); + d.resolve({ filename: "big.png" }); + await done; + expect(uploadRegistry.get(id)).toBeUndefined(); }); - it("rejects when the upload fails, so the caller can report it", async () => { - const { editor, commands } = createHarness(async () => { - throw new Error("offline"); + it("a failure keeps the placeholder with Retry, reports once, and Retry completes it", async () => { + const onUploadError = vi.fn(); + let attempt = 0; + const { editor, commands } = createHarness({ + onUploadError, + uploadFile: async () => { + attempt++; + if (attempt === 1) throw new Error("offline"); + return { filename: "photo.png" }; + }, }); + await expect(commands.uploadAndEmbedFile(file("photo.png"))).rejects.toThrow("offline"); + await settle(editor); + expect(onUploadError).toHaveBeenCalledTimes(1); + expect(blocks(editor)).toEqual(["hello", "uploading:photo.png", "after"]); + const id = editor.getEditorState().read(() => $getRoot().getChildren().find($isUploadPlaceholderNode)!.getUploadId()); + expect(uploadRegistry.get(id)!.status).toBe("error"); + expect(uploadRegistry.get(id)!.error).toBe("offline"); - await expect(commands.uploadAndEmbedFile(new File(["x"], "x.png"))).rejects.toThrow("offline"); - expect(embedTargets(editor)).toEqual([]); + uploadRegistry.get(id)!.retry(); + await settle(editor); + await settle(editor); + expect(blocks(editor)).toEqual(["hello", "embed:photo.png", "after"]); }); - it("rejects when no upload callback is configured", async () => { - const { commands } = createHarness(); + it("cancel aborts the request", async () => { + let signal: AbortSignal | null = null; + const { editor, commands } = createHarness({ + uploadFile: (_f, o) => + new Promise((_resolve, reject) => { + signal = o.signal; + o.signal.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError"))); + }), + }); + const done = commands.uploadAndEmbedFile(file("x.png")); + await settle(editor); + const id = editor.getEditorState().read(() => $getRoot().getChildren().find($isUploadPlaceholderNode)!.getUploadId()); + uploadRegistry.get(id)!.cancel(); + await expect(done).rejects.toMatchObject({ name: "AbortError" }); + expect(signal!.aborted).toBe(true); + }); + + it("refuses files the host rejects before uploading them", async () => { + const uploadFile = vi.fn(); + const onUploadError = vi.fn(); + const { editor, commands } = createHarness({ + uploadFile, + onUploadError, + validateFile: (f) => (f.size > 0 && f.name.endsWith(".exe") ? "That kind of file can't be attached." : null), + }); + await expect(commands.uploadAndEmbedFile(file("setup.exe", "application/octet-stream"))).rejects.toThrow(/can't be attached/); + expect(uploadFile).not.toHaveBeenCalled(); + expect(onUploadError).toHaveBeenCalledTimes(1); + await settle(editor); + expect(blocks(editor)).toEqual(["hello", "after"]); + }); - await expect(commands.uploadAndEmbedFile(new File(["x"], "x.png"))).rejects.toThrow( - /no uploadFile/, - ); + it("never uploads into a read-only editor", async () => { + const uploadFile = vi.fn(async () => ({ filename: "x.png" })); + const { editor, commands } = createHarness({ uploadFile }); + editor.setEditable(false); + await expect(commands.uploadAndEmbedFile(file("x.png"))).rejects.toThrow(/read-only/); + expect(paste(editor, clipboard([file("x.png")])).handled).toBe(false); + expect(uploadFile).not.toHaveBeenCalled(); + }); + + it("uses the host's current callback, not the one it was set up with", async () => { + const first = vi.fn(async () => ({ filename: "old.png" })); + const second = vi.fn(async () => ({ filename: "new.png" })); + const { editor, extension, commands } = createHarness({ uploadFile: first }); + (extension as unknown as { config: FileDropUploadConfig }).config.uploadFile = second; + await commands.uploadAndEmbedFile(file("x.png")); + await settle(editor); + expect(first).not.toHaveBeenCalled(); + expect(blocks(editor)).toContain("embed:new.png"); + }); + + it("adds a paragraph after an upload at the very end, so typing can continue", async () => { + const { editor, commands } = createHarness({ uploadFile: async () => ({ filename: "x.png" }) }); + editor.update(() => $getRoot().getLastChild()!.selectEnd(), { discrete: true }); + await commands.uploadAndEmbedFile(file("x.png")); + await settle(editor); + expect(blocks(editor)).toEqual(["hello", "after", "embed:x.png", ""]); + }); + + it("no callback: the command refuses and pastes fall through", async () => { + const { editor, commands } = createHarness(); + await expect(commands.uploadAndEmbedFile(file("x.png"))).rejects.toThrow(/no uploadFile/); + expect(paste(editor, clipboard([file("x.png")])).handled).toBe(false); + }); +}); + +describe("paste policy", () => { + it.each([ + ["a screenshot (files only)", [file("s.png")], {}, true], + ["a copied image (html is just )", [file("i.png")], { "text/html": '' }, true], + ["Word (formatted text + a picture of it)", [file("w.png")], { "text/html": "

Quarterly report

", "text/plain": "Quarterly report" }, false], + ["Excel (a table + a picture of it)", [file("e.png")], { "text/html": "
12
" }, false], + ["Google Docs (text with an image inside)", [file("g.png")], { "text/html": '

Hello

' }, false], + ["a web page with several images", [file("p.png")], { "text/html": '' }, false], + ["RTF text from a desktop app", [file("r.png")], { "text/rtf": "{\\rtf1 hi}", "text/plain": "hi" }, false], + ["plain text only", [], { "text/plain": "hello" }, false], + ])("%s → upload: %s", async (_name, files, data, uploads) => { + const uploadFile = vi.fn(async (f) => ({ filename: f.name })); + const { editor } = createHarness({ uploadFile }); + const result = paste(editor, clipboard(files as File[], data as Record)); + await settle(editor); + expect(result.handled).toBe(uploads); + expect(result.prevented).toBe(uploads); + expect(uploadFile).toHaveBeenCalledTimes(uploads ? 1 : 0); + }); + + it("isRichTextPaste ignores comments, styles and non-breaking spaces", () => { + expect(isRichTextPaste(clipboard([], { "text/html": " " }))).toBe(false); + }); +}); + +describe("drop", () => { + function drop(target: HTMLElement, files: File[]) { + const event = new Event("drop", { bubbles: true, cancelable: true }) as DragEvent; + Object.defineProperty(event, "dataTransfer", { value: clipboard(files) }); + Object.defineProperty(event, "clientX", { value: 10 }); + Object.defineProperty(event, "clientY", { value: 10 }); + target.dispatchEvent(event); + return event; + } + + it("uploads dropped files, lets the event reach the host, and announces it", async () => { + const uploadFile = vi.fn(async (f) => ({ filename: f.name })); + const { editor, root } = createHarness({ uploadFile }); + const hostSawDrop = vi.fn(); + const announced = vi.fn(); + document.body.addEventListener("drop", hostSawDrop); + document.body.addEventListener(MEDIA_DROP_EVENT, announced); + const event = drop(root, [file("a.png"), file("b.png")]); + await settle(editor); + await settle(editor); + document.body.removeEventListener("drop", hostSawDrop); + document.body.removeEventListener(MEDIA_DROP_EVENT, announced); + + expect(event.defaultPrevented).toBe(true); + expect(hostSawDrop).toHaveBeenCalledTimes(1); + expect(announced).toHaveBeenCalledTimes(1); + expect(uploadFile).toHaveBeenCalledTimes(2); + expect(blocks(editor).filter((b) => b.startsWith("embed:"))).toEqual(["embed:a.png", "embed:b.png"]); + }); + + it("lands where it was dropped: above or below the block under the pointer", async () => { + const uploadFile = vi.fn(async (f) => ({ filename: f.name })); + const { editor, root } = createHarness({ uploadFile }); + const blocksEl = Array.from(root.children) as HTMLElement[]; + const after = blocksEl[1]!; // the "after" paragraph + after.getBoundingClientRect = () => ({ top: 100, height: 40, bottom: 140, left: 0, right: 500, width: 500, x: 0, y: 100, toJSON: () => ({}) }) as DOMRect; + const original = document.elementFromPoint; + document.elementFromPoint = () => after.firstChild as Element ?? after; + try { + // Lower half of "after": below it. + const low = new Event("drop", { bubbles: true, cancelable: true }) as DragEvent; + Object.defineProperty(low, "dataTransfer", { value: clipboard([file("below.png")]) }); + Object.defineProperty(low, "clientX", { value: 10 }); + Object.defineProperty(low, "clientY", { value: 130 }); + root.dispatchEvent(low); + await settle(editor); + await settle(editor); + // Upper half: above it. + const high = new Event("drop", { bubbles: true, cancelable: true }) as DragEvent; + Object.defineProperty(high, "dataTransfer", { value: clipboard([file("above.png")]) }); + Object.defineProperty(high, "clientX", { value: 10 }); + Object.defineProperty(high, "clientY", { value: 105 }); + root.dispatchEvent(high); + await settle(editor); + await settle(editor); + } finally { + document.elementFromPoint = original; + } + expect(blocks(editor)).toEqual(["hello", "embed:above.png", "after", "embed:below.png", ""]); + }); + + it("follows the editor to a new root element", async () => { + const uploadFile = vi.fn(async (f) => ({ filename: f.name })); + const { editor, root } = createHarness({ uploadFile }); + const next = document.createElement("div"); + next.contentEditable = "true"; + document.body.appendChild(next); + editor.setRootElement(next); + drop(root, [file("old-root.png")]); + drop(next, [file("new-root.png")]); + await settle(editor); + await settle(editor); + expect(uploadFile.mock.calls.map(([f]) => f.name)).toEqual(["new-root.png"]); + next.remove(); + }); + + it("ignores drops into a read-only editor", async () => { + const uploadFile = vi.fn(); + const { editor, root } = createHarness({ uploadFile }); + editor.setEditable(false); + const event = drop(root, [file("a.png")]); + await settle(editor); + expect(event.defaultPrevented).toBe(false); + expect(uploadFile).not.toHaveBeenCalled(); + }); +}); + +describe("placeholder in markdown", () => { + it("writes nothing, so a save mid-upload persists nothing for it", () => { + const doc = { + root: { + type: "root", + version: 1, + format: "", + indent: 0, + direction: null, + children: [ + { type: "paragraph", version: 1, format: "", indent: 0, direction: null, textFormat: 0, textStyle: "", children: [{ type: "text", version: 1, text: "Intro", format: 0, detail: 0, mode: "normal", style: "" }] }, + { type: "uploadPlaceholder", version: 1, uploadId: "up-1", name: "a.png", size: 1, kind: "image", startedAt: 1 }, + ], + }, + }; + const md = jsonToMarkdown(doc, { + metadataMode: "none", + extraNodes: [UploadPlaceholderNode], + extraTransformers: [UPLOAD_PLACEHOLDER_MARKDOWN_TRANSFORMER], + }); + expect(md.trim()).toBe("Intro"); + expect(md).not.toContain("a.png"); + expect(md).not.toContain("Unsupported"); }); }); diff --git a/packages/headless/src/extensions/embeds/FileDropUploadExtension.tsx b/packages/headless/src/extensions/embeds/FileDropUploadExtension.tsx index 18ce969..13b1e5a 100644 --- a/packages/headless/src/extensions/embeds/FileDropUploadExtension.tsx +++ b/packages/headless/src/extensions/embeds/FileDropUploadExtension.tsx @@ -6,28 +6,43 @@ */ /* - * Drop/paste → upload → `![[filename]]` pipeline. + * Drop / paste / pick → upload → `![[filename]]`. * - * Intercepts file drops and clipboard pastes on the editor surface and - * delegates them to a host-provided upload callback. On success the extension - * inserts a {@link FileEmbedNode} referencing the stored filename, so the - * body reads `![[filename]]` in markdown. + * The pipeline, and the promises it keeps: * - * The paste handler is registered at HIGH priority so it runs before the - * built-in ImageExtension paste handler — Papyra stores all media as named - * files (`![[photo.png]]`), not inline base64 images. + * - **Where and in what order.** A drop lands where it was dropped; a paste or + * a picked file at the caret. Every file gets a placeholder straight away, in + * the order given, and each is replaced in place when its upload finishes — + * so a batch always reads in the order it was dropped, whatever finishes + * first. + * - **Honest progress.** At most three uploads run at once; each placeholder + * shows progress, can be cancelled, and on failure offers Retry or Remove. + * - **Pastes do what the person meant.** Copying from Word, Excel or a web page + * puts formatted text *and* a picture of it on the clipboard; the text wins. + * Only a clipboard that is just files (a screenshot, a copied image) uploads. + * - **Never into a read-only note**, and never a `blob:` URL into the shared + * document (the placeholder carries only a name and size). + * - **The host stays in charge.** The upload callback is read when a file is + * uploaded, never captured at setup, so a host that swaps its callback is + * honoured; errors go to `onUploadError`, once; nothing stops the drop event + * from reaching the host's own handlers — a `luthor:media-drop` event says an + * upload started. * - * When no `uploadFile` callback is provided, the extension is inert and all - * drop/paste events fall through to the next handler. + * Without an `uploadFile` callback the extension is inert. */ import { + $createParagraphNode, + $getNearestNodeFromDOMNode, $getRoot, $getSelection, + $isElementNode, $isRangeSelection, + $isNodeSelection, COMMAND_PRIORITY_HIGH, PASTE_COMMAND, type LexicalEditor, + type LexicalNode, } from "lexical"; import { type BaseExtensionConfig, @@ -35,6 +50,38 @@ import { } from "@lyfie/luthor-headless/extensions/types"; import { BaseExtension } from "@lyfie/luthor-headless/extensions/base"; import { $createFileEmbedNode } from "./FileEmbedNode"; +import { $createUploadPlaceholderNode, $findUploadPlaceholder, $isUploadPlaceholderNode } from "./UploadPlaceholderNode"; +import { createUploadId, UploadQueue, uploadRegistry } from "./uploads"; +import { reportError } from "../../utils/logger"; + +// Failures of the upload itself are reported through onUploadError as they +// happen; anything else escaping the pipeline is a bug — say so, never swallow. +const REPORTED = Symbol("luthor.uploadReported"); + +function logUnexpected(error: unknown): void { + const reported = typeof error === "object" && error !== null && (error as Record)[REPORTED]; + const aborted = error instanceof DOMException && error.name === "AbortError"; + if (!reported && !aborted) reportError("fileDropUpload: upload pipeline failed", error); +} + +function markReported(error: T): T { + if (typeof error === "object" && error !== null) { + try { + (error as Record)[REPORTED] = true; + } catch { + // Frozen error objects: logged twice at worst. + } + } + return error; +} + +/** What an upload callback is given besides the file. */ +export interface UploadFileOptions { + /** Aborted when the person cancels; pass it to `fetch` / XHR. */ + signal: AbortSignal; + /** Report progress, 0–1. */ + onProgress: (fraction: number) => void; +} export interface FileDropUploadConfig extends BaseExtensionConfig { /** @@ -46,7 +93,16 @@ export interface FileDropUploadConfig extends BaseExtensionConfig { * file under the unsanitized name will serve a broken reference, so * sanitize on the server too. */ - uploadFile?: (file: File) => Promise<{ filename: string }>; + uploadFile?: (file: File, options: UploadFileOptions) => Promise<{ filename: string }>; + /** + * Refuse a file before uploading it (too big, a type the host won't take): + * return a message to show, or `null` to accept. + */ + validateFile?: (file: File) => string | null; + /** Report a failed or refused upload (a toast). Called once per failure. */ + onUploadError?: (error: unknown, file: File) => void; + /** Uploads that may run at once (default 3). */ + concurrency?: number; } /** @@ -61,156 +117,336 @@ export function sanitizeEmbedTarget(filename: string): string { } /** - * Headless extension that wires drop/paste → upload → file embed insertion. - * Pass `uploadFile` in the config to activate the pipeline; without it, the - * extension is a no-op. + * Whether a paste is really rich content (keep it as text) rather than files. + * Word, Excel, Sheets and web pages put a rendered picture of the selection on + * the clipboard next to the HTML; that picture must not replace the text. + * A clipboard whose HTML is only an `` (a copied image) or empty is files. */ +export function isRichTextPaste(data: DataTransfer | null): boolean { + if (!data) return false; + const html = data.getData("text/html"); + if (html) { + const images = (html.match(//gi, "") + .replace(//g, "") + .replace(/<[^>]*>/g, "") + .replace(/ | /gi, " ") + .trim(); + if (text.length > 0 || images > 1) return true; + return false; + } + // RTF with text alongside (a desktop app that offers no HTML). + return !!data.getData("text/rtf") && data.getData("text/plain").trim().length > 0; +} + /** Commands contributed by {@link FileDropUploadExtension}. */ export type FileDropUploadCommands = { /** - * Upload `file` through the configured `uploadFile` callback and embed the - * result after the caret's block as `![[filename]]` — the same path a paste - * or drop takes, for a host's own "attach file" control. Resolves once the - * embed is inserted; rejects when no `uploadFile` is configured or the - * upload fails, so the caller can report it. + * Upload `file` and embed it after the caret's block — the same pipeline a + * paste takes, for a host's own "attach file" control. Resolves once the + * embed is in the document; rejects when the upload fails, is cancelled or + * is refused (the placeholder then shows Retry/Remove, and `onUploadError` + * has already been told). */ uploadAndEmbedFile: (file: File) => Promise; + /** Several files at once, placed in order. */ + uploadAndEmbedFiles: (files: File[]) => Promise; }; +/** The event a drop with files dispatches on the editor root (bubbles). */ +export const MEDIA_DROP_EVENT = "luthor:media-drop"; + export class FileDropUploadExtension extends BaseExtension< "fileDropUpload", FileDropUploadConfig, FileDropUploadCommands > { + private queue: UploadQueue; + constructor(config: FileDropUploadConfig = {}) { super("fileDropUpload", [ExtensionCategory.Floating]); this.config = config; + this.queue = new UploadQueue(config.concurrency ?? 3); } - register(editor: LexicalEditor): () => void { - const uploadFile = this.config.uploadFile; - if (typeof uploadFile !== "function") { - return () => {}; - } + private get enabled(): boolean { + return typeof this.config.uploadFile === "function"; + } + register(editor: LexicalEditor): () => void { const removePaste = editor.registerCommand( PASTE_COMMAND, (event) => { - const items = event.clipboardData?.items; - if (!items) { - return false; - } - - const files: File[] = []; - for (const item of Array.from(items)) { - if (item.kind === "file") { - const file = item.getAsFile(); - if (file) { - files.push(file); - } - } - } - - if (files.length === 0) { - return false; - } - + if (!this.enabled || !editor.isEditable()) return false; + const data = event.clipboardData; + const files = data ? Array.from(data.files ?? []) : []; + if (files.length === 0 || isRichTextPaste(data)) return false; event.preventDefault(); - for (const file of files) { - this.handleFileUpload(editor, file, uploadFile); - } + void this.uploadFiles(editor, files, "selection").catch(logUnexpected); return true; }, COMMAND_PRIORITY_HIGH, ); - const rootElement = editor.getRootElement(); - let removeDrop: (() => void) | null = null; - - if (rootElement) { - const handleDragOver = (event: DragEvent) => { + // Bound through the root listener so a remounted content-editable (a view + // switch) keeps its drop target. + let detach: (() => void) | null = null; + const removeRoot = editor.registerRootListener((root, previous) => { + detach?.(); + detach = null; + void previous; + if (!root) return; + const onDragOver = (event: DragEvent) => { + if (!this.enabled || !editor.isEditable()) return; if (event.dataTransfer?.types.includes("Files")) { event.preventDefault(); event.dataTransfer.dropEffect = "copy"; } }; - - const handleDrop = (event: DragEvent) => { - const files = event.dataTransfer?.files; - if (!files || files.length === 0) { - return; - } - + const onDrop = (event: DragEvent) => { + if (!this.enabled || !editor.isEditable()) return; + const files = Array.from(event.dataTransfer?.files ?? []); + if (files.length === 0) return; + // No stopPropagation: the host's own drop handling (an overlay that + // must reset) still sees the event. event.preventDefault(); - event.stopPropagation(); - - for (const file of Array.from(files)) { - this.handleFileUpload(editor, file, uploadFile); - } + root.dispatchEvent(new CustomEvent(MEDIA_DROP_EVENT, { bubbles: true, detail: { count: files.length } })); + const at = { x: event.clientX, y: event.clientY }; + void this.uploadFiles(editor, files, at).catch(logUnexpected); }; - - rootElement.addEventListener("dragover", handleDragOver); - rootElement.addEventListener("drop", handleDrop); - - removeDrop = () => { - rootElement.removeEventListener("dragover", handleDragOver); - rootElement.removeEventListener("drop", handleDrop); + root.addEventListener("dragover", onDragOver); + root.addEventListener("drop", onDrop); + detach = () => { + root.removeEventListener("dragover", onDragOver); + root.removeEventListener("drop", onDrop); }; - } + }); return () => { removePaste(); - removeDrop?.(); + removeRoot(); + detach?.(); }; } getCommands(editor: LexicalEditor): FileDropUploadCommands { return { uploadAndEmbedFile: async (file: File) => { - const uploadFile = this.config.uploadFile; - if (typeof uploadFile !== "function") { - throw new Error("fileDropUpload: no uploadFile callback is configured"); - } - const { filename } = await uploadFile(file); - this.insertEmbed(editor, filename); + if (!this.enabled) throw new Error("fileDropUpload: no uploadFile callback is configured"); + await this.uploadFiles(editor, [file], "selection"); + }, + uploadAndEmbedFiles: async (files: File[]) => { + if (!this.enabled) throw new Error("fileDropUpload: no uploadFile callback is configured"); + await this.uploadFiles(editor, files, "selection"); }, }; } - private handleFileUpload( + /** + * Validate, place a placeholder per file (in order), then upload each and + * swap its placeholder for the embed. Resolves when all are embedded; + * rejects with the first failure (every failure is still reported). + */ + private async uploadFiles( editor: LexicalEditor, - file: File, - uploadFile: (file: File) => Promise<{ filename: string }>, - ): void { - uploadFile(file).then( - ({ filename }) => this.insertEmbed(editor, filename), - () => { - // Upload failed — swallow silently. The host adapter owns error - // reporting through its own UI (toasts, banners, etc.). - }, - ); - } + files: File[], + where: "selection" | { x: number; y: number }, + ): Promise { + if (!editor.isEditable()) throw new Error("fileDropUpload: the editor is read-only"); - private insertEmbed(editor: LexicalEditor, filename: string): void { - const target = sanitizeEmbedTarget(filename); - if (!target) { - return; + const accepted: File[] = []; + let refusal: Error | null = null; + for (const file of files) { + const message = this.config.validateFile?.(file) ?? null; + if (message) { + const error = markReported(new Error(message)); + refusal ??= error; + this.config.onUploadError?.(error, file); + } else { + accepted.push(file); + } } + if (accepted.length === 0) { + throw refusal ?? new Error("fileDropUpload: nothing to upload"); + } + + const ids = accepted.map(() => createUploadId()); + const anchorBlock = where === "selection" ? null : blockAtPoint(editor, where.x, where.y); editor.update(() => { - const embedNode = $createFileEmbedNode(target); - const selection = $getSelection(); - if ($isRangeSelection(selection)) { - const anchor = selection.anchor.getNode(); - const topElement = anchor.getTopLevelElement(); - if (topElement) { - topElement.insertAfter(embedNode); - return; - } - } + const placeholders = accepted.map((file, i) => $createUploadPlaceholderNode(file, ids[i]!)); + $insertBlocks(placeholders, anchorBlock); + }); + + const results = await Promise.allSettled(accepted.map((file, i) => this.runUpload(editor, file, ids[i]!))); + const failure = results.find((r): r is PromiseRejectedResult => r.status === "rejected"); + if (failure) throw failure.reason; + if (refusal) throw refusal; + } - $getRoot().append(embedNode); + /** Upload one file for an existing placeholder; retries reuse the placeholder. */ + private runUpload(editor: LexicalEditor, file: File, id: string): Promise { + return new Promise((resolve, reject) => { + let controller = new AbortController(); + let settled = false; + const previewUrl = + typeof URL !== "undefined" && typeof URL.createObjectURL === "function" && file.type.startsWith("image/") + ? URL.createObjectURL(file) + : null; + + const finish = (error?: unknown) => { + if (settled) return; + settled = true; + if (error === undefined) resolve(); + else reject(error); + }; + + const cancel = () => { + controller.abort(); + uploadRegistry.delete(id); + finish(new DOMException("Upload cancelled", "AbortError")); + }; + + const attempt = () => { + controller = new AbortController(); + uploadRegistry.update(id, { status: "queued", error: null, progress: null }); + void this.queue + .run(async () => { + if (controller.signal.aborted) throw new DOMException("Upload cancelled", "AbortError"); + uploadRegistry.update(id, { status: "uploading" }); + const uploadFile = this.config.uploadFile; + if (typeof uploadFile !== "function") throw new Error("fileDropUpload: no uploadFile callback is configured"); + return uploadFile(file, { + signal: controller.signal, + onProgress: (fraction) => { + if (Number.isFinite(fraction)) uploadRegistry.update(id, { progress: Math.max(0, Math.min(1, fraction)) }); + }, + }); + }) + .then( + ({ filename }) => { + const target = sanitizeEmbedTarget(filename); + let placed = false; + editor.update( + () => { + const placeholder = $findUploadPlaceholder(id); + if (!placeholder) return; // removed while uploading + if (!target) { + placeholder.remove(); + return; + } + placeholder.replace($createFileEmbedNode(target)); + placed = true; + }, + // Part of the same undoable step as the drop, not a new one. + { tag: "history-merge" }, + ); + uploadRegistry.delete(id); + if (placed || !target) finish(); + else finish(new DOMException("Upload removed", "AbortError")); + }, + (error: unknown) => { + if (controller.signal.aborted || (error instanceof DOMException && error.name === "AbortError")) { + return; // cancel() already settled it + } + uploadRegistry.update(id, { + status: "error", + error: error instanceof Error && error.message ? error.message : "Upload failed", + }); + this.config.onUploadError?.(error, file); + finish(markReported(error)); + }, + ); + }; + + uploadRegistry.set({ + id, + file, + status: "queued", + progress: null, + previewUrl, + error: null, + cancel, + // A retry is a fresh attempt for the same placeholder; the caller who + // awaited the first attempt has already been told it failed. + retry: () => { + settled = true; + attempt(); + }, + }); + attempt(); }); } } +/** The top-level block under a point in the editor, or null. */ +function blockAtPoint(editor: LexicalEditor, x: number, y: number): { key: string; before: boolean } | null { + const root = editor.getRootElement(); + if (!root || typeof document === "undefined" || typeof document.elementFromPoint !== "function") return null; + let element = document.elementFromPoint(x, y); + if (!element || !root.contains(element)) return null; + // Walk up to a direct child of the root: a top-level block. + while (element && element.parentElement !== root) element = element.parentElement; + if (!element) return null; + const rect = element.getBoundingClientRect(); + const before = y < rect.top + rect.height / 2; + let key: string | null = null; + editor.read(() => { + const node = $getNearestNodeFromDOMNode(element!); + key = node ? (node.getTopLevelElement()?.getKey() ?? node.getKey()) : null; + }); + return key ? { key, before } : null; +} + +/** + * Insert blocks, in order: before/after the block under a drop point, else + * after the caret's block, else at the end. An empty paragraph the caret sat + * in is replaced by the uploads. A paragraph follows the last one when nothing + * else does, so typing can continue after an upload at the end of a note. + */ +function $insertBlocks(blocks: LexicalNode[], at: { key: string; before: boolean } | null): void { + if (blocks.length === 0) return; + const root = $getRoot(); + let anchor: LexicalNode | null = null; + let before = false; + + if (at) { + anchor = root.getChildren().find((child) => child.getKey() === at.key) ?? null; + before = at.before; + } + if (!anchor) { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + anchor = selection.anchor.getNode().getTopLevelElement(); + } else if ($isNodeSelection(selection)) { + const node = selection.getNodes()[0]; + anchor = node ? (node.getTopLevelElement() ?? node) : null; + } + } + + const replaceEmpty = + !!anchor && !before && $isElementNode(anchor) && anchor.getType() === "paragraph" && anchor.isEmpty(); + const [first, ...rest] = blocks; + if (!anchor) root.append(first!); + else if (before) anchor.insertBefore(first!); + else anchor.insertAfter(first!); + let last = first!; + for (const block of rest) { + last.insertAfter(block); + last = block; + } + if (replaceEmpty) anchor!.remove(); + + const next = last.getNextSibling(); + if (!next) { + const paragraph = $createParagraphNode(); + last.insertAfter(paragraph); + paragraph.select(); + } else if ($isElementNode(next)) { + next.selectStart(); + } +} + +export { $isUploadPlaceholderNode }; + export const fileDropUploadExtension = new FileDropUploadExtension(); diff --git a/packages/headless/src/extensions/embeds/FileEmbedNode.tsx b/packages/headless/src/extensions/embeds/FileEmbedNode.tsx index 0f921fd..2f0a6d4 100644 --- a/packages/headless/src/extensions/embeds/FileEmbedNode.tsx +++ b/packages/headless/src/extensions/embeds/FileEmbedNode.tsx @@ -6,141 +6,205 @@ */ import { + $createNodeSelection, + $createParagraphNode, + $getNearestNodeFromDOMNode, + $getNodeByKey, + $getSelection, + $isNodeSelection, + $setSelection, + CLICK_COMMAND, + COMMAND_PRIORITY_LOW, DecoratorNode, + KEY_DOWN_COMMAND, type DOMConversionMap, type DOMExportOutput, type ElementNode, + type LexicalEditor, type LexicalNode, type NodeKey, type SerializedLexicalNode, type Spread, } from "lexical"; -import type { ElementTransformer } from "@lexical/markdown"; +import type { MediaEdit } from "./EmbedResolverContext"; +import { UploadPlaceholderNode } from "./UploadPlaceholderNode"; +import type { ElementTransformer, TextMatchTransformer } from "@lexical/markdown"; import type { ReactNode } from "react"; import { ExtensionCategory } from "@lyfie/luthor-headless/extensions/types"; import { BaseExtension } from "@lyfie/luthor-headless/extensions/base"; -import { useEmbedResolvers } from "./EmbedResolverContext"; +import { MediaFrame } from "../media/MediaFrame"; +import { + formatEmbedTarget, + formatMediaDirectives, + isFileTarget, + parseEmbedTarget, + parseMediaDirectives, + type MediaAlignment, +} from "../media/mediaGrammar"; /** - * Serialized shape of a {@link FileEmbedNode}. Only the verbatim `target` (the - * `file.ext` reference) is persisted; the resolved URL and the chosen media - * element are derived at render time, so the stored data mirrors the markdown. + * Serialized shape of a {@link FileEmbedNode} (version 2). + * + * Version 1 stored only `target` — the whole text inside `![[…]]`, pipes and + * all. {@link FileEmbedNode.importJSON} still reads it (live collaboration rooms + * and saved editor states carry v1 nodes), parsing that text with the media + * grammar. */ export type SerializedFileEmbedNode = Spread< { + /** The file reference, without fragment or pipe segments. */ target: string; + fragment?: string; + alt?: string; + width?: number; + height?: number; + align?: MediaAlignment; + caption?: string; + /** Pipe segments the grammar doesn't own, verbatim. */ + extra?: string[]; + /** Trailing `` directives the grammar doesn't own, verbatim. */ + directives?: string[]; + escapedPipes?: boolean; + inline?: boolean; + /** The exact markdown this embed was parsed from (byte-stable export). */ + source?: string; }, SerializedLexicalNode >; -const IMAGE_EXTENSION = /\.(?:png|jpe?g|gif|webp|svg|avif|bmp|ico)$/i; -const AUDIO_EXTENSION = /\.(?:mp3|wav|ogg|oga|m4a|flac|aac)$/i; -const VIDEO_EXTENSION = /\.(?:mp4|webm|mov|m4v|ogv)$/i; - -type EmbedKind = "image" | "audio" | "video" | "file"; +/** The editable fields of an embed, as the markdown grammar sees them. */ +export interface FileEmbedFields { + target: string; + fragment?: string; + alt?: string; + width?: number; + height?: number; + align?: MediaAlignment; + caption?: string; + extra?: string[]; + directives?: string[]; + escapedPipes?: boolean; + inline?: boolean; +} -function classifyEmbed(target: string): EmbedKind { - if (IMAGE_EXTENSION.test(target)) { - return "image"; - } - if (AUDIO_EXTENSION.test(target)) { - return "audio"; - } - if (VIDEO_EXTENSION.test(target)) { - return "video"; - } - return "file"; +function formatFields(fields: FileEmbedFields): string { + const inner = formatEmbedTarget({ + target: fields.target, + fragment: fields.fragment ?? "", + alt: fields.alt, + width: fields.width, + height: fields.height, + extra: fields.extra ?? [], + escapedPipes: fields.escapedPipes ?? false, + }); + // Directives are a line-level syntax: an inline embed has none of its own. + const directives = fields.inline + ? "" + : formatMediaDirectives({ + align: fields.align, + caption: fields.caption, + unknown: fields.directives ?? [], + }); + return `![[${inner}]]${directives}`; } /** - * React view for a file embed. Resolves the target to a URL through the host - * {@link EmbedResolvers} and renders the matching media element; when no URL can - * be resolved (no host wired, or a non-media file) it renders a reference chip - * showing the raw target, so the embed is always visible and the markdown still - * round-trips. + * Parse one embed's markdown (`![[inner]]` plus, for a block, any trailing + * directives). Null when it isn't one. */ -function FileEmbedComponent({ target }: { target: string }): ReactNode { - const { resolveMediaUrl } = useEmbedResolvers(); - const url = - typeof resolveMediaUrl === "function" ? resolveMediaUrl(target) : ""; - const kind = classifyEmbed(target); - - if (url && kind === "image") { - return ( - {target} - ); - } - - if (url && kind === "audio") { - return ( -