diff --git a/CHANGELOG.md b/CHANGELOG.md index 2763a208..0a4bb1e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Leafdown uses lightweight [Keep a Changelog](https://keepachangelog.com/en/1.1.0 ### Changed +- Edit standalone images and links whose complete label is one image through the same in-document Markdown source projection as other inline objects. The projected source appears in a regular one-line lane immediately before the still-rendered image, distinguishes pointer entry from directional keyboard entry, exits cleanly past either source edge, shares document selection, clipboard, history, and dirty-state behavior, reads edited description Markdown through the parser, and keeps the authored target when a rendered image is moved. Unavailable-image placeholders stay one line tall and leave their target path to the source projection. - Keep local SVG images visible in rendered Markdown, and give tiny or transparent images a non-collapsing editor surface so they can still be selected without enlarging their authored pixels. - Rebuild the welcome screen as a launcher. `New document` joins `Open file` and `Open folder`, so the fastest way to start writing is no longer the least visible one, and each of the three names its keyboard shortcut the way the menus do. A recent item is listed by its own name with the folder holding it beside it, where the row used to be the full path cut off at the end, which is where the file name is. The recent lists stay away entirely until there is a recent item to list. `Clear recent items` is a small action under the lists it clears instead of a full-size button in the row beside `Open file`. - Show the article navigator only while a folder context is open. Without one it had nothing to list and stood beside the welcome screen repeating what that screen already said, so the document surface now spans the workspace instead. `Toggle sidebar` is unavailable until a folder opens, in the View menu and in the titlebar alike, and leaves the sidebar visibility setting as it found it, so a sidebar hidden before one folder closed is still hidden when the next one opens. diff --git a/docs/architecture.md b/docs/architecture.md index dc97c49a..f11eef37 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -100,7 +100,7 @@ A restructure reads canonical content, so it finalizes an active projection befo A change that reaches the projected range without passing through the engine's edit path is an unauthored write; composition input is the path that produces one. The engine keeps an unauthored write out of native history, where it would replay against coordinates the commit discards, and otherwise treats it as the content change it is: the document becomes dirty and projection-local history can step back over it. -Object adapters own target discovery, source generation, validation, rehydration, presentation spans, and selection mapping. Ownership precedence is logical link, qualifying marked fragment, standalone footnote reference, preserved character reference, then escaped literal run. Adapters that cannot preserve a semantic mapping fall back to literal text. +Object adapters own target discovery, source generation, validation, rehydration, presentation spans, and selection mapping. Ownership precedence is logical link, qualifying marked fragment, standalone image, standalone footnote reference, preserved character reference, then escaped literal run. Adapters that cannot preserve a semantic mapping fall back to literal text. A boundary adapter is asked before that precedence and claims only a caret two objects meet on, finding each side by putting the same precedence to a position inside it, on a state carrying no plugins so the probe cannot project what it is reading. Its target is the run the pair spells, and it commits by reading that run as the file would rather than by committing each side on its own, because the position the two meet on belongs to neither: a character written there would otherwise have to join one object, which would stop that object spelling itself and discard it. Reading the run whole also decides the seam without a rule of its own, since the file already says what the characters between two objects mean. Where one object owns the caret outright, precedence answers as it did. @@ -108,7 +108,7 @@ An adapter may declare that its session is finished while the caret is still ins The escape adapter owns no object of its own. Its target is a run of literal text the object adapters can convert, its source is what the serializer writes for that run on its own, and its commit is the object the edited run spells, so it reads the same validation the object adapters already own rather than repeating it. Its targets carry no mark, which is where the object adapters find theirs, so precedence between them never has to be resolved. -Marker presentation remains separate from projection lifecycle. Decorations style active source, while projected Markdown remains document text rather than widget or NodeView input state. +Marker presentation remains separate from projection lifecycle. Decorations style active source, while projected Markdown remains document text rather than widget or NodeView input state. A standalone image, or a logical link whose complete label is one image, inserts its source immediately before the original image node rather than replacing it for the duration of the session. The projected text remains the session's editable range, while the retained image stays outside that range and keeps its NodeView and decoded media. Pointer entry is carried through any restore and commit transactions separately from directional keyboard entry, so clicking can target the alt text without turning keyboard traversal over the retained node into another entry. Finalization removes the temporary source and, for an edit, replaces the retained original with the parsed result through the same two-phase history path used by other projections. Links with mixed or multiple label nodes keep the ordinary source-only link projection because their semantic target is the complete label rather than one retained image. ## Backend Responsibilities diff --git a/docs/specification.md b/docs/specification.md index 1e5d221f..a414f904 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -151,7 +151,7 @@ The editor is a unified hybrid Markdown surface. Behavior is governed by renderi ### Inline Content - Strong, emphasis, inline code, and strikethrough render visually and expose editable local markers near the caret. -- Seamless source projection for strong, emphasis, strikethrough, inline code, links, autolinks, and footnote references is local to the active inline object. For mark-based content, a caret or text selection activates projection when it is contained within one exact, contiguous combination of supported inline marks, and a line break inside that combination is part of the fragment rather than a boundary of it. Editing a projected marker can change that object's inline style, but it does not automatically merge adjacent marked runs; broader reshaping is done with an explicit selection or formatting command. A projected fragment carries the escapes the file will be written with, as a link label already does, so a run the file keeps literal by a backslash is not shown as the construct that backslash prevents. +- Seamless source projection for strong, emphasis, strikethrough, inline code, links, autolinks, images, and footnote references is local to the active inline object. For mark-based content, a caret or text selection activates projection when it is contained within one exact, contiguous combination of supported inline marks, and a line break inside that combination is part of the fragment rather than a boundary of it. Editing a projected marker can change that object's inline style, but it does not automatically merge adjacent marked runs; broader reshaping is done with an explicit selection or formatting command. A projected fragment carries the escapes the file will be written with, as a link label already does, so a run the file keeps literal by a backslash is not shown as the construct that backslash prevents. - Inline-code projection shows the backtick run the file is written with: the shortest run the content leaves free, plus whatever the authored run spent over it. An edit inside the content leaves that run alone, and a marker the author edits records the run they typed. A recorded run the content grows to spell gives way to the free run, since a run the content holds would close the span at itself. - Link and autolink projection exposes their source directly in the document; links preserve their label, target, optional title, and compatible uniform outer inline formatting. An autolink keeps the form it was authored in, bare or angle-bracket, when it is projected and when it is saved. A link remains one semantic projection owner. A caret or contained text selection anywhere in a supported label projects the complete link source, including labels with nested strong, emphasis, strikethrough, inline-code formatting, a line break, an image, a footnote reference, a preserved character reference, or whitespace at either edge. An image or footnote reference in a projected label becomes its own Markdown source and returns as its object when the label commits. A label broken across lines remains one logical label, and each of its lines is written under the prefix or indentation the block holding it gives that line. Valid edits rehydrate one link over the complete rich label; invalid or incomplete edits become the literal text the source spells, where a backslash escapes the character it precedes and is otherwise kept as text. Mixed-format and multiline labels do not fall back to fragmented projections for their nested content. - A projected link label or marked fragment carries every form of line break it holds: a soft line ending, and a hard break written as a backslash or as a run of two or more spaces. A hard break shows the run it is written with under the rule for hard breaks in [Serialization And Output](#serialization-and-output), and the run reads as a marker. The run and the line ending after it stand for the one break the document holds, so a caret standing just before the break enters before the run, and one standing just after it enters after the line ending. A run of spaces closing a line draws nothing on its own, so each space in it carries a dot; the dot is presentation only, and the spaces are the text the source holds. A hard break at either edge of a marked run is not part of the fragment, as whitespace there is not, because the delimiters cannot be written around it. A clean session restores the break exactly, and a committed edit keeps the break, its run, and the single link or mark around it. An edit to the run commits the break the source then spells, so a backslash deleted, or a run left with fewer than two spaces, commits a soft line ending. An edit that stops the object spelling itself becomes literal text as any other projected source does, and a backslash before a line ending stays text there rather than a break. @@ -164,6 +164,7 @@ The editor is a unified hybrid Markdown surface. Behavior is governed by renderi - A projected character reference shows the character it names immediately before its source, because the form and the character it produces are read together and a reference naming whitespace cannot otherwise be confirmed from the source. The character reads as the content it renders and the source reads as the marker it is, so a reference in a link label draws its character in the label's own styling while `©` beside it is muted, and one in a marked fragment draws its character with that fragment's emphasis. The character is presentation only: it is not document text, takes no caret, is never selected, copied, or saved, and moves no offset between the document and the file. The character and the `&` that opens the source read as one position, so a caret at the start of the source rests before both and one move to the right leaves the pair. It is shown wherever a reference is projected, so the reference's own projection, a marked fragment holding one, and a link label holding one all present it the same way. Source that spells no reference shows none, which covers a form an edit has broken and text a backslash or a code span keeps literal. - A character reference within one exact, contiguous supported mark combination belongs to that marked fragment. Entering through its text, the reference itself, or either end of the fragment projects one outer wrapper such as `**a©b**`, and a valid edit commits one mark with the reference intact. Logical links retain higher semantic ownership, so a caret or contained text selection anywhere in a label holding a reference projects the complete link source, while standalone or otherwise ineligible references use the reference-only projection above. - A link wrapped by one exact, contiguous supported mark combination belongs to that marked fragment. Entering from either side of the link projects one outer wrapper holding the link's complete source, such as `**bold [a b](./doc.md) tail**`, and a valid edit commits one mark around the link, its label, and its destination. A mark that stops at the link keeps its own projection, and logical links retain higher semantic ownership, so a caret inside the label still projects the link alone. +- A standalone image projects its complete Markdown source as editable document text in a 28-pixel line immediately before the rendered image. The image remains mounted and visible during the session, while the extra source line deliberately moves the following document content until projection closes. A caret entering from the left starts at the beginning of the source, a caret entering from the right starts at the end, and clicking or selecting the rendered image opens a collapsed caret immediately after `![`, at the start of the alt text. The caret does not enter the image node: moving left from the source start or right from its end restores the ordinary editor surface on the corresponding side of the image. A valid edit rehydrates one canonical image; an invalid or incomplete edit becomes the literal text its source spells, on the same escape rule as a link. The source preserves the description, reference form, destination form, and title marker the file holds. A logical link whose complete label is one image follows the same retained-image interaction while projecting the complete link source; clicking it starts the caret after `[![`, so its image and link destinations remain one editable construct. A link with mixed text, multiple images, or other label content keeps the ordinary source-only link projection. An image inside one exact, contiguous supported mark combination belongs to that marked fragment. - A footnote reference within one exact, contiguous supported mark combination belongs to that marked fragment. Entering through its text, either reference boundary, or the atomic reference projects one outer wrapper such as `**archive note[^archive]**`; the complete compatible mark set applies to both text and reference nodes. Logical links retain higher semantic ownership, while standalone or otherwise ineligible references use the reference-only adapter. - Standalone footnote references project their complete `[^label]` source as editable document text. A caret entering from the left starts at the beginning of the source, a caret entering from the right starts at the end, and selecting an atomic reference selects its label after projection. Valid edits in either projection rehydrate canonical Milkdown footnote-reference nodes, and an invalid edit to a standalone reference becomes the literal text its source spells, on the same escape rule as a link. If a marked wrapper remains valid, incomplete reference-like content remains exact text inside its outer marks; if the outer wrapper becomes invalid, the complete projected source becomes exact unmarked literal text. Editing a reference label does not create, rename, delete, or modify any footnote definition. - A reference link and a reference image project the reference source they were written as, such as `[Full reference][garden report]` or `![Reference leaf][leaf]`, rather than the destination their definition names, because a projection reads as the file will be written. The definitions the document holds travel with the projected source, so an edit that still names one of them commits a reference, an edit that spells an inline link or image commits that object instead, and any other edit becomes the literal text the source spells, on the same rule a link already follows. A reference written as text in the current editing session stays that text until the file is read back, so typing a reference form never claims a definition on its own. Editing a reference label does not create, rename, delete, or modify any definition. @@ -173,7 +174,7 @@ The editor is a unified hybrid Markdown surface. Behavior is governed by renderi - Normal click places the caret in a link; `Mod+click` opens it. - A footnote reference resolves to the definition its label names, and every reference sharing a label resolves to the same definition. `Mod+click` on a reference moves the caret into that definition's body, which is what `Jump to footnote definition` does from the keyboard; the plain click keeps opening the reference's own source, so navigation never takes that gesture away. Navigating moves the caret and nothing else: the Markdown, the dirty state, and the undo history are untouched. A reference no definition answers to navigates nowhere and leaves the command unavailable. A file never opens holding one, because a label without a definition is read as literal text rather than a reference; the state is reached inside the session, by editing a reference's label to a name no definition answers to or by deleting the definition it named. - Resting the pointer on a footnote reference previews the definition it names after a short delay, and `Mod+Alt+P` opens the same preview on the reference the caret is reading, so the preview is not hover-only. It is a tooltip rather than a surface to work in: it holds no control, takes no focus, and reports the definition's text on one line, cut short where the definition runs longer than the preview shows. A reference no definition answers to previews an explicit missing-definition state rather than an empty one. The pointer leaving the reference, `Escape`, and any key that edits or moves the caret close it. Nothing about the document changes while a preview opens, closes, or is navigated from. -- Local relative images render automatically. Clicking an image focuses it. When focused, show the raw image Markdown above the image for editing. +- Local relative images render automatically. Clicking a standalone image projects its source in place for editing. ### Keyboard Editing @@ -368,13 +369,13 @@ Confirmations, warnings, and security blocks affect editor rendering only; sourc - Local relative images render automatically when supported. - Supported local image formats are `.png`, `.jpg`, `.jpeg`, `.gif`, `.svg`, and `.webp`. -- Missing local images show a clear placeholder. +- Missing, inaccessible, or otherwise unavailable local images show a short placeholder without repeating the authored target path; the complete target remains available by entering source projection. - Remote image Markdown is preserved, but network images are currently blocked completely; loading them is deferred. - Local images that resolve outside the current folder context require explicit confirmation before rendering. Instead of a blocking modal, the editor displays an inline placeholder in place of the image, prompting the user to click to load/render it. -- Selecting a rendered or placeholder image exposes the raw image Markdown for editing the description and target path. +- Moving the caret onto a rendered image or placeholder exposes its complete Markdown source in a 28-pixel line immediately before the image for editing the description, target, optional title, or reference form. Moving the caret beyond either source boundary restores the ordinary editor surface. The decoded image remains mounted below the source while projection is active, and unavailable-image placeholders remain one line tall. - A rendered image keeps its intrinsic aspect ratio and stays within the document width. Its editor surface never collapses, so a tiny or transparent image keeps its authored pixels while remaining reliably selectable with the usual selected-node treatment. - An image description keeps the Markdown it was written with, including emphasis, strong, inline code, strikethrough, a link, and a nested image. It is source the image carries rather than content the document holds, so a nested image is written back as it was authored rather than rendered as a second image, and the image is named by the text its description spells. -- The raw image Markdown of a focused image is the source the file holds. Editing the destination or the title leaves the description as it was written; editing the description replaces it with the text typed there, which is written back escaped. +- The projected image Markdown is the source the file holds. Editing a description reads its inline Markdown through the shared parser, so supported inline content in the description returns as image description content rather than being escaped as plain text. For local-path resolution and asset-protocol handling, see [Architecture](./architecture.md#frontend-responsibilities). diff --git a/e2e/desktop/specs/rendered-images.spec.ts b/e2e/desktop/specs/rendered-images.spec.ts index 72f95c24..6cb124a9 100644 --- a/e2e/desktop/specs/rendered-images.spec.ts +++ b/e2e/desktop/specs/rendered-images.spec.ts @@ -8,6 +8,11 @@ interface ElementBox { width: number; } +interface ElementRect extends ElementBox { + x: number; + y: number; +} + const getBox = async (element: ReturnType): Promise => { const result = await element.execute((node) => { const { height, width } = node.getBoundingClientRect(); @@ -18,6 +23,16 @@ const getBox = async (element: ReturnType): Promise => { return result as ElementBox; }; +const getRect = async (element: ReturnType): Promise => { + const result = await element.execute((node) => { + const { height, width, x, y } = node.getBoundingClientRect(); + + return { height, width, x, y }; + }); + + return result as ElementRect; +}; + describe("desktop rendered images", () => { it("keeps decoded SVGs visible and tiny images on an editor surface", async () => { const { images } = await getDesktopE2ERunContext(); @@ -27,11 +42,13 @@ describe("desktop rendered images", () => { const visibleImage = $('img[alt="Visible SVG"]'); const linkedImage = $('img[alt="Linked SVG"]'); const tinyImage = $('img[alt="Tiny transparent SVG"]'); - const missingPlaceholder = $(".leafdown-image-placeholder"); await expect(visibleImage).toBeDisplayed(); await expect(linkedImage).toBeDisplayed(); await expect(tinyImage).toBeDisplayed(); + + const missingPlaceholder = $(".leafdown-image-placeholder"); + await expect(missingPlaceholder).toBeDisplayed(); const [visibleBox, linkedBox, tinyBox] = await Promise.all([ @@ -59,5 +76,44 @@ describe("desktop rendered images", () => { expect(selectionSurface.width).toBeGreaterThanOrEqual(32); expect(selectionSurface.height).toBe(28); expect(placeholderBox.height).toBe(28); + + expect(await missingPlaceholder.getText()).toBe("Image not found."); + + const linkedBefore = await getRect(linkedImage); + + await visibleImage.execute((node) => { + (window as Window & { leafdownRetainedImage?: Element }).leafdownRetainedImage = node; + }); + await visibleImage.execute((node) => { + node.dispatchEvent( + new MouseEvent("mousedown", { + bubbles: true, + button: 0, + cancelable: true, + }), + ); + }); + + const projection = $('.leafdown-source-projection[data-leafdown-source~="image"]'); + const projectedImage = $('img[alt="Visible SVG"]'); + + await expect(projection).toBeDisplayed(); + await expect(projectedImage).toBeDisplayed(); + const [projectionBox, projectionRect, projectedRect, linkedAfter, retainedImage] = + await Promise.all([ + getBox(projection), + getRect(projection), + getRect(projectedImage), + getRect(linkedImage), + projectedImage.execute( + (node) => + (window as Window & { leafdownRetainedImage?: Element }).leafdownRetainedImage === node, + ), + ]); + + expect(projectionBox.height).toBe(28); + expect(projectionRect.y).toBeLessThan(projectedRect.y); + expect(linkedAfter.y).toBeGreaterThan(linkedBefore.y); + expect(retainedImage).toBe(true); }); }); diff --git a/src/features/editor/components/milkdown-editor.css b/src/features/editor/components/milkdown-editor.css index 3357fcdc..68d588cf 100644 --- a/src/features/editor/components/milkdown-editor.css +++ b/src/features/editor/components/milkdown-editor.css @@ -307,24 +307,16 @@ @apply my-0 block; } - .leafdown-image-markdown-input { - @apply mb-2 w-full min-w-64 rounded-sm border border-border bg-muted px-2 py-1 font-mono text-sm text-foreground outline-none; - - &:focus-visible { - @apply border-primary ring-2 ring-primary/25; - } - } - .leafdown-image-placeholder { - @apply inline-flex max-w-full flex-wrap items-center gap-2 rounded-md border border-dashed border-border bg-card/70 px-3 py-[3px] text-sm text-muted-foreground; + @apply flex h-7 max-w-full flex-nowrap items-center gap-2 overflow-hidden rounded-md border border-dashed border-border bg-card/70 px-2 text-sm text-muted-foreground; } .leafdown-image-placeholder__message { - @apply min-w-0 wrap-anywhere; + @apply min-w-0 flex-1 truncate; } .leafdown-image-placeholder__action { - @apply shrink-0 rounded-sm border border-border bg-muted px-2 py-1 text-xs font-medium text-foreground transition-colors; + @apply h-6 shrink-0 rounded-sm border border-border bg-muted px-2 text-xs font-medium text-foreground transition-colors; &:hover { @apply border-primary text-primary; @@ -497,6 +489,15 @@ color: inherit; } + .leafdown-source-projection[data-leafdown-source~="image"] { + @apply block h-7 max-w-full overflow-x-auto overflow-y-hidden font-mono whitespace-nowrap; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + } + .leafdown-source-projection__marker { @apply text-muted-foreground; font-style: normal; diff --git a/src/features/editor/plugins/characterReference.ts b/src/features/editor/plugins/characterReference.ts index 52860ca1..0d858a3b 100644 --- a/src/features/editor/plugins/characterReference.ts +++ b/src/features/editor/plugins/characterReference.ts @@ -8,6 +8,8 @@ import { findAuthoredDestination, findAuthoredReferenceDescription, findAuthoredTitle, + findImageDestinationMarker, + IMAGE_DESTINATION_MARKER_ATTRIBUTE_NAME, splitCharacterReferences, } from "../utils/characterReferenceMarkdown"; import { findTitleMarker, type TitleMarker } from "../utils/markdownTitle"; @@ -74,6 +76,8 @@ const markAuthoredSource = (node: MarkdownNode, source: string) => { } if (child.type === "image") { + (child as Record)[IMAGE_DESTINATION_MARKER_ATTRIBUTE_NAME] = + findImageDestinationMarker(raw); markAuthoredDescription( child, findAuthoredDescription(raw, readNodeString(child, "alt") ?? "", child.url), diff --git a/src/features/editor/plugins/imageView.test.ts b/src/features/editor/plugins/imageView.test.ts index f6dc8b36..3dbfd3ae 100644 --- a/src/features/editor/plugins/imageView.test.ts +++ b/src/features/editor/plugins/imageView.test.ts @@ -1,5 +1,6 @@ // @vitest-environment happy-dom +import { DOMParser as ProseMirrorDOMParser } from "@milkdown/kit/prose/model"; import { convertFileSrc } from "@tauri-apps/api/core"; import { describe, expect, it, vi } from "vitest"; @@ -7,17 +8,18 @@ import { EDITOR_TEST_ROOT_CLASS_NAME, createMarkdownReferenceContext, } from "@/test/factories/editor"; -import { dispatchInput, dispatchMouseDown } from "@/test/utils/events"; +import { dispatchMouseDown } from "@/test/utils/events"; import { setupMilkdownEditorMount } from "@/test/utils/milkdown"; +import { getEditorNodePosition, setSelectionAtDocumentEnd } from "@/test/utils/prosemirror"; import { setupUser, waitFor, within } from "@/test/utils/react"; import { countTauriApiCalls, getLastTauriApiArgs, - mockTauriApi, mockTauriApiCommand, } from "@/test/utils/tauriApi"; -type RenderableImageTargetResult = { kind: "renderable"; path: string }; +import { IMAGE_DESTINATION_MARKER_ATTRIBUTE_NAME } from "../utils/characterReferenceMarkdown"; +import { TITLE_MARKER_ATTRIBUTE_NAME } from "../utils/markdownTitle"; const mountImageEditor = setupMilkdownEditorMount({ ...createMarkdownReferenceContext(), @@ -25,468 +27,223 @@ const mountImageEditor = setupMilkdownEditorMount({ }); describe("Markdown images", () => { - describe("resolution outcomes", () => { - it("renders supported local images through backend resolution and Tauri asset URLs", async () => { - mockTauriApiCommand("resolveMarkdownImageTarget", () => ({ - kind: "renderable", - path: "C:\\Notes\\assets\\icon special.png", - })); - - const mounted = await mountImageEditor("![Sample Icon](./assets/icon.png)"); - - await waitFor(() => { - expect( - within(mounted.view.dom).getByRole("img", { name: "Sample Icon" }), - ).toBeInTheDocument(); - }); + it("renders supported local images through backend resolution and Tauri asset URLs", async () => { + mockTauriApiCommand("resolveMarkdownImageTarget", () => ({ + kind: "renderable", + path: "C:\\Notes\\assets\\icon special.png", + })); - const image = within(mounted.view.dom).getByRole("img", { - name: "Sample Icon", - }); + const mounted = await mountImageEditor("![Sample Icon](./assets/icon.png)"); - expect(image.getAttribute("src")).toBe( - "asset://localhost/C%3A%2FNotes%2Fassets%2Ficon%20special.png", - ); - expect(convertFileSrc).toHaveBeenCalledWith("C:/Notes/assets/icon special.png"); - expect(getLastTauriApiArgs("resolveMarkdownImageTarget")).toEqual({ - ...createMarkdownReferenceContext(), - allowOutsideFolder: false, - target: "./assets/icon.png", - }); - expect(mounted.getMarkdown()).toBe("![Sample Icon](./assets/icon.png)\n"); + await waitFor(() => { + expect( + within(mounted.view.dom).getByRole("img", { name: "Sample Icon" }), + ).toBeInTheDocument(); }); - it("blocks remote image loading while preserving source Markdown", async () => { - mockTauriApiCommand("resolveMarkdownImageTarget", () => ({ kind: "remoteBlocked" })); - - const mounted = await mountImageEditor("![Remote](https://example.com/image.png)"); - - await waitFor(() => { - expect(mounted.view.dom).toHaveTextContent("Remote images are blocked."); - }); - - expect(mounted.view.dom.querySelector(".leafdown-markdown-image")).not.toBeInTheDocument(); - expect(convertFileSrc).not.toHaveBeenCalled(); - expect(mounted.getMarkdown()).toBe("![Remote](https://example.com/image.png)\n"); + const image = within(mounted.view.dom).getByRole("img", { + name: "Sample Icon", }); - it.each([ - { - backendResult: { kind: "missing", path: "C:/Notes/assets/missing.png" }, - expectedMessage: "Image not found: ./assets/missing.png", - markdown: "![Missing](./assets/missing.png)", - }, - { - backendResult: { kind: "untitledRelative" }, - expectedMessage: "Save the document to resolve this image.", - markdown: "![Untitled](./assets/icon.png)", - }, - { - backendResult: { kind: "unsupportedFormat" }, - expectedMessage: "Unsupported image format.", - markdown: "![Unsupported](./assets/readme.txt)", - }, - { - backendResult: { kind: "unsupportedTarget" }, - expectedMessage: "Unsupported image target.", - markdown: "![Unsafe](custom:target.png)", - }, - { - backendResult: { - kind: "permissionDenied", - path: "C:/Notes/assets/private.png", - message: "No image permission.", - }, - expectedMessage: "No image permission.", - markdown: "![Denied](./assets/private.png)", - }, - { - backendResult: { - kind: "metadataFailed", - path: "C:/Notes/assets/image.png", - message: "Could not inspect image.", - }, - expectedMessage: "Could not inspect image.", - markdown: "![Metadata](./assets/image.png)", - }, - ] as const)( - "renders a safe placeholder for $backendResult.kind images without mutating Markdown", - async ({ backendResult, expectedMessage, markdown }) => { - mockTauriApiCommand("resolveMarkdownImageTarget", () => backendResult); - - const mounted = await mountImageEditor(markdown); - - await waitFor(() => { - expect(mounted.view.dom).toHaveTextContent(expectedMessage); - }); - - expect(mounted.view.dom.querySelector(".leafdown-markdown-image")).not.toBeInTheDocument(); - expect(convertFileSrc).not.toHaveBeenCalled(); - expect(mounted.getMarkdown()).toBe(`${markdown}\n`); - }, + expect(image.getAttribute("src")).toBe( + "asset://localhost/C%3A%2FNotes%2Fassets%2Ficon%20special.png", ); - }); - - describe("explicit loading", () => { - it("requires an inline explicit load before rendering outside-folder images", async () => { - const user = setupUser(); - const resolveMarkdownImageTarget = vi - .fn() - .mockResolvedValueOnce({ kind: "outsideFolder", path: "C:\\Other\\outside.png" }) - .mockResolvedValue({ kind: "renderable", path: "C:\\Other\\outside.png" }); - mockTauriApiCommand("resolveMarkdownImageTarget", resolveMarkdownImageTarget); - - const mounted = await mountImageEditor("![Outside](../outside.png)"); - - await waitFor(() => { - expect(mounted.view.dom).toHaveTextContent( - "Image is outside the current folder: C:\\Other\\outside.png", - ); - }); - - const button = within(mounted.view.dom).getByRole("button", { name: "Load image" }); - - await user.click(button); - - await waitFor(() => { - expect(within(mounted.view.dom).getByRole("img", { name: "Outside" })).toBeInTheDocument(); - }); - - expect(getLastTauriApiArgs("resolveMarkdownImageTarget")).toEqual({ - ...createMarkdownReferenceContext(), - allowOutsideFolder: true, - target: "../outside.png", - }); - expect(mounted.getMarkdown()).toBe("![Outside](../outside.png)\n"); + expect(convertFileSrc).toHaveBeenCalledWith("C:/Notes/assets/icon special.png"); + expect(getLastTauriApiArgs("resolveMarkdownImageTarget")).toEqual({ + ...createMarkdownReferenceContext(), + allowOutsideFolder: false, + target: "./assets/icon.png", }); + expect(mounted.getMarkdown()).toBe("![Sample Icon](./assets/icon.png)\n"); }); - describe("resolution lifecycle", () => { - it("retries failed image resolutions on a later matching update", async () => { - const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); - - const resolveMarkdownImageTarget = vi - .fn() - .mockRejectedValueOnce(new Error("Image resolver unavailable.")) - .mockResolvedValueOnce({ kind: "renderable", path: "C:\\Notes\\assets\\icon.png" }); - mockTauriApiCommand("resolveMarkdownImageTarget", resolveMarkdownImageTarget); - - const mounted = await mountImageEditor("![Retry](./assets/icon.png)"); - - await waitFor(() => { - expect(mounted.view.dom).toHaveTextContent("Image resolver unavailable."); - expect(consoleError).toHaveBeenCalledWith( - "Unexpected error (resolveMarkdownImage).", - expect.any(Error), - ); - }); - - const placeholder = mounted.view.dom.querySelector(".leafdown-image-placeholder"); - - if (!placeholder) { - throw new Error("Expected image placeholder to be rendered."); - } - - dispatchMouseDown(placeholder); - dispatchInput( - within(mounted.view.dom).getByRole("textbox", { name: "Image Markdown" }), - "![Retried](./assets/icon.png)", - ); - - await waitFor(() => { - expect(within(mounted.view.dom).getByRole("img", { name: "Retried" })).toHaveAttribute( - "src", - "asset://localhost/C%3A%2FNotes%2Fassets%2Ficon.png", - ); - }); - - expect(countTauriApiCalls("resolveMarkdownImageTarget")).toBe(2); - }); - - it("does not re-resolve images when raw Markdown edits keep the same source", async () => { - mockTauriApiCommand("resolveMarkdownImageTarget", () => ({ - kind: "renderable", - path: "C:\\Notes\\assets\\icon.png", - })); - - const mounted = await mountImageEditor("![Alt](./assets/icon.png)"); - - await waitFor(() => { - expect(within(mounted.view.dom).getByRole("img", { name: "Alt" })).toBeInTheDocument(); - }); - expect(countTauriApiCalls("resolveMarkdownImageTarget")).toBe(1); - - const image = within(mounted.view.dom).getByRole("img", { name: "Alt" }); - dispatchMouseDown(image); + it("blocks remote image loading while preserving source Markdown", async () => { + mockTauriApiCommand("resolveMarkdownImageTarget", () => ({ kind: "remoteBlocked" })); - const input = within(mounted.view.dom).getByRole("textbox", { name: "Image Markdown" }); - dispatchInput(input, '![Updated](./assets/icon.png "Updated title")'); + const mounted = await mountImageEditor("![Remote](https://example.com/image.png)"); - await waitFor(() => { - expect(within(mounted.view.dom).getByRole("img", { name: "Updated" })).toHaveAttribute( - "title", - "Updated title", - ); - }); - expect(countTauriApiCalls("resolveMarkdownImageTarget")).toBe(1); - expect(mounted.getMarkdown()).toBe('![Updated](./assets/icon.png "Updated title")\n'); + await waitFor(() => { + expect(mounted.view.dom).toHaveTextContent("Remote images are blocked."); }); - it("ignores stale image resolutions after the image source changes", async () => { - const oldResolution = Promise.withResolvers(); - const newResolution = Promise.withResolvers(); + expect(mounted.view.dom.querySelector(".leafdown-markdown-image")).not.toBeInTheDocument(); + expect(convertFileSrc).not.toHaveBeenCalled(); + expect(mounted.getMarkdown()).toBe("![Remote](https://example.com/image.png)\n"); + }); - mockTauriApi({ - resolveMarkdownImageTarget: ({ target }) => { - return target === "./assets/old.png" ? oldResolution.promise : newResolution.promise; - }, - }); + it.each([ + { + backendResult: { kind: "missing", path: "C:/Notes/assets/missing.png" }, + expectedMessage: "Image not found.", + markdown: "![Missing](./assets/missing.png)", + }, + { + backendResult: { kind: "untitledRelative" }, + expectedMessage: "Save the document to resolve this image.", + markdown: "![Untitled](./assets/icon.png)", + }, + { + backendResult: { kind: "unsupportedFormat" }, + expectedMessage: "Unsupported image format.", + markdown: "![Unsupported](./assets/readme.txt)", + }, + { + backendResult: { kind: "unsupportedTarget" }, + expectedMessage: "Unsupported image target.", + markdown: "![Unsafe](custom:target.png)", + }, + { + backendResult: { + kind: "permissionDenied", + path: "C:/Notes/assets/private.png", + message: "No image permission.", + }, + expectedMessage: "Image access denied.", + markdown: "![Denied](./assets/private.png)", + }, + { + backendResult: { + kind: "metadataFailed", + path: "C:/Notes/assets/image.png", + message: "Could not inspect image.", + }, + expectedMessage: "Image metadata unavailable.", + markdown: "![Metadata](./assets/image.png)", + }, + ] as const)( + "renders a safe placeholder for $backendResult.kind images without mutating Markdown", + async ({ backendResult, expectedMessage, markdown }) => { + mockTauriApiCommand("resolveMarkdownImageTarget", () => backendResult); - const mounted = await mountImageEditor("![Old](./assets/old.png)"); + const mounted = await mountImageEditor(markdown); await waitFor(() => { - expect(mounted.view.dom).toHaveTextContent("Resolving image..."); + expect(mounted.view.dom).toHaveTextContent(expectedMessage); }); - const placeholder = mounted.view.dom.querySelector(".leafdown-image-placeholder"); - - if (!placeholder) { - throw new Error("Expected image placeholder to be rendered."); - } - - dispatchMouseDown(placeholder); - dispatchInput( - within(mounted.view.dom).getByRole("textbox", { name: "Image Markdown" }), - "![New](./assets/new.png)", - ); - newResolution.resolve({ kind: "renderable", path: "C:\\Notes\\assets\\new.png" }); - - await waitFor(() => { - expect(within(mounted.view.dom).getByRole("img", { name: "New" })).toHaveAttribute( - "src", - "asset://localhost/C%3A%2FNotes%2Fassets%2Fnew.png", - ); - }); - - oldResolution.resolve({ kind: "renderable", path: "C:\\Notes\\assets\\old.png" }); - await Promise.resolve(); - - expect(within(mounted.view.dom).getByRole("img", { name: "New" })).toHaveAttribute( - "src", - "asset://localhost/C%3A%2FNotes%2Fassets%2Fnew.png", - ); - expect(mounted.view.dom.querySelector('img[src*="old.png"]')).not.toBeInTheDocument(); + expect(mounted.view.dom.querySelector(".leafdown-markdown-image")).not.toBeInTheDocument(); + expect(convertFileSrc).not.toHaveBeenCalled(); + expect(mounted.getMarkdown()).toBe(`${markdown}\n`); + }, + ); + + it("requires an inline explicit load before rendering outside-folder images", async () => { + const user = setupUser(); + const resolveMarkdownImageTarget = vi + .fn() + .mockResolvedValueOnce({ kind: "outsideFolder", path: "C:\\Other\\outside.png" }) + .mockResolvedValue({ kind: "renderable", path: "C:\\Other\\outside.png" }); + mockTauriApiCommand("resolveMarkdownImageTarget", resolveMarkdownImageTarget); + + const mounted = await mountImageEditor("![Outside](../outside.png)"); + + await waitFor(() => { + expect(mounted.view.dom).toHaveTextContent("Image outside the current folder."); }); - it("ignores stale image resolution failures after the image source changes", async () => { - const oldResolution = Promise.withResolvers(); - const newResolution = Promise.withResolvers(); + expect(mounted.view.dom).not.toHaveTextContent("C:\\Other\\outside.png"); - mockTauriApi({ - resolveMarkdownImageTarget: ({ target }) => { - return target === "./assets/old.png" ? oldResolution.promise : newResolution.promise; - }, - }); - - const mounted = await mountImageEditor("![Old](./assets/old.png)"); - - await waitFor(() => { - expect(mounted.view.dom).toHaveTextContent("Resolving image..."); - }); + await user.click(within(mounted.view.dom).getByRole("button", { name: "Load image" })); - const placeholder = mounted.view.dom.querySelector(".leafdown-image-placeholder"); - - if (!placeholder) { - throw new Error("Expected image placeholder to be rendered."); - } - - dispatchMouseDown(placeholder); - dispatchInput( - within(mounted.view.dom).getByRole("textbox", { name: "Image Markdown" }), - "![New](./assets/new.png)", - ); - newResolution.resolve({ kind: "renderable", path: "C:\\Notes\\assets\\new.png" }); - - await waitFor(() => { - expect(within(mounted.view.dom).getByRole("img", { name: "New" })).toBeInTheDocument(); - }); - - oldResolution.reject(new Error("Old image resolution failed.")); - await Promise.resolve(); - - expect(mounted.view.dom).not.toHaveTextContent("Old image resolution failed."); - expect(within(mounted.view.dom).getByRole("img", { name: "New" })).toBeInTheDocument(); + await waitFor(() => { + expect(within(mounted.view.dom).getByRole("img", { name: "Outside" })).toBeInTheDocument(); }); - }); - describe("raw Markdown editing", () => { - it("exposes focused images as editable raw Markdown", async () => { - mockTauriApiCommand("resolveMarkdownImageTarget", () => ({ - kind: "renderable", - path: "C:\\Notes\\assets\\icon.png", - })); - - const mounted = await mountImageEditor("![Alt](./assets/icon.png)"); - - await waitFor(() => { - expect(within(mounted.view.dom).getByRole("img", { name: "Alt" })).toBeInTheDocument(); - }); - - const image = within(mounted.view.dom).getByRole("img", { name: "Alt" }); - dispatchMouseDown(image); - - const input = within(mounted.view.dom).getByRole("textbox", { name: "Image Markdown" }); - - expect(input).toHaveValue("![Alt](./assets/icon.png)"); - dispatchInput(input, '![Updated](./assets/updated.png "Updated title")'); - - await waitFor(() => { - expect(mounted.getMarkdown()).toBe('![Updated](./assets/updated.png "Updated title")\n'); - }); + expect(getLastTauriApiArgs("resolveMarkdownImageTarget")).toEqual({ + ...createMarkdownReferenceContext(), + allowOutsideFolder: true, + target: "../outside.png", }); + }); - it("keeps raw Markdown editing focused while image resolution rerenders", async () => { - const user = setupUser(); - mockTauriApiCommand("resolveMarkdownImageTarget", () => ({ - kind: "renderable", - path: "C:\\Notes\\assets\\icon.png", - })); - - const mounted = await mountImageEditor("![Alt](./assets/icon.png)"); - - await waitFor(() => { - expect(within(mounted.view.dom).getByRole("img", { name: "Alt" })).toBeInTheDocument(); - }); - - const image = within(mounted.view.dom).getByRole("img", { name: "Alt" }); - dispatchMouseDown(image); - - const input = within(mounted.view.dom).getByRole("textbox", { - name: "Image Markdown", - }); - - await user.click(input); - input.setSelectionRange(4, 4); - await user.keyboard("p"); + it("keeps a failed resolution mounted through a clean source-projection round trip", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + const resolveMarkdownImageTarget = vi + .fn() + .mockRejectedValueOnce(new Error("Image resolver unavailable.")); + mockTauriApiCommand("resolveMarkdownImageTarget", resolveMarkdownImageTarget); - const latestInput = within(mounted.view.dom).getByRole("textbox", { - name: "Image Markdown", - }); + const mounted = await mountImageEditor("![Retry](./assets/icon.png) tail"); - expect(latestInput).toBe(document.activeElement); - expect(latestInput).toHaveValue("![Alpt](./assets/icon.png)"); - expect(latestInput.selectionStart).toBe(5); - expect(mounted.getMarkdown()).toBe("![Alpt](./assets/icon.png)\n"); + await waitFor(() => { + expect(mounted.view.dom).toHaveTextContent("Image unavailable."); }); - const mountDescribedImage = async () => { - mockTauriApiCommand("resolveMarkdownImageTarget", () => ({ - kind: "renderable", - path: "C:\\Notes\\assets\\icon.png", - })); - - const mounted = await mountImageEditor("![Alt with *emphasis*](./assets/icon.png)"); - - await waitFor(() => { - expect( - within(mounted.view.dom).getByRole("img", { name: "Alt with emphasis" }), - ).toBeInTheDocument(); - }); - - dispatchMouseDown(within(mounted.view.dom).getByRole("img", { name: "Alt with emphasis" })); - - return mounted; - }; - - // The input holds the source the file was written with rather than the text the description - // spells, so what it writes back is what the author is editing. - it("keeps the description the file holds while the rest of the image is edited", async () => { - const mounted = await mountDescribedImage(); - const input = within(mounted.view.dom).getByRole("textbox", { name: "Image Markdown" }); - - expect(input).toHaveValue("![Alt with *emphasis*](./assets/icon.png)"); - dispatchInput(input, "![Alt with *emphasis*](./assets/updated.png)"); - - await waitFor(() => { - expect(mounted.getMarkdown()).toBe("![Alt with *emphasis*](./assets/updated.png)\n"); - }); - expect( - within(mounted.view.dom).getByRole("img", { name: "Alt with emphasis" }), - ).toBeInTheDocument(); - }); + const placeholder = mounted.view.dom.querySelector(".leafdown-image-placeholder"); - // Reading the markers a typed description spells back as inline content is the parse this - // input does not run, so an edited description is the text it holds and the file escapes it. - it("writes an edited description as the text it spells", async () => { - const mounted = await mountDescribedImage(); - const input = within(mounted.view.dom).getByRole("textbox", { name: "Image Markdown" }); + if (!placeholder) { + throw new Error("Expected image placeholder to be rendered."); + } - dispatchInput(input, "![Alt with *markers*](./assets/icon.png)"); + dispatchMouseDown(placeholder); + setSelectionAtDocumentEnd(mounted.view); - await waitFor(() => { - expect(mounted.getMarkdown()).toBe("![Alt with \\*markers\\*](./assets/icon.png)\n"); - }); - expect( - within(mounted.view.dom).getByRole("img", { name: "Alt with *markers*" }), - ).toBeInTheDocument(); - }); + expect(mounted.view.dom).toHaveTextContent("Image unavailable."); + expect(countTauriApiCalls("resolveMarkdownImageTarget")).toBe(1); + consoleError.mockRestore(); }); - describe("reference images", () => { - const DEFINITION = '[leaf]: ./assets/icon.png "Leaf"'; - - const mountReferenceImage = async (reference: string) => { + it.each([ + { + expectedTitle: "Leaf", + source: "start ![inline](<../assets/leaf.svg> 'Leaf') end", + }, + { + expectedTitle: "", + source: "start ![inline](<../assets/leaf.svg>) end", + }, + ])( + "keeps authored image attributes when rendered DOM is parsed back into the document", + async ({ expectedTitle, source }) => { mockTauriApiCommand("resolveMarkdownImageTarget", () => ({ kind: "renderable", - path: "C:\\Notes\\assets\\icon.png", + path: "C:/Notes/assets/leaf.svg", })); - const mounted = await mountImageEditor(`${DEFINITION}\n\n${reference}\n`); + const mounted = await mountImageEditor(source); await waitFor(() => { - expect(within(mounted.view.dom).getByRole("img")).toBeInTheDocument(); + expect(within(mounted.view.dom).getByRole("img", { name: "inline" })).toBeInTheDocument(); }); - dispatchMouseDown(within(mounted.view.dom).getByRole("img")); + const rendered = within(mounted.view.dom).getByRole("img", { name: "inline" }); + const container = document.createElement("div"); - return mounted; - }; + container.append(rendered.cloneNode(true)); - // The input reads the form the file holds, so editing an image no longer trades its - // definition for a copy of the destination. - it.each(["![Reference leaf][leaf]", "![leaf][]", "![leaf]"])( - "shows %s as the source it was authored with", - async (reference) => { - const mounted = await mountReferenceImage(reference); + const parsed = ProseMirrorDOMParser.fromSchema(mounted.view.state.schema).parseSlice( + container, + ); + const image = parsed.content.firstChild; - expect( - within(mounted.view.dom).getByRole("textbox", { name: "Image Markdown" }), - ).toHaveValue(reference); - }, - ); + expect(image?.type.name).toBe("image"); + expect(image?.attrs.src).toBe("../assets/leaf.svg"); + expect(image?.attrs.title ?? "").toBe(expectedTitle); + expect(image?.attrs[IMAGE_DESTINATION_MARKER_ATTRIBUTE_NAME]).toBe("<"); + expect(image?.attrs[TITLE_MARKER_ATTRIBUTE_NAME]).toBe(expectedTitle ? "'" : '"'); - it("keeps the reference when the alt text is edited", async () => { - const mounted = await mountReferenceImage("![Reference leaf][leaf]"); + const imagePosition = getEditorNodePosition(mounted, "image"); - dispatchInput( - within(mounted.view.dom).getByRole("textbox", { name: "Image Markdown" }), - "![Renamed leaf][leaf]", + mounted.view.dispatch( + mounted.view.state.tr.replace(imagePosition, imagePosition + 1, parsed), ); - expect(mounted.getMarkdown()).toBe(`${DEFINITION}\n\n![Renamed leaf][leaf]\n`); - }); + expect(mounted.getMarkdown()).toBe(`${source}\n`); + }, + ); - it("turns a reference into the inline image its source spells", async () => { - const mounted = await mountReferenceImage("![Reference leaf][leaf]"); + it("ignores malformed rendered-image provenance when parsing DOM", async () => { + const mounted = await mountImageEditor("text"); + const container = document.createElement("div"); - dispatchInput( - within(mounted.view.dom).getByRole("textbox", { name: "Image Markdown" }), - "![Reference leaf](./assets/other.png)", - ); + container.innerHTML = + 'Safe'; - expect(mounted.getMarkdown()).toBe( - `${DEFINITION}\n\n![Reference leaf](./assets/other.png)\n`, - ); - }); + const parsed = ProseMirrorDOMParser.fromSchema(mounted.view.state.schema).parseSlice(container); + const image = parsed.content.firstChild; + + expect(image?.type.name).toBe("image"); + expect(image?.attrs.src).toBe("./safe.png"); + expect(image?.attrs.alt).toBe("Safe"); }); }); diff --git a/src/features/editor/plugins/imageView.ts b/src/features/editor/plugins/imageView.ts index 18f35a8f..ee09934a 100644 --- a/src/features/editor/plugins/imageView.ts +++ b/src/features/editor/plugins/imageView.ts @@ -1,6 +1,6 @@ import { imageSchema } from "@milkdown/kit/preset/commonmark"; import type { Node as ProseMirrorNode } from "@milkdown/kit/prose/model"; -import { NodeSelection } from "@milkdown/kit/prose/state"; +import { NodeSelection, TextSelection } from "@milkdown/kit/prose/state"; import type { EditorView, NodeView } from "@milkdown/kit/prose/view"; import { $view } from "@milkdown/kit/utils"; @@ -9,19 +9,7 @@ import { getErrorDescription, handleUnexpectedError } from "@/lib/errors"; import { MutableDisposable } from "@/lib/lifecycle"; import { isSameNullablePath } from "@/lib/path"; -import { - AUTHORED_DESCRIPTION_ATTRIBUTE_NAME, - AUTHORED_TITLE_ATTRIBUTE_NAME, - readAuthoredDescription, - readAuthoredTitle, -} from "../utils/characterReferenceMarkdown"; -import { - parseImageMarkdown, - readImageDescription, - serializeImageMarkdown, - type ImageDefinitionResolver, - type ImageMarkdownAttrs, -} from "../utils/imageMarkdown"; +import { writeImageNodeAttrsToDom } from "../utils/characterReferenceMarkdown"; import { resolveMarkdownImage, type MarkdownImageResolution, @@ -31,16 +19,7 @@ import { EMPTY_MARKDOWN_REFERENCE_CONTEXT, type MarkdownReferenceContext, } from "../utils/markdownReferences"; -import { readTitleMarker, TITLE_MARKER_ATTRIBUTE_NAME } from "../utils/markdownTitle"; -import { - DEFINITION_NODE_NAME, - normalizeReferenceLabel, - readDefinitionAttrs, - readReferenceType, - REFERENCE_LABEL_ATTRIBUTE_NAME, - REFERENCE_TYPE_ATTRIBUTE_NAME, - type DefinitionAttrs, -} from "../utils/referenceLinkMarkdown"; +import { SOURCE_PROJECTION_IMAGE_POINTER_ENTRY_META } from "./sourceProjection"; type ImageResolutionState = | { status: "pending" } @@ -49,10 +28,10 @@ type ImageResolutionState = type ImageResolutionInput = ResolveMarkdownImageOptions & { allowOutsideFolder: boolean }; -interface RawMarkdownFocusState { - selectionDirection: "backward" | "forward" | "none" | null; - selectionEnd: number | null; - selectionStart: number | null; +interface ImageAttrs { + alt: string; + src: string; + title: string; } export const createLeafdownImageViewPlugin = ( @@ -74,8 +53,6 @@ class LeafdownImageNodeView implements NodeView { private readonly currentResolutionCancellation = new MutableDisposable(); private currentResolutionInput: ImageResolutionInput | null = null; private resolutionState: ImageResolutionState = { status: "pending" }; - private rawMarkdownDraft: string | null = null; - private rawMarkdownInputElement: HTMLInputElement | null = null; constructor( initialNode: ProseMirrorNode, @@ -109,7 +86,7 @@ class LeafdownImageNodeView implements NodeView { } stopEvent(event: Event) { - return event.target instanceof HTMLInputElement || event.target instanceof HTMLButtonElement; + return event.target instanceof HTMLButtonElement; } ignoreMutation() { @@ -118,13 +95,12 @@ class LeafdownImageNodeView implements NodeView { selectNode() { this.isSelected = true; - this.render(); + this.updateSelectionPresentation(); } deselectNode() { this.isSelected = false; - this.rawMarkdownDraft = null; - this.render(); + this.updateSelectionPresentation(); } destroy() { @@ -134,7 +110,7 @@ class LeafdownImageNodeView implements NodeView { } private readonly handleMouseDown = (event: MouseEvent) => { - if (event.target instanceof HTMLButtonElement || event.target instanceof HTMLInputElement) { + if (event.target instanceof HTMLButtonElement) { return; } @@ -153,41 +129,19 @@ class LeafdownImageNodeView implements NodeView { return; } + const isLinked = this.node.marks.some((mark) => mark.type.name === "link"); + const selection = isLinked + ? TextSelection.create(this.view.state.doc, position + this.node.nodeSize) + : NodeSelection.create(this.view.state.doc, position); + this.view.dispatch( - this.view.state.tr.setSelection(NodeSelection.create(this.view.state.doc, position)), + this.view.state.tr + .setSelection(selection) + .setMeta(SOURCE_PROJECTION_IMAGE_POINTER_ENTRY_META, true), ); this.view.focus(); } - private readonly updateImageAttrs = (attrs: Partial) => { - const position = this.getPos(); - - if (typeof position !== "number" || !this.view.editable) { - return; - } - - const currentAttrs = this.getImageAttrs(); - const editedAttrs = { ...currentAttrs, ...attrs }; - // The input holds the description the file was written with, so an edit to the destination or - // the title leaves that description standing. A description the author did change is the text - // they typed, because reading its markers back as inline content is the parse this input does - // not run, and the file escapes them for it. - const keepsDescription = editedAttrs.description === currentAttrs.description; - const nextAttrs = toNodeAttrs( - keepsDescription ? { ...editedAttrs, alt: currentAttrs.alt } : editedAttrs, - keepsDescription ? readAuthoredDescription(this.node.attrs) : null, - readAuthoredTitle(this.node.attrs), - ); - - if (attrs.src !== undefined && attrs.src !== currentAttrs.src) { - this.allowOutsideFolder = false; - } - - const tr = this.view.state.tr.setNodeMarkup(position, undefined, nextAttrs); - - this.view.dispatch(tr.setSelection(NodeSelection.create(tr.doc, position)).scrollIntoView()); - }; - private createResolutionInput(): ImageResolutionInput { const attrs = this.getImageAttrs(); const context = this.getMarkdownReferenceContext(); @@ -255,44 +209,40 @@ class LeafdownImageNodeView implements NodeView { private render() { const attrs = this.getImageAttrs(); - const rawMarkdownFocusState = getRawMarkdownFocusState(this.rawMarkdownInputElement); this.dom.dataset.imageState = getImageStateValue(this.resolutionState); - this.dom.classList.toggle("leafdown-image-view--selected", this.isSelected); - this.dom.replaceChildren(); - this.rawMarkdownInputElement = null; - - if (this.isSelected) { - this.rawMarkdownInputElement = createRawMarkdownInput( - attrs, - this.updateImageAttrs, - createImageDefinitionResolver(this.view), - this.rawMarkdownDraft, - (value) => { - this.rawMarkdownDraft = value; - }, - ); - this.dom.append(this.rawMarkdownInputElement); - restoreRawMarkdownFocus(this.rawMarkdownInputElement, rawMarkdownFocusState); - } else { - this.rawMarkdownDraft = null; - } + this.updateSelectionPresentation(); if ( this.resolutionState.status === "resolved" && this.resolutionState.resolution.kind === "renderable" ) { - this.dom.append(createImageElement(attrs, this.resolutionState.resolution.assetUrl)); + const existingImage = this.dom.querySelector(".leafdown-markdown-image"); + const image = + existingImage?.src === this.resolutionState.resolution.assetUrl + ? existingImage + : createImageElement(attrs, this.node.attrs, this.resolutionState.resolution.assetUrl); + + updateImageElement(image, attrs, this.node.attrs); + + if (image !== existingImage) { + this.dom.replaceChildren(image); + } + return; } - this.dom.append( - createImagePlaceholder(this.resolutionState, attrs.src, () => { + this.dom.replaceChildren( + createImagePlaceholder(this.resolutionState, () => { this.allowOutsideFolder = true; this.requestImageResolution(); }), ); } + + private updateSelectionPresentation() { + this.dom.classList.toggle("leafdown-image-view--selected", this.isSelected); + } } const readNodeString = (node: ProseMirrorNode, key: string) => { @@ -301,45 +251,12 @@ const readNodeString = (node: ProseMirrorNode, key: string) => { return typeof value === "string" ? value : ""; }; -const imageAttrsFromNode = (node: ProseMirrorNode): ImageMarkdownAttrs => ({ +const imageAttrsFromNode = (node: ProseMirrorNode): ImageAttrs => ({ alt: readNodeString(node, "alt"), - description: readImageDescription( - readAuthoredDescription(node.attrs), - readNodeString(node, "alt"), - ), - referenceLabel: readNodeString(node, REFERENCE_LABEL_ATTRIBUTE_NAME), - referenceType: readReferenceType(node.attrs), src: readNodeString(node, "src"), title: readNodeString(node, "title"), - titleMarker: readTitleMarker(node.attrs), }); -// The input reads and writes the form the file holds, so a reference resolves against the same -// definitions the document was built from rather than against the destination it happens to carry. -const createImageDefinitionResolver = - (view: EditorView): ImageDefinitionResolver => - (label) => { - const definitions: DefinitionAttrs[] = []; - - view.state.doc.descendants((node) => { - if (node.type.name !== DEFINITION_NODE_NAME) { - return node.isBlock; - } - - definitions.push(readDefinitionAttrs(node.attrs)); - - return false; - }); - - const definition = definitions.find( - (candidate) => normalizeReferenceLabel(candidate.label) === label, - ); - - return definition - ? { src: definition.url, title: definition.title, titleMarker: definition.titleMarker } - : null; - }; - const isSameImageResolutionInput = ( currentInput: ImageResolutionInput, nextInput: ImageResolutionInput, @@ -349,98 +266,39 @@ const isSameImageResolutionInput = ( isSameNullablePath(currentInput.folderContextPath, nextInput.folderContextPath) && currentInput.target === nextInput.target; -const toNodeAttrs = ( - { alt, referenceLabel, referenceType, src, title, titleMarker }: ImageMarkdownAttrs, - authoredDescription: string | null, - authoredTitle: string | null, -) => ({ - alt, - src, - title, - [AUTHORED_DESCRIPTION_ATTRIBUTE_NAME]: authoredDescription, - // The serializer writes the authored title only while it still decodes to the title, so an edit - // to the title in the input leaves it describing text the node no longer holds. - [AUTHORED_TITLE_ATTRIBUTE_NAME]: authoredTitle, - [TITLE_MARKER_ATTRIBUTE_NAME]: titleMarker, - [REFERENCE_LABEL_ATTRIBUTE_NAME]: referenceLabel, - [REFERENCE_TYPE_ATTRIBUTE_NAME]: referenceType, -}); - -const createImageElement = (attrs: ImageMarkdownAttrs, assetUrl: string) => { +const createImageElement = ( + attrs: ImageAttrs, + nodeAttrs: Record, + assetUrl: string, +) => { const image = document.createElement("img"); image.className = "leafdown-markdown-image"; image.src = assetUrl; - image.alt = attrs.alt; - if (attrs.title) { - image.title = attrs.title; - } + updateImageElement(image, attrs, nodeAttrs); return image; }; -const createRawMarkdownInput = ( - attrs: ImageMarkdownAttrs, - updateAttrs: (attrs: Partial) => void, - resolveDefinition: ImageDefinitionResolver, - draft: string | null, - updateDraft: (value: string) => void, +const updateImageElement = ( + image: HTMLImageElement, + attrs: ImageAttrs, + nodeAttrs: Record, ) => { - const input = document.createElement("input"); - - input.className = "leafdown-image-markdown-input"; - input.type = "text"; - input.setAttribute("aria-label", "Image Markdown"); - input.value = draft ?? serializeImageMarkdown(attrs); - - input.addEventListener("input", () => { - updateDraft(input.value); - - const parsed = parseImageMarkdown(input.value, resolveDefinition); - - if (parsed) { - updateAttrs(parsed); - } - }); - - return input; -}; - -const getRawMarkdownFocusState = (input: HTMLInputElement | null): RawMarkdownFocusState | null => { - if (!input || document.activeElement !== input) { - return null; - } - - return { - selectionDirection: input.selectionDirection, - selectionEnd: input.selectionEnd, - selectionStart: input.selectionStart, - }; -}; + image.alt = attrs.alt; -const restoreRawMarkdownFocus = ( - input: HTMLInputElement, - focusState: RawMarkdownFocusState | null, -) => { - if (!focusState) { - return; + if (attrs.title) { + image.title = attrs.title; + } else { + image.removeAttribute("title"); } - input.focus(); - - if (focusState.selectionStart !== null && focusState.selectionEnd !== null) { - input.setSelectionRange( - focusState.selectionStart, - focusState.selectionEnd, - focusState.selectionDirection ?? "none", - ); - } + writeImageNodeAttrsToDom(image, nodeAttrs); }; const createImagePlaceholder = ( resolutionState: ImageResolutionState, - target: string, allowOutsideFolderAccess: () => void, ) => { const placeholder = document.createElement("span"); @@ -453,7 +311,7 @@ const createImagePlaceholder = ( placeholder.className = "leafdown-image-placeholder"; placeholder.dataset.imageResolution = resolutionKind; message.className = "leafdown-image-placeholder__message"; - message.textContent = getPlaceholderText(resolutionState, target); + message.textContent = getPlaceholderText(resolutionState); placeholder.append(message); if ( @@ -475,24 +333,24 @@ const createImagePlaceholder = ( const getImageStateValue = (resolutionState: ImageResolutionState) => resolutionState.status === "resolved" ? resolutionState.resolution.kind : resolutionState.status; -const getPlaceholderText = (resolutionState: ImageResolutionState, target: string) => { +const getPlaceholderText = (resolutionState: ImageResolutionState) => { if (resolutionState.status === "pending") { return "Resolving image..."; } if (resolutionState.status === "failed") { - return resolutionState.message; + return "Image unavailable."; } switch (resolutionState.resolution.kind) { case "missing": - return `Image not found: ${target}`; + return "Image not found."; case "untitledRelative": return "Save the document to resolve this image."; case "outsideFolder": - return `Image is outside the current folder: ${resolutionState.resolution.path}`; + return "Image outside the current folder."; case "remoteBlocked": return "Remote images are blocked."; @@ -504,19 +362,13 @@ const getPlaceholderText = (resolutionState: ImageResolutionState, target: strin return "Unsupported image target."; case "invalidPath": - return `Invalid image path: ${resolutionState.resolution.path}`; + return "Invalid image path."; case "permissionDenied": - return ( - resolutionState.resolution.message || - `Image access denied: ${resolutionState.resolution.path}` - ); + return "Image access denied."; case "metadataFailed": - return ( - resolutionState.resolution.message || - `Image metadata unavailable: ${resolutionState.resolution.path}` - ); + return "Image metadata unavailable."; case "renderable": return ""; diff --git a/src/features/editor/plugins/sourceProjection.test.ts b/src/features/editor/plugins/sourceProjection.test.ts index 33d96593..c0cd256d 100644 --- a/src/features/editor/plugins/sourceProjection.test.ts +++ b/src/features/editor/plugins/sourceProjection.test.ts @@ -785,6 +785,10 @@ describe("source projection", () => { description: "a link the mark wraps", source: "**left[^note][link](https://example.com)right**", }, + { + description: "an image the mark wraps", + source: "**left[^note]![alt](image.png)right**", + }, { description: "a hard break the mark wraps", source: "**left[^note] \nright**" }, ])("keeps $description inside one marked fragment", async ({ source }) => { const mounted = await mountProjectionEditor(`${source}\n\n[^note]: Detail`); @@ -799,10 +803,6 @@ describe("source projection", () => { boundary: "inline code", markdown: "**left[^note]`code`right**", }, - { - boundary: "image", - markdown: "**left[^note]![alt](image.png)right**", - }, { boundary: "inline HTML", markdown: "**left[^note]rawright**", @@ -1062,7 +1062,7 @@ describe("source projection", () => { it("keeps a valid outer wrapper when marked reference content becomes unsupported", async () => { const source = "**Text[^note]**"; - const imageSource = "![Text[^note]](./pic.png)"; + const unsupportedSource = "Text[^note]"; const mounted = await mountProjectionEditor(`${source}\n\n[^note]: Detail`); selectFootnoteReference(mounted); @@ -1070,11 +1070,11 @@ describe("source projection", () => { const sourceStart = getEditorTextPosition(mounted, source); setTextSelection(mounted.view, sourceStart + 2, sourceStart + source.length - 2); - typeText(mounted.view, imageSource); + typeText(mounted.view, unsupportedSource); setSelectionAtDocumentEnd(mounted.view); const strongMark = mounted.view.state.schema.marks.strong; - const literalNode = findEditorTextNode(mounted, imageSource); + const literalNode = findEditorTextNode(mounted, unsupportedSource); expect(literalNode).not.toBeNull(); expect(strongMark.isInSet(literalNode!.marks)).toBeDefined(); diff --git a/src/features/editor/plugins/sourceProjection.ts b/src/features/editor/plugins/sourceProjection.ts index 2cd6afb5..333e5406 100644 --- a/src/features/editor/plugins/sourceProjection.ts +++ b/src/features/editor/plugins/sourceProjection.ts @@ -28,6 +28,7 @@ import { findSourceProjectionTarget, type LiteralSourceCommit, type SourceProjectionAdapter, + type SourceProjectionEntryContext, type SourceProjectionEdit, type SourceProjectionPresentationPreview, type SourceProjectionTarget, @@ -40,6 +41,7 @@ import { import { createCharacterReferenceSourceProjectionAdapter } from "../utils/sourceProjectionCharacterReferenceAdapter"; import { createEscapeSourceProjectionAdapter } from "../utils/sourceProjectionEscapeAdapter"; import { createFootnoteReferenceSourceProjectionAdapter } from "../utils/sourceProjectionFootnoteReferenceAdapter"; +import { createImageSourceProjectionAdapter } from "../utils/sourceProjectionImageAdapter"; import { createLinkSourceProjectionAdapter } from "../utils/sourceProjectionLinkAdapter"; import { getRangeText, getTextBetween, type TextRange } from "../utils/textRanges"; @@ -56,6 +58,8 @@ export const leafdownSourceProjectionPluginKey = new PluginKey { let compositionSource: string | null = null; + let keyboardEntryDirection: SourceProjectionEntryContext["direction"] = null; return new Plugin({ key: leafdownSourceProjectionPluginKey, - appendTransaction: (transactions, oldState, newState) => - appendProjectionTransaction(transactions, oldState, newState, adapters)?.setMeta( - SOURCE_PROJECTION_APPENDED_META, - true, - ) ?? null, + appendTransaction: (transactions, oldState, newState) => { + const transaction = appendProjectionTransaction( + transactions, + oldState, + newState, + adapters, + keyboardEntryDirection, + ); + + return transaction?.setMeta(SOURCE_PROJECTION_APPENDED_META, true) ?? null; + }, // A change captured in native history while the document holds projected source replays // against coordinates the commit discards. `filterTransaction` is the only hook that runs // before the history plugin reads the meta. @@ -175,12 +186,28 @@ export const createSourceProjectionProsePlugin = (adapters: readonly SourceProje return false; }, + keyup: (_view, event) => { + if (getKeyboardEntryDirection(event.key)) { + keyboardEntryDirection = null; + } + + return false; + }, mouseout: (view, event) => handleProjectionLinkLabelMouseOut(view, event), mouseover: (view, event) => handleProjectionLinkLabelMouseOver(view, event), paste: (view, event) => handleProjectionPaste(view, event), }, handleDrop: (view, event, slice, moved) => handleProjectionDrop(view, event, slice, moved), - handleKeyDown: (view, event) => handleProjectionKeyDown(view, event), + handleKeyDown: (view, event) => { + keyboardEntryDirection = getKeyboardEntryDirection(event.key); + const handled = handleProjectionKeyDown(view, event); + + if (handled) { + keyboardEntryDirection = null; + } + + return handled; + }, handlePaste: (view, event, slice) => handleProjectionPaste(view, event, slice), handleTextInput: (view, from, to, text) => handleProjectionTextInput(view, from, to, text, adapters), @@ -212,6 +239,10 @@ export const createLeafdownSourceProjectionPlugin = () => remark, serializer, }), + createImageSourceProjectionAdapter({ + parser, + serializer, + }), createFootnoteReferenceSourceProjectionAdapter({ parser, serializer, @@ -249,12 +280,21 @@ export const createLeafdownSourceProjectionContinuationPlugin = () => $prose( () => new Plugin({ - appendTransaction: (transactions, _oldState, state) => - transactions.some( - (transaction) => transaction.getMeta(SOURCE_PROJECTION_APPENDED_META) === true, - ) - ? state.tr - : null, + appendTransaction: (transactions, _oldState, state) => { + if ( + !transactions.some( + (transaction) => transaction.getMeta(SOURCE_PROJECTION_APPENDED_META) === true, + ) + ) { + return null; + } + + const transaction = state.tr; + + return hasImagePointerEntryIntent(transactions) + ? transaction.setMeta(SOURCE_PROJECTION_IMAGE_POINTER_ENTRY_META, true) + : transaction; + }, }), ); @@ -487,28 +527,37 @@ const appendProjectionTransaction = ( oldState: EditorState, state: EditorState, adapters: readonly SourceProjectionAdapter[], + keyboardEntryDirection: SourceProjectionEntryContext["direction"], ) => { const projectionState = getSourceProjectionState(state); + const pointer = hasImagePointerEntryIntent(transactions); if (projectionState.pendingCommit) { - return hasDeferredProjectionCommit(transactions) + const transaction = hasDeferredProjectionCommit(transactions) ? null : createCommitAfterRestoreTransaction(state, projectionState.pendingCommit); + + return preserveImagePointerEntryIntent(transaction, pointer); } if (projectionState.session) { if (!isRangeInside(state.selection, projectionState.session)) { - return createFinalizeProjectionTransaction(state, projectionState.session); + return preserveImagePointerEntryIntent( + createFinalizeProjectionTransaction(state, projectionState.session), + pointer, + ); } // A session the caret has moved off gives way where it stands, rather than on the caret // leaving its range, so what the caret moved onto opens in its place. - return shouldKeepProjection(state, projectionState.session, transactions, adapters) + const transaction = shouldKeepProjection(state, projectionState.session, transactions, adapters) ? null : createFinalizeProjectionTransaction(state, projectionState.session, true); + + return preserveImagePointerEntryIntent(transaction, pointer); } - if (areSelectionsEqual(projectionState.suppressedSelection, state.selection)) { + if (!pointer && areSelectionsEqual(projectionState.suppressedSelection, state.selection)) { return null; } @@ -543,14 +592,26 @@ const appendProjectionTransaction = ( } const match = findSourceProjectionTarget(state, adapters); - if (!match || !isProjectableTarget(match, projectionState)) { return null; } - return createEnterProjectionTransaction(state, match); + return createEnterProjectionTransaction(state, match, { + direction: keyboardEntryDirection, + pointer, + }); }; +const hasImagePointerEntryIntent = (transactions: readonly Transaction[]) => + transactions.some( + (transaction) => transaction.getMeta(SOURCE_PROJECTION_IMAGE_POINTER_ENTRY_META) === true, + ); + +const preserveImagePointerEntryIntent = (transaction: Transaction | null, pointer: boolean) => + transaction && pointer + ? transaction.setMeta(SOURCE_PROJECTION_IMAGE_POINTER_ENTRY_META, true) + : transaction; + // An escaped run spells out source the file already holds, so the engine neither treats entering // it as authoring nor offers it over a run this session wrote. A boundary holding one carries the // same rules, because the same characters reach the document either way. @@ -602,7 +663,12 @@ const givesWayToDiscovery = ( return false; } - const restored = state.tr.replace(session.from, session.to, session.target.originalContent).doc; + const restoreRange = session.adapter.getRestoreRange?.(session) ?? session; + const restored = state.tr.replace( + restoreRange.from, + restoreRange.to, + session.target.originalContent, + ).doc; const to = session.from + session.target.originalContentSize; const probe = createSourceProjectionProbeState( restored, @@ -1260,6 +1326,23 @@ const handleProjectionKeyDown = (view: EditorView, event: KeyboardEvent) => { return false; } + const restoreRange = session.adapter.getRestoreRange?.(session) ?? session; + const retainsOriginalContent = restoreRange.to > session.to; + const { selection } = view.state; + + if ( + retainsOriginalContent && + selection instanceof TextSelection && + selection.empty && + ((event.key === "ArrowLeft" && selection.from === session.from) || + (event.key === "ArrowRight" && selection.from === session.to)) + ) { + event.preventDefault(); + finalizeSourceProjection(view); + + return true; + } + if (event.key === "Enter") { finalizeSourceProjection(view); @@ -1282,6 +1365,14 @@ const handleProjectionKeyDown = (view: EditorView, event: KeyboardEvent) => { return true; }; +const getKeyboardEntryDirection = (key: string): SourceProjectionEntryContext["direction"] => { + if (key === "ArrowLeft" || key === "ArrowUp") { + return "backward"; + } + + return key === "ArrowRight" || key === "ArrowDown" ? "forward" : null; +}; + const dispatchProjectionEdit = (view: EditorView, from: number, to: number, text: string) => { const session = getSourceProjectionState(view.state).session; @@ -1504,8 +1595,9 @@ const getNextCharacterRange = ( const createEnterProjectionTransaction = ( state: EditorState, { adapter, target }: SourceProjectionTargetMatch, + context: SourceProjectionEntryContext, ) => { - const selection = adapter.mapSelectionToSource(state.selection, target); + const selection = adapter.mapSelectionToSource(state.selection, target, context); const session = createProjectionSession(adapter, target); const transaction = adapter.createEnterTransaction(state, target); @@ -1530,12 +1622,15 @@ const createFinalizeProjectionTransaction = ( const source = getProjectionSource(state, session); const { adapter } = session; const parsed = adapter.parseSource(state, source, session.target); + const restoreRange = adapter.getRestoreRange?.(session) ?? session; const original = { ...adapter.parseSource(state, session.target.originalSource, session.target), replacement: session.target.originalContent, replacementSize: session.target.originalContentSize, }; - const shouldSuppressProjectionAtSelection = isRangeInside(state.selection, session); + const shouldSuppressProjectionAtSelection = + isRangeInside(state.selection, session) || + (state.selection instanceof TextSelection && isRangeInside(state.selection, restoreRange)); const shouldMapCrossingTextSelection = state.selection instanceof TextSelection && state.selection.from < session.to && @@ -1607,10 +1702,11 @@ const createRestoreBeforeCommitTransaction = ({ suppressedSelection: isInPlace ? null : commitSelection, to: session.from + session.target.originalContentSize, }; + const restoreRange = session.adapter.getRestoreRange?.(session) ?? session; const transaction = replaceProjectionRange( state.tr, - session.from, - session.to, + restoreRange.from, + restoreRange.to, session.target.originalContent, ); diff --git a/src/features/editor/tests/htmlSafety.test.tsx b/src/features/editor/tests/htmlSafety.test.tsx index ef5affd9..29026f2a 100644 --- a/src/features/editor/tests/htmlSafety.test.tsx +++ b/src/features/editor/tests/htmlSafety.test.tsx @@ -24,6 +24,7 @@ import { } from "@/test/utils/tauriApi"; import { runEditorCommand } from "../commands"; +import { finalizeSourceProjection } from "../plugins/sourceProjection"; const executionFlag = "__leafdownHtmlExecuted"; const { clipboard, createClipboardItem } = setupClipboardMock(); @@ -266,6 +267,8 @@ describe("pasted HTML safety", () => { '

Tracker

', ); + expect(finalizeSourceProjection(mounted.view)).toBe(true); + await waitFor(() => { expect(mounted.view.dom).toHaveTextContent("Remote images are blocked."); }); diff --git a/src/features/editor/tests/markdownCompatibility.test.ts b/src/features/editor/tests/markdownCompatibility.test.ts index 3cc2daae..0a0c54fd 100644 --- a/src/features/editor/tests/markdownCompatibility.test.ts +++ b/src/features/editor/tests/markdownCompatibility.test.ts @@ -1561,6 +1561,10 @@ describe("Raw link destination parentheses", () => { }, { saved: '[Titled](garden(one).md "Garden")', source: '[Titled](garden(one).md "Garden")' }, { saved: "![Image](garden(one).png)", source: "![Image](garden(one).png)" }, + { + saved: '![a > b]( "title > x")', + source: '![a > b]( "title > x")', + }, ])("writes $saved without an escape it does not need", async ({ saved, source }) => { const mounted = await mountEditor(`${source}\n`); @@ -1579,9 +1583,9 @@ describe("Raw link destination parentheses", () => { source: "[Unbalanced close]()", }, // The image serializes inside the link handler, which has already relaxed the escapes its own - // balanced destination does not need. + // balanced destination does not need. Its authored angle destination is preserved. { - saved: "[![alt](img\\(1.png)](target(2).md)", + saved: "[![alt]()](target(2).md)", source: "[![alt]()](target(2).md)", }, ])("keeps the escape $saved needs", async ({ saved, source }) => { diff --git a/src/features/editor/tests/sourceProjectionImage.test.ts b/src/features/editor/tests/sourceProjectionImage.test.ts new file mode 100644 index 00000000..ccc96472 --- /dev/null +++ b/src/features/editor/tests/sourceProjectionImage.test.ts @@ -0,0 +1,338 @@ +// @vitest-environment happy-dom + +import { NodeSelection } from "@milkdown/kit/prose/state"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { + EDITOR_TEST_ROOT_CLASS_NAME, + createMarkdownReferenceContext, +} from "@/test/factories/editor"; +import { dispatchMouseDown } from "@/test/utils/events"; +import { setupMilkdownEditorMount } from "@/test/utils/milkdown"; +import { + containsNodeType, + getEditorNodePosition, + getEditorTextContent, + getEditorTextPosition, + runKeyDownHandlers, + setSelectionAtDocumentEnd, + setTextSelection, + typeText, +} from "@/test/utils/prosemirror"; +import { waitFor, within } from "@/test/utils/react"; +import { mockTauriApiCommand } from "@/test/utils/tauriApi"; + +import { + hasActiveSourceProjection, + getSourceProjectionClipboardSlice, + redoSourceProjection, + SOURCE_PROJECTION_IMAGE_POINTER_ENTRY_META, + undoSourceProjection, +} from "../plugins/sourceProjection"; + +const mountProjectionEditor = setupMilkdownEditorMount({ + ...createMarkdownReferenceContext(), + rootClassName: EDITOR_TEST_ROOT_CLASS_NAME, +}); + +const selectImage = (mounted: Awaited>) => { + const position = getEditorNodePosition(mounted, "image"); + + mounted.view.dispatch( + mounted.view.state.tr + .setSelection(NodeSelection.create(mounted.view.state.doc, position)) + .setMeta(SOURCE_PROJECTION_IMAGE_POINTER_ENTRY_META, true), + ); +}; + +describe("standalone image source projection", () => { + beforeEach(() => { + mockTauriApiCommand("resolveMarkdownImageTarget", ({ target }) => ({ + kind: "renderable", + path: `C:/Notes/${target}`, + })); + }); + + it.each([ + { offset: 0, side: "left" }, + { offset: 1, side: "right" }, + ])("enters at the source's $side edge from that side of the image", async ({ offset }) => { + const source = "![alt](./pic.png)"; + const mounted = await mountProjectionEditor(`${source} tail`); + const imagePosition = getEditorNodePosition(mounted, "image"); + + setTextSelection(mounted.view, imagePosition + offset); + + expect(hasActiveSourceProjection(mounted.view.state)).toBe(true); + expect(getEditorTextContent(mounted)).toBe(`${source} tail`); + expect(mounted.view.state.selection.anchor).toBe( + imagePosition + (offset === 0 ? 0 : source.length), + ); + }); + + it.each([ + { entryPosition: "after" as const, expectedOffset: 0, key: "ArrowDown", side: "before" }, + { + entryPosition: "before" as const, + expectedOffset: "end" as const, + key: "ArrowUp", + side: "after", + }, + ])( + "uses keyboard direction when entering from the block $side the image", + async ({ entryPosition, expectedOffset, key }) => { + const source = "![alt](./pic.png)"; + const mounted = await mountProjectionEditor(`before\n\n${source}\n\nafter`); + const imagePosition = getEditorNodePosition(mounted, "image"); + const adjacentText = entryPosition === "after" ? "before" : "after"; + const adjacentPosition = getEditorTextPosition(mounted, adjacentText); + + setTextSelection( + mounted.view, + entryPosition === "after" ? adjacentPosition + adjacentText.length : adjacentPosition, + ); + runKeyDownHandlers(mounted.view, key); + setTextSelection(mounted.view, imagePosition + (entryPosition === "after" ? 1 : 0)); + + expect(hasActiveSourceProjection(mounted.view.state)).toBe(true); + expect(mounted.view.state.selection.head).toBe( + imagePosition + (expectedOffset === "end" ? source.length : expectedOffset), + ); + }, + ); + + it("places the caret at the start of the alt text when the rendered image was selected", async () => { + const source = "![alt](./pic.png)"; + const mounted = await mountProjectionEditor(`${source} tail`); + const imagePosition = getEditorNodePosition(mounted, "image"); + + selectImage(mounted); + + expect(hasActiveSourceProjection(mounted.view.state)).toBe(true); + expect(mounted.view.state.selection.empty).toBe(true); + expect(mounted.view.state.selection.head).toBe(imagePosition + 2); + }); + + it("enters source projection when the rendered image is clicked", async () => { + const source = "![alt](./pic.png)"; + const mounted = await mountProjectionEditor(source); + const imagePosition = getEditorNodePosition(mounted, "image"); + + await waitFor(() => { + expect(within(mounted.view.dom).getByRole("img", { name: "alt" })).toBeInTheDocument(); + }); + + const image = within(mounted.view.dom).getByRole("img", { name: "alt" }); + + dispatchMouseDown(image); + + expect(hasActiveSourceProjection(mounted.view.state)).toBe(true); + expect(getEditorTextContent(mounted)).toBe(source); + expect(mounted.view.state.selection.empty).toBe(true); + expect(mounted.view.state.selection.head).toBe(imagePosition + 2); + + setTextSelection(mounted.view, imagePosition + source.length); + dispatchMouseDown(image); + + expect(hasActiveSourceProjection(mounted.view.state)).toBe(true); + expect(mounted.view.state.selection.head).toBe(imagePosition + 2); + }); + + it.each([ + { entryOffset: 0, exitOffset: -1, side: "before" }, + { entryOffset: 1, exitOffset: 1, side: "after" }, + ])( + "returns to the editor surface when the caret moves $side the source", + async ({ entryOffset, exitOffset }) => { + const source = "![alt](./pic.png)"; + const mounted = await mountProjectionEditor(`lead ${source} tail`); + const imagePosition = getEditorNodePosition(mounted, "image"); + + setTextSelection(mounted.view, imagePosition + entryOffset); + + const projectedBoundary = imagePosition + (entryOffset === 0 ? 0 : source.length); + + setTextSelection(mounted.view, projectedBoundary + exitOffset); + + expect(hasActiveSourceProjection(mounted.view.state)).toBe(false); + expect(containsNodeType(mounted, "image")).toBe(true); + }, + ); + + it.each([ + { boundary: "start", expectedOffset: 0, key: "ArrowLeft" }, + { boundary: "end", expectedOffset: 1, key: "ArrowRight" }, + ])( + "leaves the retained image projection at its $boundary boundary", + async ({ boundary, expectedOffset, key }) => { + const source = "![alt](./pic.png)"; + const mounted = await mountProjectionEditor(`lead ${source} tail`); + const imagePosition = getEditorNodePosition(mounted, "image"); + + setTextSelection(mounted.view, imagePosition); + setTextSelection(mounted.view, imagePosition + (boundary === "start" ? 0 : source.length)); + + expect(runKeyDownHandlers(mounted.view, key).handled).toBe(true); + expect(hasActiveSourceProjection(mounted.view.state)).toBe(false); + expect(mounted.view.state.selection.head).toBe(imagePosition + expectedOffset); + }, + ); + + it("keeps the decoded image mounted after the source line while projection is active", async () => { + const source = "![alt](./pic.png)"; + const mounted = await mountProjectionEditor(`${source} tail`); + + await waitFor(() => { + expect(within(mounted.view.dom).getByRole("img", { name: "alt" })).toBeInTheDocument(); + }); + + const imageView = mounted.view.dom.querySelector(".leafdown-image-view"); + + if (!imageView) { + throw new Error("Expected the rendered image view."); + } + + const renderedImage = within(imageView).getByRole("img", { name: "alt" }); + + selectImage(mounted); + + const projection = mounted.view.dom.querySelector( + '.leafdown-source-projection[data-leafdown-source~="image"]', + ); + + expect(projection).not.toBeNull(); + expect( + projection!.compareDocumentPosition(imageView) & Node.DOCUMENT_POSITION_FOLLOWING, + ).not.toBe(0); + expect(within(imageView).getByRole("img", { name: "alt" })).toBe(renderedImage); + expect(within(mounted.view.dom).queryByRole("button", { name: /image/iu })).toBeNull(); + expect(getEditorTextContent(mounted)).toBe(`${source} tail`); + + setSelectionAtDocumentEnd(mounted.view); + + expect(within(mounted.view.dom).getByRole("img", { name: "alt" })).toBe(renderedImage); + }); + + it("keeps an unavailable image placeholder visible after the source line", async () => { + mockTauriApiCommand("resolveMarkdownImageTarget", () => ({ + kind: "missing", + path: "C:/Notes/missing.png", + })); + const mounted = await mountProjectionEditor("![missing](./missing.png)"); + + await waitFor(() => { + expect(mounted.view.dom).toHaveTextContent("Image not found."); + }); + + selectImage(mounted); + + const projection = mounted.view.dom.querySelector( + '.leafdown-source-projection[data-leafdown-source~="image"]', + ); + const imageView = mounted.view.dom.querySelector(".leafdown-image-view"); + + expect(projection).not.toBeNull(); + expect(imageView).not.toBeNull(); + expect( + projection!.compareDocumentPosition(imageView!) & Node.DOCUMENT_POSITION_FOLLOWING, + ).not.toBe(0); + expect(imageView).toHaveTextContent("Image not found."); + expect(imageView).not.toHaveTextContent("./missing.png"); + }); + + it.each([ + "![inline](<../assets/leaf.svg>)", + "![alt with *emphasis*](<../assets/leaf icon.svg> 'Leaf')", + "[leaf]: ../assets/leaf.svg 'Leaf'\n\n![Reference leaf][leaf]", + ])("restores %j exactly after a clean projection", async (source) => { + const mounted = await mountProjectionEditor(`${source}\n\ntail`); + const originalDocument = mounted.view.state.doc; + + selectImage(mounted); + setSelectionAtDocumentEnd(mounted.view); + + expect(hasActiveSourceProjection(mounted.view.state)).toBe(false); + expect(mounted.view.state.doc.eq(originalDocument)).toBe(true); + expect(mounted.getMarkdown()).toBe(`${source}\n\ntail\n`); + }); + + it("rehydrates a valid edit through the Milkdown parser", async () => { + const source = "![alt](./pic.png)"; + const mounted = await mountProjectionEditor(`${source} tail`); + const imagePosition = getEditorNodePosition(mounted, "image"); + + setTextSelection(mounted.view, imagePosition); + setTextSelection(mounted.view, getEditorTextPosition(mounted, source) + "![alt".length); + typeText(mounted.view, "er"); + setSelectionAtDocumentEnd(mounted.view); + + expect(mounted.getMarkdown()).toBe("![alter](./pic.png) tail\n"); + expect(containsNodeType(mounted, "image")).toBe(true); + }); + + it("commits an incomplete edit as the literal source it spells", async () => { + const source = "![alt](./pic.png)"; + const mounted = await mountProjectionEditor(source); + const imagePosition = getEditorNodePosition(mounted, "image"); + + setTextSelection(mounted.view, imagePosition); + setTextSelection(mounted.view, getEditorTextPosition(mounted, source) + source.length); + runKeyDownHandlers(mounted.view, "Backspace"); + setSelectionAtDocumentEnd(mounted.view); + + expect(containsNodeType(mounted, "image")).toBe(false); + expect(getEditorTextContent(mounted)).toBe(source.slice(0, -1)); + expect(mounted.getMarkdown()).toBe("![alt](./pic.png\n"); + }); + + it("uses projection-local Undo and Redo for source edits", async () => { + const source = "![alt](./pic.png)"; + const mounted = await mountProjectionEditor(source); + const imagePosition = getEditorNodePosition(mounted, "image"); + + setTextSelection(mounted.view, imagePosition); + setTextSelection(mounted.view, getEditorTextPosition(mounted, source) + "![alt".length); + typeText(mounted.view, "r"); + + expect(undoSourceProjection(mounted.view)).toBe(true); + expect(getEditorTextContent(mounted)).toBe(source); + expect(redoSourceProjection(mounted.view)).toBe(true); + expect(getEditorTextContent(mounted)).toBe("![altr](./pic.png)"); + }); + + it("copies the complete image semantically and keeps partial source literal", async () => { + const source = "![alt](./pic.png)"; + const mounted = await mountProjectionEditor(source); + + selectImage(mounted); + + const sourceFrom = getEditorTextPosition(mounted, source); + + setTextSelection(mounted.view, sourceFrom, sourceFrom + source.length); + + const complete = getSourceProjectionClipboardSlice(mounted.view.state); + + expect(complete).not.toBeNull(); + expect(containsNodeType(complete!.content, "image")).toBe(true); + + setTextSelection(mounted.view, sourceFrom + "![a".length, sourceFrom + "![alt](./pic".length); + + expect(getSourceProjectionClipboardSlice(mounted.view.state)).toBeNull(); + }); + + it("projects a marked image through its owning marked fragment", async () => { + const source = "**![alt](./pic.png)**"; + const mounted = await mountProjectionEditor(`${source} tail`); + + setTextSelection(mounted.view, getEditorNodePosition(mounted, "image")); + + expect(hasActiveSourceProjection(mounted.view.state)).toBe(true); + expect(getEditorTextContent(mounted)).toBe(`${source} tail`); + + setTextSelection(mounted.view, getEditorTextPosition(mounted, source) + "**![alt".length); + typeText(mounted.view, "er"); + setSelectionAtDocumentEnd(mounted.view); + + expect(mounted.getMarkdown()).toBe("**![alter](./pic.png)** tail\n"); + }); +}); diff --git a/src/features/editor/tests/sourceProjectionImageLink.test.ts b/src/features/editor/tests/sourceProjectionImageLink.test.ts index ec2518d2..7d854f06 100644 --- a/src/features/editor/tests/sourceProjectionImageLink.test.ts +++ b/src/features/editor/tests/sourceProjectionImageLink.test.ts @@ -6,6 +6,7 @@ import { EDITOR_TEST_ROOT_CLASS_NAME, createMarkdownReferenceContext, } from "@/test/factories/editor"; +import { dispatchMouseDown } from "@/test/utils/events"; import { setupMilkdownEditorMount, type MountedMilkdownEditor } from "@/test/utils/milkdown"; import { containsNodeType, @@ -20,6 +21,7 @@ import { setTextSelection, typeText, } from "@/test/utils/prosemirror"; +import { waitFor, within } from "@/test/utils/react"; import { mockTauriApiCommand } from "@/test/utils/tauriApi"; import { hasActiveSourceProjection } from "../plugins/sourceProjection"; @@ -58,7 +60,7 @@ describe("image link label source projection", () => { expect(hasActiveSourceProjection(mounted.view.state)).toBe(true); expect(getEditorTextContent(mounted)).toBe(`${IMAGE_LINK_SOURCE} tail`); - expect(containsNodeType(mounted, "image")).toBe(false); + expect(containsNodeType(mounted, "image")).toBe(true); expect(getLabelPresentation(mounted)).toBe("![alt](./pic.png)"); const sourceStart = getEditorTextPosition(mounted, IMAGE_LINK_SOURCE); @@ -67,6 +69,59 @@ describe("image link label source projection", () => { expect(mounted.view.state.selection.anchor).toBe(sourceStart + expectedOffset); }); + it("keeps a pure linked image mounted and places a click at the alt-text start", async () => { + const mounted = await mountProjectionEditor(`${IMAGE_LINK_SOURCE} tail`); + const imagePosition = getEditorNodePosition(mounted, "image"); + + await waitFor(() => { + expect(within(mounted.view.dom).getByRole("img", { name: "alt" })).toBeInTheDocument(); + }); + + const image = within(mounted.view.dom).getByRole("img", { name: "alt" }); + + dispatchMouseDown(image); + + expect(hasActiveSourceProjection(mounted.view.state)).toBe(true); + expect(containsNodeType(mounted, "image")).toBe(true); + expect(within(mounted.view.dom).getByRole("img", { name: "alt" })).toBe(image); + expect(mounted.view.state.selection.empty).toBe(true); + expect(mounted.view.state.selection.head).toBe(imagePosition + "[![".length); + + setTextSelection(mounted.view, imagePosition + IMAGE_LINK_SOURCE.length); + dispatchMouseDown(image); + + expect(hasActiveSourceProjection(mounted.view.state)).toBe(true); + expect(mounted.view.state.selection.head).toBe(imagePosition + "[![".length); + }); + + it.each([ + { entryOffset: 1, expectedOffset: 0, key: "ArrowDown", side: "before" }, + { + entryOffset: 0, + expectedOffset: IMAGE_LINK_SOURCE.length, + key: "ArrowUp", + side: "after", + }, + ])( + "uses keyboard direction when entering a pure image link from the block $side it", + async ({ entryOffset, expectedOffset, key, side }) => { + const mounted = await mountProjectionEditor(`before\n\n${IMAGE_LINK_SOURCE}\n\nafter`); + const imagePosition = getEditorNodePosition(mounted, "image"); + const adjacentPosition = getEditorTextPosition(mounted, side); + + setTextSelection( + mounted.view, + side === "before" ? adjacentPosition + side.length : adjacentPosition, + ); + runKeyDownHandlers(mounted.view, key); + setTextSelection(mounted.view, imagePosition + entryOffset); + + expect(hasActiveSourceProjection(mounted.view.state)).toBe(true); + expect(containsNodeType(mounted, "image")).toBe(true); + expect(mounted.view.state.selection.head).toBe(imagePosition + expectedOffset); + }, + ); + it("projects a label mixing text and an image", async () => { const mounted = await mountProjectionEditor(`${MIXED_IMAGE_LINK_SOURCE} tail`); diff --git a/src/features/editor/tests/sourceProjectionIntegration.test.ts b/src/features/editor/tests/sourceProjectionIntegration.test.ts index 4bd6e18c..daa62836 100644 --- a/src/features/editor/tests/sourceProjectionIntegration.test.ts +++ b/src/features/editor/tests/sourceProjectionIntegration.test.ts @@ -456,20 +456,6 @@ describe("source projection integration", () => { expect(hasActiveSourceProjection(mounted.view.state)).toBe(true); expect(getEditorTextContent(mounted)).toBe(before); }); - - it("ends the session when a node is moved into projected source from the document", async () => { - const mounted = await mountProjectionEditor("[word](./doc.md) ![alt](./pic.png)"); - - enterProjection(mounted, "a"); - - const imagePosition = getEditorNodePosition(mounted, "image"); - const labelPosition = getEditorTextPosition(mounted, "[word](./doc.md)") + "[wor".length; - - dropNode(mounted, labelPosition, { nodePosition: imagePosition }); - - expect(hasActiveSourceProjection(mounted.view.state)).toBe(false); - expect(mounted.view.state.doc.nodeAt(labelPosition)?.type.name).toBe("image"); - }); }); describe("projection history", () => { diff --git a/src/features/editor/utils/characterReferenceMarkdown.ts b/src/features/editor/utils/characterReferenceMarkdown.ts index 3348fcc8..df3b275b 100644 --- a/src/features/editor/utils/characterReferenceMarkdown.ts +++ b/src/features/editor/utils/characterReferenceMarkdown.ts @@ -4,7 +4,12 @@ import type { MarkdownNode, MarkSchema, NodeSchema } from "@milkdown/kit/transfo import { decodeNamedCharacterReference } from "decode-named-character-reference"; import { decodeNumericCharacterReference } from "micromark-util-decode-numeric-character-reference"; -import { findTitleSource, TITLE_MARKER_ATTRIBUTE_NAME, readTitleMarker } from "./markdownTitle"; +import { + findTitleSource, + TITLE_MARKER_ATTRIBUTE_NAME, + TITLE_MARKER_PAIRS, + readTitleMarker, +} from "./markdownTitle"; type RemarkStringifyHandlers = NonNullable< ReturnType["handlers"] @@ -19,6 +24,7 @@ export const AUTHORED_TITLE_ATTRIBUTE_NAME = "authoredTitle"; // An image description holds inline content, and the parser keeps only the text it spells, so the // node carries the source the description was written with wherever that source says more. export const AUTHORED_DESCRIPTION_ATTRIBUTE_NAME = "authoredDescription"; +export const IMAGE_DESTINATION_MARKER_ATTRIBUTE_NAME = "imageDestinationMarker"; export const CHARACTER_REFERENCE_SOURCE_ATTRIBUTE_NAME = "source"; // Every reference closes on this character, named, decimal, and hexadecimal alike, so one @@ -308,6 +314,21 @@ const findDestinationSource = (raw: string): string | null => { return raw.slice(start, end); }; +export const findImageDestinationMarker = (raw: string) => { + const destination = findDescriptionSource(raw)?.tail.match(/^\(\s*(<)?/u); + + return destination?.[1] === "<" ? "<" : ""; +}; + +export const removeImageDestinationTrailingSpace = (raw: string) => { + const source = findDescriptionSource(raw); + const destination = source?.tail.match(/^(\(\s*<[^>\r\n]*) >([\s\S]*)$/u); + + return source && destination + ? `![${source.description}]${destination[1]}>${destination[2]}` + : raw; +}; + export const resolveMarkdownEscapes = (value: string) => { let resolved = ""; @@ -568,10 +589,74 @@ const omitAuthoredAttributes = (attributes: Record) => { return rendered; }; +const IMAGE_NODE_ATTRS_ATTRIBUTE_NAME = "data-leafdown-image-attrs"; +const IMAGE_REFERENCE_TYPES = new Set(["collapsed", "full", "shortcut"]); + +export const writeImageNodeAttrsToDom = (element: HTMLElement, attrs: Record) => { + element.setAttribute(IMAGE_NODE_ATTRS_ATTRIBUTE_NAME, JSON.stringify(attrs)); +}; + +const readImageNodeAttrsFromDom = (element: HTMLElement) => { + const serialized = element.getAttribute(IMAGE_NODE_ATTRS_ATTRIBUTE_NAME); + + if (!serialized) { + return null; + } + + try { + const attrs: unknown = JSON.parse(serialized); + + if (!attrs || typeof attrs !== "object" || Array.isArray(attrs)) { + return null; + } + + const candidate = attrs as Record; + const titleMarker = candidate[TITLE_MARKER_ATTRIBUTE_NAME]; + const destinationMarker = candidate[IMAGE_DESTINATION_MARKER_ATTRIBUTE_NAME]; + const referenceType = candidate.referenceType; + const nullableStringKeys = [ + "title", + AUTHORED_URL_ATTRIBUTE_NAME, + AUTHORED_TITLE_ATTRIBUTE_NAME, + AUTHORED_DESCRIPTION_ATTRIBUTE_NAME, + ]; + + if ( + typeof candidate.src !== "string" || + typeof candidate.alt !== "string" || + typeof candidate.referenceLabel !== "string" || + nullableStringKeys.some( + (key) => candidate[key] !== null && typeof candidate[key] !== "string", + ) || + (destinationMarker !== "" && destinationMarker !== "<") || + typeof titleMarker !== "string" || + !(titleMarker in TITLE_MARKER_PAIRS) || + (referenceType !== null && !IMAGE_REFERENCE_TYPES.has(referenceType as string)) + ) { + return null; + } + + return { + alt: candidate.alt, + src: candidate.src, + title: candidate.title, + [AUTHORED_URL_ATTRIBUTE_NAME]: candidate[AUTHORED_URL_ATTRIBUTE_NAME], + [AUTHORED_TITLE_ATTRIBUTE_NAME]: candidate[AUTHORED_TITLE_ATTRIBUTE_NAME], + [AUTHORED_DESCRIPTION_ATTRIBUTE_NAME]: candidate[AUTHORED_DESCRIPTION_ATTRIBUTE_NAME], + [IMAGE_DESTINATION_MARKER_ATTRIBUTE_NAME]: destinationMarker, + [TITLE_MARKER_ATTRIBUTE_NAME]: titleMarker, + referenceLabel: candidate.referenceLabel, + referenceType, + }; + } catch { + return null; + } +}; + // An image is a node rather than a mark, so the form it was authored in travels in node attributes. -// The rendered `img` carries none of them and no parse rule reads them back, which leaves a copy -// through the DOM holding the decoded destination, a double-quoted title, and a description flat -// to its text — the same fallback an edit inside a reference takes. +// Schema DOM output keeps those attributes internal; the image node view adds a validated copy to +// its rendered `img` so a browser-driven move can reconstruct the authored form instead of reading +// the resolved asset URL back as the destination. export const withAuthoredDestination = (schema: NodeSchema): NodeSchema => { const { toDOM } = schema; @@ -582,8 +667,23 @@ export const withAuthoredDestination = (schema: NodeSchema): NodeSchema => { [AUTHORED_URL_ATTRIBUTE_NAME]: { default: null, validate: "string|null" }, [AUTHORED_TITLE_ATTRIBUTE_NAME]: { default: null, validate: "string|null" }, [AUTHORED_DESCRIPTION_ATTRIBUTE_NAME]: { default: null, validate: "string|null" }, + [IMAGE_DESTINATION_MARKER_ATTRIBUTE_NAME]: { default: "", validate: "string" }, [TITLE_MARKER_ATTRIBUTE_NAME]: { default: '"', validate: "string" }, }, + parseDOM: schema.parseDOM?.map((rule) => ({ + ...rule, + getAttrs: (dom) => { + const originalAttrs = rule.getAttrs?.(dom); + + if (originalAttrs === false) { + return false; + } + + const authoredAttrs = dom instanceof HTMLElement ? readImageNodeAttrsFromDom(dom) : null; + + return authoredAttrs ? { ...originalAttrs, ...authoredAttrs } : (originalAttrs ?? null); + }, + })), toDOM: toDOM && ((node) => { @@ -605,6 +705,8 @@ export const withAuthoredDestination = (schema: NodeSchema): NodeSchema => { [AUTHORED_URL_ATTRIBUTE_NAME]: readAuthoredUrl(node), [AUTHORED_TITLE_ATTRIBUTE_NAME]: readAuthoredTitle(node), [AUTHORED_DESCRIPTION_ATTRIBUTE_NAME]: readAuthoredDescription(node), + [IMAGE_DESTINATION_MARKER_ATTRIBUTE_NAME]: + (node as Record)[IMAGE_DESTINATION_MARKER_ATTRIBUTE_NAME] ?? "", [TITLE_MARKER_ATTRIBUTE_NAME]: readTitleMarker(node), }); }, @@ -619,6 +721,8 @@ export const withAuthoredDestination = (schema: NodeSchema): NodeSchema => { [AUTHORED_URL_ATTRIBUTE_NAME]: node.attrs[AUTHORED_URL_ATTRIBUTE_NAME], [AUTHORED_TITLE_ATTRIBUTE_NAME]: node.attrs[AUTHORED_TITLE_ATTRIBUTE_NAME], [AUTHORED_DESCRIPTION_ATTRIBUTE_NAME]: node.attrs[AUTHORED_DESCRIPTION_ATTRIBUTE_NAME], + [IMAGE_DESTINATION_MARKER_ATTRIBUTE_NAME]: + node.attrs[IMAGE_DESTINATION_MARKER_ATTRIBUTE_NAME], [TITLE_MARKER_ATTRIBUTE_NAME]: node.attrs[TITLE_MARKER_ATTRIBUTE_NAME], }); }, diff --git a/src/features/editor/utils/imageMarkdown.test.ts b/src/features/editor/utils/imageMarkdown.test.ts deleted file mode 100644 index a1e8d86b..00000000 --- a/src/features/editor/utils/imageMarkdown.test.ts +++ /dev/null @@ -1,285 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - parseImageMarkdown, - readImageDescription, - serializeImageMarkdown, - type ImageDefinition, - type ImageMarkdownAttrs, -} from "./imageMarkdown"; - -type InlineImageAttrs = Omit< - ImageMarkdownAttrs, - "description" | "referenceLabel" | "referenceType" -> & - Partial>; - -const inlineImageAttrs = (attrs: InlineImageAttrs): ImageMarkdownAttrs => ({ - referenceLabel: "", - referenceType: null, - description: readImageDescription(null, attrs.alt), - ...attrs, -}); - -describe("imageMarkdown", () => { - it("serializes image attrs into editable Markdown", () => { - expect( - serializeImageMarkdown( - inlineImageAttrs({ - alt: "Sample", - src: "./assets/icon.png", - title: "", - titleMarker: '"', - }), - ), - ).toBe("![Sample](./assets/icon.png)"); - - expect( - serializeImageMarkdown( - inlineImageAttrs({ - alt: "A ] bracket", - src: "./assets/icon.png", - title: 'A "quoted" title', - titleMarker: '"', - }), - ), - ).toBe('![A \\] bracket](./assets/icon.png "A \\"quoted\\" title")'); - - expect( - serializeImageMarkdown( - inlineImageAttrs({ - alt: "A [ and ] bracket", - src: "./assets/icon with spaces.png", - title: "", - titleMarker: '"', - }), - ), - ).toBe("![A \\[ and \\] bracket](<./assets/icon with spaces.png>)"); - }); - - it.each([ - ["'" as const, "![Sample](./assets/icon.png 'A title')"], - ['"' as const, '![Sample](./assets/icon.png "A title")'], - ["(" as const, "![Sample](./assets/icon.png (A title))"], - ])("serializes a title authored with %s in the form it was authored in", (marker, expected) => { - expect( - serializeImageMarkdown( - inlineImageAttrs({ - alt: "Sample", - src: "./assets/icon.png", - title: "A title", - titleMarker: marker, - }), - ), - ).toBe(expected); - }); - - // A parenthesis would have to be escaped inside a run CommonMark reads between matching ones, so - // the title moves to a quote that holds it bare instead. - it("serializes a parenthesized title that holds a parenthesis as a quoted title", () => { - expect( - serializeImageMarkdown( - inlineImageAttrs({ - alt: "Sample", - src: "./assets/icon.png", - title: "A (nested) title", - titleMarker: "(", - }), - ), - ).toBe('![Sample](./assets/icon.png "A (nested) title")'); - }); - - it.each([ - [ - '![Updated](./assets/updated.png "Updated title")', - { - alt: "Updated", - src: "./assets/updated.png", - title: "Updated title", - titleMarker: '"', - }, - ], - [ - "![A \\] bracket](./assets/icon(1).png)", - { alt: "A ] bracket", src: "./assets/icon(1).png", title: "", titleMarker: '"' }, - ], - [ - "![A \\[ bracket](./assets/icon.png)", - { alt: "A [ bracket", src: "./assets/icon.png", title: "", titleMarker: '"' }, - ], - [ - "![Alt](<./assets/icon with spaces.png> 'Single quoted title')", - { - alt: "Alt", - src: "./assets/icon with spaces.png", - title: "Single quoted title", - titleMarker: "'", - }, - ], - [ - "![Alt](./assets/icon.png (Parenthesized title))", - { - alt: "Alt", - src: "./assets/icon.png", - title: "Parenthesized title", - titleMarker: "(", - }, - ], - [ - '![Alt](./assets/icon.png "A \\"quoted\\" title")', - { - alt: "Alt", - src: "./assets/icon.png", - title: 'A "quoted" title', - titleMarker: '"', - }, - ], - ])("parses %s", (markdown, attrs) => { - expect(parseImageMarkdown(markdown)).toEqual(inlineImageAttrs(attrs as InlineImageAttrs)); - }); - - it("round-trips serialized image attrs", () => { - const attrs = inlineImageAttrs({ - alt: "A [ and ] bracket", - src: "./assets/icon with spaces.png", - title: 'A "quoted" title', - titleMarker: '"', - }); - - expect(parseImageMarkdown(serializeImageMarkdown(attrs))).toEqual(attrs); - }); - - const LEAF_DEFINITION: ImageDefinition = { - src: "../assets/leaf.svg", - title: "Leaf", - titleMarker: '"', - }; - const resolveLeaf = (label: string) => (label === "leaf" ? LEAF_DEFINITION : null); - - it.each([ - ["full" as const, "Reference leaf", "![Reference leaf][leaf]"], - ["collapsed" as const, "leaf", "![leaf][]"], - ["shortcut" as const, "leaf", "![leaf]"], - // A form that no longer spells its label is written as the one that names it. - ["collapsed" as const, "Renamed", "![Renamed][leaf]"], - ["shortcut" as const, "Renamed", "![Renamed][leaf]"], - ])("serializes a %s reference", (referenceType, alt, expected) => { - expect( - serializeImageMarkdown({ - alt, - description: readImageDescription(null, alt), - referenceLabel: "leaf", - referenceType, - src: LEAF_DEFINITION.src, - title: LEAF_DEFINITION.title, - titleMarker: LEAF_DEFINITION.titleMarker, - }), - ).toBe(expected); - }); - - it.each([ - ["![Reference leaf][leaf]", "full" as const, "Reference leaf", "leaf"], - ["![leaf][]", "collapsed" as const, "leaf", "leaf"], - ["![leaf]", "shortcut" as const, "leaf", "leaf"], - // The label matches its definition however it is cased and spaced. - ["![Reference leaf][ LEAF ]", "full" as const, "Reference leaf", " LEAF "], - ])( - "parses %s against the definitions the document holds", - (markdown, referenceType, alt, referenceLabel) => { - expect(parseImageMarkdown(markdown, resolveLeaf)).toEqual({ - alt, - description: readImageDescription(null, alt), - referenceLabel, - referenceType, - ...LEAF_DEFINITION, - }); - }, - ); - - it.each(["![Reference leaf][missing]", "![missing]", "![missing][]", "![Reference leaf][leaf"])( - "rejects a reference no definition resolves: %s", - (markdown) => { - expect(parseImageMarkdown(markdown, resolveLeaf)).toBeNull(); - }, - ); - - it.each(["[Alt](./assets/icon.png)", "![Alt] ./assets/icon.png", "![Alt](./assets/icon.png"])( - "rejects invalid image Markdown: %s", - (markdown) => { - expect(parseImageMarkdown(markdown)).toBeNull(); - }, - ); - - it.each([ - ["![Alt with *emphasis*](./assets/icon.png)", "Alt with *emphasis*"], - [ - "![Outer ![inner](./assets/inner.png)](./assets/icon.png)", - "Outer ![inner](./assets/inner.png)", - ], - ])("writes the description the document kept as it was written: %s", (expected, description) => { - expect( - serializeImageMarkdown( - inlineImageAttrs({ - alt: "Alt", - description, - src: "./assets/icon.png", - title: "", - titleMarker: '"', - }), - ), - ).toBe(expected); - }); - - // The input holds one image's source rather than a document, so a description typed into it is - // the text it spells and the reference forms compare against that text. - it("reads a typed description as the source it spells", () => { - expect(parseImageMarkdown("![Outer ![inner](./inner.png)](./assets/icon.png)")).toEqual( - inlineImageAttrs({ - alt: "Outer ![inner](./inner.png)", - description: "Outer ![inner](./inner.png)", - src: "./assets/icon.png", - title: "", - titleMarker: '"', - }), - ); - }); - - it.each([ - [String.raw`![a ©](./assets/icon.png)`, "a ©"], - [String.raw`![a \©](./assets/icon.png)`, "a ©"], - [String.raw`![a & b](./assets/icon.png)`, "a & b"], - ])("reads the references in %s as the file does", (markdown, alt) => { - expect(parseImageMarkdown(markdown)?.alt).toBe(alt); - }); - - it("escapes an ampersand in the alt text that would open a reference", () => { - const description = readImageDescription(null, "a © & b"); - - expect(description).toBe(String.raw`a \© & b`); - expect(parseImageMarkdown(`![${description}](./assets/icon.png)`)?.alt).toBe("a © & b"); - }); - - it("matches a shortcut reference on the references its label spells", () => { - const resolve = (label: string) => (label === "a ©" ? LEAF_DEFINITION : null); - - expect(parseImageMarkdown("![a ©]", resolve)).toEqual({ - alt: "a ©", - description: "a ©", - referenceLabel: "a ©", - referenceType: "shortcut", - ...LEAF_DEFINITION, - }); - }); - - it("keeps a reference whose description spells its label in the form it was written", () => { - expect( - serializeImageMarkdown({ - alt: "em", - description: "*em*", - referenceLabel: "*em*", - referenceType: "shortcut", - ...LEAF_DEFINITION, - }), - ).toBe("![*em*]"); - }); -}); diff --git a/src/features/editor/utils/imageMarkdown.ts b/src/features/editor/utils/imageMarkdown.ts deleted file mode 100644 index 672b1c7c..00000000 --- a/src/features/editor/utils/imageMarkdown.ts +++ /dev/null @@ -1,299 +0,0 @@ -import { decodeMarkdownText, escapeCharacterReferences } from "./characterReferenceMarkdown"; -import { chooseTitleMarker, TITLE_MARKER_PAIRS, type TitleMarker } from "./markdownTitle"; -import { normalizeReferenceLabel, type ReferenceType } from "./referenceLinkMarkdown"; - -export interface ImageMarkdownAttrs { - alt: string; - description: string; - referenceLabel: string; - referenceType: ReferenceType | null; - src: string; - title: string; - titleMarker: TitleMarker; -} - -export interface ImageDefinition { - src: string; - title: string; - titleMarker: TitleMarker; -} - -export type ImageDefinitionResolver = (label: string) => ImageDefinition | null; - -// The source a description is written with: the one the document kept from the file, and the alt -// text escaped to sit inside the brackets for a description holding nothing the text cannot carry. -export const readImageDescription = (authored: string | null, alt: string) => - authored ?? escapeImageAlt(alt); - -// The reference forms collapse to the full one where the description no longer spells the label, -// which is the rule the serializer applies to the node itself. -const serializeImageReference = ( - description: string, - label: string, - referenceType: ReferenceType, -) => { - if (referenceType === "full" || description !== label) { - return `![${description}][${label}]`; - } - - return referenceType === "shortcut" ? `![${description}]` : `![${description}][]`; -}; - -export const serializeImageMarkdown = ({ - description, - referenceLabel, - referenceType, - src, - title, - titleMarker, -}: ImageMarkdownAttrs) => { - if (referenceType) { - return serializeImageReference(description, referenceLabel, referenceType); - } - - const serializedSrc = serializeImageSource(src); - - if (!title) { - return `![${description}](${serializedSrc})`; - } - - const marker = chooseTitleMarker(title, titleMarker); - const [opening, closing] = TITLE_MARKER_PAIRS[marker]; - const serializedTitle = escapeImageTitle(title, marker); - - return `![${description}](${serializedSrc} ${opening}${serializedTitle}${closing})`; -}; - -export const parseImageMarkdown = ( - value: string, - resolveDefinition: ImageDefinitionResolver = () => null, -): ImageMarkdownAttrs | null => { - const source = value.trim(); - - if (!source.startsWith("![")) { - return null; - } - - const altEnd = findClosingBracket(source, 2); - - if (altEnd === -1) { - return null; - } - - const description = source.slice(2, altEnd); - const alt = decodeMarkdownText(description); - const tail = source.slice(altEnd + 1); - - if (tail.startsWith("(") && source.endsWith(")")) { - return { - alt, - description, - referenceLabel: "", - referenceType: null, - ...parseImageBody(source.slice(altEnd + 2, -1)), - }; - } - - return parseImageReference(alt, description, tail, resolveDefinition); -}; - -// A reference names a destination its definition holds, so a label that resolves to nothing is not -// an image and leaves the node as it was. -const parseImageReference = ( - alt: string, - description: string, - tail: string, - resolveDefinition: ImageDefinitionResolver, -): ImageMarkdownAttrs | null => { - // A label matches its definition on the references it spells rather than on what they name. - const reference = readImageReferenceTail(unescapeMarkdownText(description), tail); - const definition = reference && resolveDefinition(normalizeReferenceLabel(reference.label)); - - return definition - ? { - alt, - description, - referenceLabel: reference.label, - referenceType: reference.referenceType, - ...definition, - } - : null; -}; - -// The tail is whatever follows the label's closing bracket: nothing for a shortcut reference, an -// empty pair for a collapsed one, and the label itself for a full one. -const readImageReferenceTail = (alt: string, tail: string) => { - if (tail === "") { - return { label: alt, referenceType: "shortcut" } as const; - } - - if (tail === "[]") { - return { label: alt, referenceType: "collapsed" } as const; - } - - if (!tail.startsWith("[") || !tail.endsWith("]")) { - return null; - } - - const label = tail.slice(1, -1); - - return label && !label.includes("]") ? ({ label, referenceType: "full" } as const) : null; -}; - -const parseImageBody = ( - body: string, -): Omit => { - const trimmedBody = body.trim(); - const titleMatch = getTrailingTitle(trimmedBody); - - if (!titleMatch) { - return { - src: normalizeImageSource(trimmedBody), - title: "", - titleMarker: '"', - }; - } - - return { - src: normalizeImageSource(titleMatch.src), - title: unescapeMarkdownText(titleMatch.title), - titleMarker: titleMatch.marker, - }; -}; - -const getTrailingTitle = ( - body: string, -): { marker: TitleMarker; src: string; title: string } | null => { - const delimiter = body.at(-1); - - if (delimiter === '"' || delimiter === "'") { - const titleStart = findOpeningDelimiter(body, delimiter); - - if (titleStart > 0 && /\s/u.test(body[titleStart - 1])) { - return { - marker: delimiter, - src: body.slice(0, titleStart).trim(), - title: body.slice(titleStart + 1, -1), - }; - } - } - - if (delimiter === ")") { - const titleStart = findOpeningTitleParenthesis(body); - - if (titleStart > 0 && /\s/u.test(body[titleStart - 1])) { - return { - marker: "(", - src: body.slice(0, titleStart).trim(), - title: body.slice(titleStart + 1, -1), - }; - } - } - - return null; -}; - -const findClosingBracket = (value: string, start: number) => { - let bracketDepth = 0; - - for (let index = start; index < value.length; index += 1) { - const character = value[index]; - - if (character === "\\") { - index += 1; - continue; - } - - if (character === "[") { - bracketDepth += 1; - continue; - } - - if (character === "]") { - if (bracketDepth === 0) { - return index; - } - - bracketDepth -= 1; - } - } - - return -1; -}; - -const findOpeningDelimiter = (value: string, delimiter: string) => { - for (let index = value.length - 2; index >= 0; index -= 1) { - if (value[index] === delimiter && !isEscaped(value, index)) { - return index; - } - } - - return -1; -}; - -const findOpeningTitleParenthesis = (value: string) => { - let depth = 0; - - for (let index = value.length - 1; index >= 0; index -= 1) { - const character = value[index]; - - if (isEscaped(value, index)) { - continue; - } - - if (character === ")") { - depth += 1; - continue; - } - - if (character === "(") { - depth -= 1; - - if (depth === 0) { - return index; - } - } - } - - return -1; -}; - -const isEscaped = (value: string, index: number) => { - let backslashCount = 0; - - for (let cursor = index - 1; cursor >= 0 && value[cursor] === "\\"; cursor -= 1) { - backslashCount += 1; - } - - return backslashCount % 2 === 1; -}; - -const normalizeImageSource = (value: string) => { - const source = value.trim(); - - return source.startsWith("<") && source.endsWith(">") - ? unescapeMarkdownText(source.slice(1, -1).trim()) - : unescapeMarkdownText(source); -}; - -// The input reads a description's references as the parser does, so an ampersand in the text that -// would open one is escaped to stay the character it is. -const escapeImageAlt = (value: string) => - escapeCharacterReferences(value.replace(/[\\[\]]/gu, "\\$&")); - -const TITLE_ESCAPE_PATTERNS: Record = { - '"': /[\\"]/gu, - "'": /[\\']/gu, - "(": /[\\()]/gu, -}; - -const escapeImageTitle = (value: string, marker: TitleMarker) => - value.replace(TITLE_ESCAPE_PATTERNS[marker], "\\$&"); - -const serializeImageSource = (value: string) => - needsAngledImageSource(value) ? `<${value.replace(/[\\>]/gu, "\\$&")}>` : value; - -const needsAngledImageSource = (value: string) => /[\s<>]/u.test(value); - -const unescapeMarkdownText = (value: string) => - value.replace(/\\([!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~])/gu, "$1"); diff --git a/src/features/editor/utils/markdownDestination.ts b/src/features/editor/utils/markdownDestination.ts index cdf51504..8ed9bbf5 100644 --- a/src/features/editor/utils/markdownDestination.ts +++ b/src/features/editor/utils/markdownDestination.ts @@ -2,10 +2,12 @@ import type { remarkStringifyOptionsCtx } from "@milkdown/kit/core"; import { defaultHandlers } from "mdast-util-to-markdown"; import { + IMAGE_DESTINATION_MARKER_ATTRIBUTE_NAME, decodeCharacterReferences, findCharacterReferenceSources, readAuthoredDescription, readWrittenTitle, + removeImageDestinationTrailingSpace, } from "./characterReferenceMarkdown"; import { chooseTitleMarker, @@ -406,9 +408,26 @@ export const serializeMarkdownImage: NonNullable - defaultHandlers.image(image, parent, state, info), - ); + return withAuthoredTitle(image, state.options, () => { + if ( + (node as unknown as Record)[IMAGE_DESTINATION_MARKER_ATTRIBUTE_NAME] !== + "<" || + /[\0- \u007F]/u.test(image.url) + ) { + return defaultHandlers.image(image, parent, state, info); + } + + // The upstream handler chooses angle syntax only when a destination requires it. A temporary + // space takes that branch so an optional authored angle pair can be retained without + // duplicating the handler; the space is removed from its result immediately below. + const angled = defaultHandlers.image( + { ...image, url: `${image.url} ` }, + parent, + state, + info, + ); + return removeImageDestinationTrailingSpace(angled); + }); } finally { restoreTitle(); restore(); diff --git a/src/features/editor/utils/sourceProjectionAdapters.ts b/src/features/editor/utils/sourceProjectionAdapters.ts index 5b03569a..bbcbaca7 100644 --- a/src/features/editor/utils/sourceProjectionAdapters.ts +++ b/src/features/editor/utils/sourceProjectionAdapters.ts @@ -55,6 +55,7 @@ export type SourceProjectionAdapterId = | "character-reference" | "escape" | "footnote-reference" + | "image" | "link" | "mark"; @@ -122,6 +123,11 @@ export interface SourceProjectionPresentation { spans: SourceProjectionPresentationSpan[]; } +export interface SourceProjectionEntryContext { + direction: "backward" | "forward" | null; + pointer: boolean; +} + export interface SourceProjectionInsertionCandidate< TTarget extends SourceProjectionTarget = SourceProjectionTarget, > extends TextRange { @@ -157,12 +163,17 @@ export interface SourceProjectionAdapter< findLiteralSourceCommit?(state: EditorState, range: TextRange): LiteralSourceCommit | null; findTarget(state: EditorState): TTarget | null; getPresentation(target: TTarget, source: string): SourceProjectionPresentation; + getRestoreRange?(session: SourceProjectionSessionRange): TextRange; mapSelectionFromSource( selection: Selection, session: SourceProjectionSessionRange, parsed: SourceProjectionParseResult, ): { anchor: number; head: number }; - mapSelectionToSource(selection: Selection, target: TTarget): { anchor: number; head: number }; + mapSelectionToSource( + selection: Selection, + target: TTarget, + context: SourceProjectionEntryContext, + ): { anchor: number; head: number }; // Whether the caret still sits on what this session was opened for. An adapter whose range // covers more than the caret's own object narrows it here, so the session gives way once the // caret moves onto something the range holds but the session does not answer for. @@ -210,8 +221,7 @@ interface ProjectionMarkSegment extends ActiveProjectionRange { const LINK_MARK_NAME = "link"; -const isLinkImage = (node: ProseMirrorNode) => - node.type.name === "image" && node.marks.some((mark) => mark.type.name === LINK_MARK_NAME); +const isProjectableImage = (node: ProseMirrorNode) => node.type.name === "image"; const createTextSlice = ( state: EditorState, @@ -524,7 +534,7 @@ const getProjectionMarksFromInlineNode = ( !node.isText && !isFootnoteReference && node.type.name !== "hardbreak" && - !isLinkImage(node) + !isProjectableImage(node) ) { return []; } @@ -835,6 +845,16 @@ const getMarkedFragmentPresentation = ( continue; } + if (segment.type === "image") { + objectTypes.add("image"); + spans.push({ + className: "leafdown-source-projection__marker", + from: segment.sourceFrom, + to: segment.sourceTo, + }); + continue; + } + objectTypes.add("footnote-reference"); spans.push( { diff --git a/src/features/editor/utils/sourceProjectionImageAdapter.ts b/src/features/editor/utils/sourceProjectionImageAdapter.ts new file mode 100644 index 00000000..127ef148 --- /dev/null +++ b/src/features/editor/utils/sourceProjectionImageAdapter.ts @@ -0,0 +1,232 @@ +import { Fragment, Slice, type Node as ProseMirrorNode } from "@milkdown/kit/prose/model"; +import type { EditorState, Selection } from "@milkdown/kit/prose/state"; +import { NodeSelection, TextSelection } from "@milkdown/kit/prose/state"; +import type { Parser, Serializer } from "@milkdown/kit/transformer"; + +import { readEnclosingInlineConstructs, serializeLinkRunSource } from "./logicalLinkMarkdown"; +import { + createLiteralSourceProjectionSlice, + decodeSourceProjectionEscapes, + mapLiteralSourceOffsetToDocument, + shouldHandleInlineObjectTextInput, + type SourceProjectionAdapter, + type SourceProjectionParseResult, + type SourceProjectionSessionRange, + type SourceProjectionTarget, +} from "./sourceProjectionAdapters"; +import { getDocumentDefinitionSources } from "./sourceProjectionDefinitions"; +import { isStandaloneImage, parseStandaloneImageSource } from "./sourceProjectionImageSyntax"; + +const IMAGE_ADAPTER_ID = "image"; +const IMAGE_DESCRIPTION_START_OFFSET = 2; + +interface ImageSourceProjectionTarget extends SourceProjectionTarget { + adapterId: typeof IMAGE_ADAPTER_ID; + definitions: readonly string[]; +} + +interface ImageAdapterDependencies { + parser: Parser; + serializer: Serializer; +} + +const createImageTarget = ( + state: EditorState, + serializer: Serializer, + node: ProseMirrorNode, + from: number, +): ImageSourceProjectionTarget => ({ + adapterId: IMAGE_ADAPTER_ID, + definitions: getDocumentDefinitionSources(state.doc), + from, + originalContent: state.doc.slice(from, from + node.nodeSize), + originalContentSize: node.nodeSize, + originalSource: serializeLinkRunSource( + state, + serializer, + [node], + readEnclosingInlineConstructs(state.doc.resolve(from)), + ), + to: from + node.nodeSize, +}); + +const findImageTarget = ( + state: EditorState, + serializer: Serializer, +): ImageSourceProjectionTarget | null => { + const { selection } = state; + + if (selection instanceof NodeSelection && isStandaloneImage(selection.node)) { + return createImageTarget(state, serializer, selection.node, selection.from); + } + + if (!(selection instanceof TextSelection) || !selection.empty || !selection.$cursor) { + return null; + } + + const nodeAfter = selection.$cursor.nodeAfter; + + if (isStandaloneImage(nodeAfter)) { + return createImageTarget(state, serializer, nodeAfter, selection.from); + } + + const nodeBefore = selection.$cursor.nodeBefore; + + return isStandaloneImage(nodeBefore) + ? createImageTarget(state, serializer, nodeBefore, selection.from - nodeBefore.nodeSize) + : null; +}; + +const mapSelectionPositionToSource = (position: number, target: ImageSourceProjectionTarget) => { + if (position <= target.from) { + return position; + } + + return position >= target.to + ? target.from + target.originalSource.length + (position - target.to) + : target.from; +}; + +const mapAtomicSelectionPositionFromSource = ( + position: number, + session: SourceProjectionSessionRange, + result: SourceProjectionParseResult, +) => { + if (position <= session.from) { + return position; + } + + return position >= session.to + ? session.from + + result.replacementSize + + Math.max(0, position - session.to - session.target.originalContentSize) + : session.from; +}; + +const mapLiteralSelectionPositionFromSource = ( + position: number, + session: SourceProjectionSessionRange, + result: SourceProjectionParseResult, +) => { + if (position <= session.from) { + return position; + } + + if (position >= session.to) { + return ( + session.from + + result.replacementSize + + Math.max(0, position - session.to - session.target.originalContentSize) + ); + } + + return session.from + mapLiteralSourceOffsetToDocument(result.source, position - session.from); +}; + +const mapSelectionFromSource = ( + parser: Parser, + selection: Selection, + session: SourceProjectionSessionRange, + result: SourceProjectionParseResult, +) => { + const mapPosition = parseStandaloneImageSource(parser, result.source, session.target.definitions) + ? mapAtomicSelectionPositionFromSource + : mapLiteralSelectionPositionFromSource; + + return { + anchor: mapPosition(selection.anchor, session, result), + head: mapPosition(selection.head, session, result), + }; +}; + +export const createImageSourceProjectionAdapter = ({ + parser, + serializer, +}: ImageAdapterDependencies): SourceProjectionAdapter => ({ + id: IMAGE_ADAPTER_ID, + canCopySelectionSemantically: (selection, session, parsed) => + !parseStandaloneImageSource(parser, parsed.source, session.target.definitions) || + (selection.from === session.from && selection.to === session.to), + createEnterTransaction: (state, target) => + state.tr.replace( + target.from, + target.from, + createLiteralSourceProjectionSlice(state, target.originalSource), + ), + findTarget: (state) => findImageTarget(state, serializer), + getPresentation: (_target, source) => ({ + previews: [], + sourceTypes: [IMAGE_ADAPTER_ID], + spans: [ + { + className: "leafdown-source-projection__marker", + from: 0, + to: source.length, + }, + ], + }), + getRestoreRange: (session) => ({ + from: session.from, + to: session.to + session.target.originalContentSize, + }), + mapSelectionFromSource: (selection, session, result) => + mapSelectionFromSource(parser, selection, session, result), + mapSelectionToSource: (selection, target, context) => { + if (context.direction === "forward") { + return { anchor: target.from, head: target.from }; + } + + if (context.direction === "backward") { + const sourceEnd = target.from + target.originalSource.length; + + return { anchor: sourceEnd, head: sourceEnd }; + } + + if (context.pointer && selection instanceof NodeSelection) { + const descriptionStart = Math.min( + target.from + IMAGE_DESCRIPTION_START_OFFSET, + target.from + target.originalSource.length, + ); + + return { + anchor: descriptionStart, + head: descriptionStart, + }; + } + + if (selection instanceof NodeSelection) { + return { anchor: target.from, head: target.from }; + } + + return { + anchor: mapSelectionPositionToSource(selection.anchor, target), + head: mapSelectionPositionToSource(selection.head, target), + }; + }, + parseSource: (state, source, target) => { + const image = parseStandaloneImageSource(parser, source, target.definitions); + + if (image) { + return { + replacement: new Slice(Fragment.from(image), 0, 0), + replacementSize: image.nodeSize, + source, + }; + } + + const literal = decodeSourceProjectionEscapes(source); + + return { + replacement: createLiteralSourceProjectionSlice(state, literal), + replacementSize: literal.length, + source, + }; + }, + restoreCleanTarget: (state, session) => state.tr.delete(session.from, session.to), + serializeInlineSource: (state, fragment) => { + const image = fragment.childCount === 1 ? fragment.firstChild : null; + + return isStandaloneImage(image) ? serializeLinkRunSource(state, serializer, [image]) : null; + }, + shouldHandleTextInput: shouldHandleInlineObjectTextInput, +}); diff --git a/src/features/editor/utils/sourceProjectionImageSyntax.ts b/src/features/editor/utils/sourceProjectionImageSyntax.ts new file mode 100644 index 00000000..96609ca6 --- /dev/null +++ b/src/features/editor/utils/sourceProjectionImageSyntax.ts @@ -0,0 +1,29 @@ +import type { Node as ProseMirrorNode } from "@milkdown/kit/prose/model"; +import type { Parser } from "@milkdown/kit/transformer"; + +import { getAugmentedParagraph, withProjectionDefinitions } from "./sourceProjectionDefinitions"; + +export const IMAGE_NODE_NAME = "image"; + +export const isStandaloneImage = ( + node: ProseMirrorNode | null | undefined, +): node is ProseMirrorNode => node?.type.name === IMAGE_NODE_NAME && node.marks.length === 0; + +export const parseStandaloneImageSource = ( + parser: Parser, + source: string, + definitions: readonly string[], +): ProseMirrorNode | null => { + let document: ProseMirrorNode; + + try { + document = parser(withProjectionDefinitions(source, definitions)); + } catch { + return null; + } + + const paragraph = getAugmentedParagraph(document); + const image = paragraph?.childCount === 1 ? paragraph.firstChild : null; + + return isStandaloneImage(image) ? image : null; +}; diff --git a/src/features/editor/utils/sourceProjectionLinkAdapter.ts b/src/features/editor/utils/sourceProjectionLinkAdapter.ts index c3d340ba..092d9ec2 100644 --- a/src/features/editor/utils/sourceProjectionLinkAdapter.ts +++ b/src/features/editor/utils/sourceProjectionLinkAdapter.ts @@ -71,6 +71,7 @@ interface LinkSourceProjectionTarget extends SourceProjectionTarget { ambientMarks: readonly Mark[]; constructs: readonly ConstructName[]; definitions: readonly string[]; + retainsImage: boolean; sourceMap: LinkSourceMap; } @@ -232,6 +233,7 @@ const findLinkTarget = ( originalContent: state.doc.slice(range.from, range.to), originalContentSize: range.to - range.from, originalSource, + retainsImage: nodes.length === 1 && nodes[0].type.name === IMAGE_NODE_NAME, sourceMap, to: range.to, }; @@ -554,6 +556,14 @@ const createEnterLinkProjectionTransaction = ( state: EditorState, target: LinkSourceProjectionTarget, ) => { + if (target.retainsImage) { + return state.tr.replace( + target.from, + target.from, + createLiteralSourceProjectionSlice(state, target.originalSource), + ); + } + const transaction = state.tr.removeMark(target.from, target.to); const { enter } = getLinkProjectionTextEdits(target); @@ -565,6 +575,11 @@ const createRestoreCleanLinkTransaction = ( session: SourceProjectionSessionRange, ) => { const { target } = session; + + if (target.retainsImage) { + return state.tr.delete(session.from, session.to); + } + const { restore } = getLinkProjectionTextEdits(target); const transaction = applyLinkProjectionTextEdits(state.tr, session.from, restore); @@ -600,7 +615,7 @@ const mapSelectionPositionToSource = ( const mapSelectionPositionFromSource = ( position: number, - session: SourceProjectionSessionRange, + session: SourceProjectionSessionRange, result: SourceProjectionParseResult, map: LinkSourceMap | null, ) => { @@ -609,6 +624,14 @@ const mapSelectionPositionFromSource = ( } if (position >= session.to) { + if (session.target.retainsImage) { + return ( + session.from + + result.replacementSize + + Math.max(0, position - session.to - session.target.originalContentSize) + ); + } + return session.from + result.replacementSize + (position - session.to); } @@ -777,6 +800,13 @@ export const createLinkSourceProjectionAdapter = ({ })), }; }, + getRestoreRange: (session) => + session.target.retainsImage + ? { + from: session.from, + to: session.to + session.target.originalContentSize, + } + : session, mapSelectionFromSource: (selection, session, result) => { const map = createLinkSourceMap( remark, @@ -790,7 +820,24 @@ export const createLinkSourceProjectionAdapter = ({ head: mapSelectionPositionFromSource(selection.head, session, result, map), }; }, - mapSelectionToSource: (selection, linkTarget) => { + mapSelectionToSource: (selection, linkTarget, context) => { + if (linkTarget.retainsImage && context.direction === "forward") { + return { anchor: linkTarget.from, head: linkTarget.from }; + } + + if (linkTarget.retainsImage && context.direction === "backward") { + const sourceEnd = linkTarget.from + linkTarget.originalSource.length; + + return { anchor: sourceEnd, head: sourceEnd }; + } + + if (linkTarget.retainsImage && context.pointer) { + const image = linkTarget.sourceMap.segments.find((segment) => segment.type === "image"); + const descriptionStart = linkTarget.from + (image?.sourceFrom ?? 1) + 2; + + return { anchor: descriptionStart, head: descriptionStart }; + } + const isForwardSelection = selection.anchor <= selection.head; const anchorAssociation = selection.empty || isForwardSelection ? 1 : -1; const headAssociation = selection.empty || !isForwardSelection ? 1 : -1; diff --git a/src/features/editor/utils/sourceProjectionMarkedFragmentSyntax.ts b/src/features/editor/utils/sourceProjectionMarkedFragmentSyntax.ts index aa7afff0..db90a423 100644 --- a/src/features/editor/utils/sourceProjectionMarkedFragmentSyntax.ts +++ b/src/features/editor/utils/sourceProjectionMarkedFragmentSyntax.ts @@ -34,6 +34,7 @@ import { parseFootnoteReferenceSource, serializeFootnoteReference, } from "./sourceProjectionFootnoteReferenceSyntax"; +import { parseStandaloneImageSource } from "./sourceProjectionImageSyntax"; import { createIdentityBoundaries, findUnpositionedChildRange, @@ -90,6 +91,10 @@ interface MarkedFragmentLinkSourceSegment extends MarkedFragmentSourceSegmentBas type: "link"; } +interface MarkedFragmentImageSourceSegment extends MarkedFragmentSourceSegmentBase { + type: "image"; +} + interface MarkedFragmentTextSourceSegment extends MarkedFragmentSourceSegmentBase { // One source offset per document offset the run covers, so a backslash the file spends on an // escape maps onto the character it keeps literal rather than onto a position of its own. @@ -101,6 +106,7 @@ interface MarkedFragmentTextSourceSegment extends MarkedFragmentSourceSegmentBas export type MarkedFragmentSourceSegment = | MarkedFragmentBreakSourceSegment | MarkedFragmentCharacterReferenceSourceSegment + | MarkedFragmentImageSourceSegment | MarkedFragmentLinkSourceSegment | MarkedFragmentReferenceSourceSegment | MarkedFragmentTextSourceSegment; @@ -408,6 +414,8 @@ const isSupportedMarkedFragmentChild = (node: MarkdownNode): boolean => { node.type === "text" || node.type === HARD_BREAK_MARKDOWN_TYPE || node.type === "footnoteReference" || + node.type === "image" || + node.type === "imageReference" || node.type === CHARACTER_REFERENCE_MARKDOWN_TYPE ) { return true; @@ -478,6 +486,7 @@ export const serializeMarkedFragmentSource = ( const runNodes = nodes.slice(index, runEnd); const documentSize = runNodes.reduce((size, runNode) => size + runNode.nodeSize, 0); const isBreak = node.type.name === INLINE_BREAK_NODE_NAME; + const isImage = node.type.name === "image" && !linkMark; const isSoftBreak = isBreak && node.attrs.isInline === true; const referenceSource = linkMark ? null : getPreservedCharacterReferenceSource(node); const isPlainText = !linkMark && !referenceSource && node.isText; @@ -518,11 +527,18 @@ export const serializeMarkedFragmentSource = ( ), constructs, ) - : (referenceSource ?? - escapedText ?? - cellCodeSource ?? - breakSource ?? - (node.isText ? text : serializeFootnoteReference(state, serializer, node))); + : isImage + ? serializeLinkRunSource( + state, + serializer, + [node.mark(node.marks.filter((mark) => !wrappingMarkNames.has(mark.type.name)))], + constructs, + ) + : (referenceSource ?? + escapedText ?? + cellCodeSource ?? + breakSource ?? + (node.isText ? text : serializeFootnoteReference(state, serializer, node))); const sourceTo = sourceOffset + nodeSource.length; if (escapedContent !== null) { @@ -548,6 +564,14 @@ export const serializeMarkedFragmentSource = ( sourceTo, type: "link", }); + } else if (isImage) { + innerSegments.push({ + documentFrom: documentOffset, + documentTo: documentOffset + documentSize, + sourceFrom: sourceOffset, + sourceTo, + type: "image", + }); } else if (referenceSource) { innerSegments.push({ documentFrom: documentOffset, @@ -722,6 +746,25 @@ export const createMarkedFragmentSourceStructure = ( type: "link", }); documentOffset += map.documentSize; + } else if (child.type === "image" || child.type === "imageReference") { + const image = parseStandaloneImageSource( + parser, + source.slice(position.from, position.to), + definitions, + ); + + if (!image) { + return createMarkedLiteralStructure(source, parsed.marks); + } + + segments.push({ + documentFrom: documentOffset, + documentTo: documentOffset + image.nodeSize, + sourceFrom: position.from, + sourceTo: position.to, + type: "image", + }); + documentOffset += image.nodeSize; } else if (child.type === CHARACTER_REFERENCE_MARKDOWN_TYPE) { const text = decodeWholeCharacterReference(source.slice(position.from, position.to)); @@ -854,6 +897,14 @@ export const parseMarkedFragmentSource = ( return linkNodes ?? []; } + if (segment.type === "image") { + const image = parseStandaloneImageSource(parser, segmentSource, definitions); + + isValid &&= image !== null; + + return image ? [image.mark(documentMarks)] : []; + } + if (segment.type === "characterReference") { const text = decodeWholeCharacterReference(segmentSource); @@ -919,6 +970,7 @@ export const mapMarkedFragmentDocumentOffsetToSource = ( if ( segment.type === "break" || segment.type === "characterReference" || + segment.type === "image" || segment.type === "footnoteReference" ) { return normalizedOffset <= segment.documentFrom ? segment.sourceFrom : segment.sourceTo; @@ -961,7 +1013,11 @@ export const mapMarkedFragmentSourceOffsetToDocument = ( : segment.documentTo; } - if (segment.type === "break" || segment.type === "characterReference") { + if ( + segment.type === "break" || + segment.type === "characterReference" || + segment.type === "image" + ) { return offset - segment.sourceFrom < segment.sourceTo - offset ? segment.documentFrom : segment.documentTo; diff --git a/src/features/session/services/dropWorkflows.test.ts b/src/features/session/services/dropWorkflows.test.ts index 18ecbc02..c4efc5f8 100644 --- a/src/features/session/services/dropWorkflows.test.ts +++ b/src/features/session/services/dropWorkflows.test.ts @@ -8,7 +8,7 @@ import { setDefaultSession, setDefaultSettings } from "@/test/utils/appStores"; import { mockInvokeCommands } from "@/test/utils/tauri"; import { INSPECT_DROPPED_PATH_COMMAND } from "./dropApi"; -import { getRelativePath, handleDroppedPaths, prepareDroppedPaths } from "./dropWorkflows"; +import { handleDroppedPaths, prepareDroppedPaths } from "./dropWorkflows"; const openSessionMocks = vi.hoisted(() => ({ openFolderContextAtPath: vi.fn(async () => true), @@ -99,13 +99,4 @@ describe("dropped path workflows", () => { type: "warning", }); }); - - it("derives portable relative targets without crossing path roots", () => { - expect(getRelativePath("C:/Notes/drafts", "c:\\Notes\\guides\\setup.md")).toBe( - "../guides/setup.md", - ); - expect(getRelativePath("C:/Notes", "D:/Guides/setup.md")).toBeNull(); - expect(getRelativePath("/home/notes", "/home/notes/guide.md")).toBe("guide.md"); - expect(getRelativePath("//server/share/notes", "//SERVER/SHARE/guides")).toBe("../guides"); - }); }); diff --git a/src/features/session/services/dropWorkflows.ts b/src/features/session/services/dropWorkflows.ts index 06d27207..af75352b 100644 --- a/src/features/session/services/dropWorkflows.ts +++ b/src/features/session/services/dropWorkflows.ts @@ -1,6 +1,6 @@ import { getActiveDocumentKey } from "@/features/document"; import { useSettingsStore } from "@/features/preferences"; -import { getPathParts, toSlashPath } from "@/lib/path"; +import { getPathParts, getRelativePath, toSlashPath } from "@/lib/path"; import { notifyWarning } from "@/lib/toast"; import { useSessionStore } from "../stores/session"; @@ -8,15 +8,6 @@ import { documentEditorBridge } from "./documentEditorBridge"; import { inspectDroppedPath, type DroppedPath } from "./dropApi"; import { openFolderContextAtPath, openMarkdownFileAtPath } from "./openSession"; -interface PathRoot { - caseInsensitive: boolean; - components: string[]; - root: string; -} - -const WINDOWS_DRIVE_ROOT_PATTERN = /^([a-z]:)(?:\/|$)/iu; -const WINDOWS_UNC_ROOT_PATTERN = /^\/\/([^/]+)\/([^/]+)(?:\/|$)/u; - type SupportedDroppedPath = Exclude; export type DroppedPathAction = @@ -136,66 +127,3 @@ const insertDroppedPathLink = (droppedPath: SupportedDroppedPath) => { return true; }; - -export const getRelativePath = (fromFolderPath: string, targetPath: string) => { - const from = splitPath(fromFolderPath); - const target = splitPath(targetPath); - - if (!sameComponent(from.root, target.root, from.caseInsensitive || target.caseInsensitive)) { - return null; - } - - let sharedLength = 0; - - while ( - sharedLength < from.components.length && - sharedLength < target.components.length && - sameComponent( - from.components[sharedLength], - target.components[sharedLength], - from.caseInsensitive, - ) - ) { - sharedLength += 1; - } - - const parentSegments = Array.from({ length: from.components.length - sharedLength }, () => ".."); - const targetSegments = target.components.slice(sharedLength); - - return [...parentSegments, ...targetSegments].join("/") || "."; -}; - -const splitPath = (path: string): PathRoot => { - const slashPath = toSlashPath(path); - const driveMatch = slashPath.match(WINDOWS_DRIVE_ROOT_PATTERN); - - if (driveMatch) { - return { - caseInsensitive: true, - components: slashPath.slice(driveMatch[0].length).split("/").filter(Boolean), - root: driveMatch[1], - }; - } - - const uncMatch = slashPath.match(WINDOWS_UNC_ROOT_PATTERN); - - if (uncMatch) { - return { - caseInsensitive: true, - components: slashPath.slice(uncMatch[0].length).split("/").filter(Boolean), - root: `//${uncMatch[1]}/${uncMatch[2]}`, - }; - } - - return { - caseInsensitive: false, - components: slashPath - .replace(/^\/+|\/+$/gu, "") - .split("/") - .filter(Boolean), - root: slashPath.startsWith("/") ? "/" : "", - }; -}; - -const sameComponent = (left: string, right: string, caseInsensitive: boolean) => - caseInsensitive ? left.toLowerCase() === right.toLowerCase() : left === right; diff --git a/src/lib/path.test.ts b/src/lib/path.test.ts index 15e228a3..9d345529 100644 --- a/src/lib/path.test.ts +++ b/src/lib/path.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { getPathIdentityKey, getPathParts, + getRelativePath, isSameOrParentPath, isSamePath, PathMap, @@ -64,6 +65,15 @@ describe("path utilities", () => { expect(isSameOrParentPath("", "/Users/Ada/Notes")).toBe(false); }); + it("derives portable relative paths without crossing path roots", () => { + expect(getRelativePath("C:/Notes/drafts", "c:\\Notes\\guides\\setup.md")).toBe( + "../guides/setup.md", + ); + expect(getRelativePath("C:/Notes", "D:/Guides/setup.md")).toBeNull(); + expect(getRelativePath("/home/notes", "/home/notes/guide.md")).toBe("guide.md"); + expect(getRelativePath("//server/share/notes", "//SERVER/SHARE/guides")).toBe("../guides"); + }); + it("stores values by path identity", () => { const paths = new PathMap([["C:/Notes/Readme.md", 1]]); diff --git a/src/lib/path.ts b/src/lib/path.ts index cd5d2d3c..15946247 100644 --- a/src/lib/path.ts +++ b/src/lib/path.ts @@ -3,6 +3,14 @@ export const toSlashPath = (path: string) => path.replaceAll("\\", "/"); const WINDOWS_DRIVE_PATH_PATTERN = /^[a-z]:($|\/)/iu; const SLASH_ONLY_PATH_PATTERN = /^\/+$/u; const WINDOWS_DRIVE_ROOT_PATTERN = /^([a-z]:)\/+$/iu; +const WINDOWS_DRIVE_RELATIVE_ROOT_PATTERN = /^([a-z]:)(?:\/|$)/iu; +const WINDOWS_UNC_ROOT_PATTERN = /^\/\/([^/]+)\/([^/]+)(?:\/|$)/u; + +interface PathRoot { + caseInsensitive: boolean; + components: string[]; + root: string; +} const isWindowsPath = (path: string) => WINDOWS_DRIVE_PATH_PATTERN.test(path) || path.startsWith("//"); @@ -72,6 +80,69 @@ export const isSameNullablePath = (leftPath: string | null, rightPath: string | ? leftPath === rightPath : isSamePath(leftPath, rightPath); +export const getRelativePath = (fromFolderPath: string, targetPath: string) => { + const from = splitPathRoot(fromFolderPath); + const target = splitPathRoot(targetPath); + + if (!samePathComponent(from.root, target.root, from.caseInsensitive || target.caseInsensitive)) { + return null; + } + + let sharedLength = 0; + + while ( + sharedLength < from.components.length && + sharedLength < target.components.length && + samePathComponent( + from.components[sharedLength], + target.components[sharedLength], + from.caseInsensitive, + ) + ) { + sharedLength += 1; + } + + const parentSegments = Array.from({ length: from.components.length - sharedLength }, () => ".."); + const targetSegments = target.components.slice(sharedLength); + + return [...parentSegments, ...targetSegments].join("/") || "."; +}; + +const splitPathRoot = (path: string): PathRoot => { + const slashPath = toSlashPath(path); + const driveMatch = slashPath.match(WINDOWS_DRIVE_RELATIVE_ROOT_PATTERN); + + if (driveMatch) { + return { + caseInsensitive: true, + components: slashPath.slice(driveMatch[0].length).split("/").filter(Boolean), + root: driveMatch[1], + }; + } + + const uncMatch = slashPath.match(WINDOWS_UNC_ROOT_PATTERN); + + if (uncMatch) { + return { + caseInsensitive: true, + components: slashPath.slice(uncMatch[0].length).split("/").filter(Boolean), + root: `//${uncMatch[1]}/${uncMatch[2]}`, + }; + } + + return { + caseInsensitive: false, + components: slashPath + .replace(/^\/+|\/+$/gu, "") + .split("/") + .filter(Boolean), + root: slashPath.startsWith("/") ? "/" : "", + }; +}; + +const samePathComponent = (left: string, right: string, caseInsensitive: boolean) => + caseInsensitive ? left.toLowerCase() === right.toLowerCase() : left === right; + interface PathMapEntry { path: string; value: Value;