From 599aeaf7dddd1d1fd322f50322f14d2b465a4952 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20Jos=C3=A9=20Ferreira=20J=C3=BAnior?= Date: Sat, 15 Aug 2026 11:11:44 -0300 Subject: [PATCH] Project a link label holding a footnote reference Admitting the reference node to the label was not enough: the label's source map parsed with remark, which reads `[^label]` as a reference only while a matching definition is in the same document, and a projected label carries the inline run alone. The definition injection the marked fragment already used moves to the reference syntax module and now covers the link's map and both parse-back paths, which is also why a map rejects a block starting inside the source it was given. The logical-link serializer skipped a run whose label held a reference for the same reason it never projected, saving `[**bold** a[^n]](./x)` as two links. The candidate scan excludes an unescaped bracket, which a label cannot contain and which let a run of unterminated `[^` scan quadratically. --- CHANGELOG.md | 2 + docs/specification.md | 2 +- .../editor/plugins/sourceProjection.test.tsx | 115 +++++++++++++++++- .../tests/markdownCompatibility.test.tsx | 11 ++ .../tests/sourceProjectionClipboard.test.tsx | 28 ++++- .../sourceProjectionIntegration.test.tsx | 8 +- .../editor/utils/logicalLinkMarkdown.ts | 7 +- .../editor/utils/sourceProjectionAdapters.ts | 11 +- ...ProjectionFootnoteReferenceSyntax.test.tsx | 19 +++ ...sourceProjectionFootnoteReferenceSyntax.ts | 26 +++- .../utils/sourceProjectionLinkAdapter.ts | 22 ++-- .../utils/sourceProjectionLinkSyntax.ts | 60 +++++++-- .../sourceProjectionMarkedFragmentSyntax.ts | 28 +---- 13 files changed, 284 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b9e0c8..70c32b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,8 @@ Leafdown uses lightweight [Keep a Changelog](https://keepachangelog.com/en/1.1.0 ### Fixed +- Open the Markdown source of a link whose label holds a footnote reference, instead of leaving it closed everywhere in the label except on the reference itself. +- Keep a link label that mixes formatted text with a footnote reference as one link, instead of saving it as two links. - Open bold, italic, or strikethrough that wraps a link as one Markdown source with the link inside it, instead of one side of the link at a time with markers that do not match the file. - Keep bold, italic, or strikethrough that wraps a link when the label repeats the same formatting inside it, instead of dropping the wrapper on save. - Keep a strikethrough that wraps a link outside the link on save, as bold and italic already are, instead of rewriting it inside the label. diff --git a/docs/specification.md b/docs/specification.md index 343f5d1..b3155b2 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -149,7 +149,7 @@ The editor is a unified hybrid Markdown surface. Behavior is governed by renderi - 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. 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. - Inline-code projection uses a valid canonical backtick delimiter run rather than preserving the exact source delimiter length. -- Link and autolink projection exposes their source directly in the document; links preserve their label, target, optional title, and compatible uniform outer inline formatting. 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, semantic soft line endings, or an image. An image in a projected label becomes its own Markdown source and returns as an image when the label commits. Soft line endings remain one logical label; indentation follows Leafdown's canonical serialization. Valid edits rehydrate one link over the complete rich label; invalid or incomplete edits become exact literal text. Mixed-format and multiline labels do not fall back to fragmented projections for their nested content. +- Link and autolink projection exposes their source directly in the document; links preserve their label, target, optional title, and compatible uniform outer inline formatting. 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, semantic soft line endings, an image, or a footnote reference. An image or footnote reference in a projected label becomes its own Markdown source and returns as its object when the label commits. Soft line endings remain one logical label; indentation follows Leafdown's canonical serialization. Valid edits rehydrate one link over the complete rich label; invalid or incomplete edits become exact literal text. Mixed-format and multiline labels do not fall back to fragmented projections for their nested content. - 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 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. 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. diff --git a/src/features/editor/plugins/sourceProjection.test.tsx b/src/features/editor/plugins/sourceProjection.test.tsx index 48fe3f3..21c2294 100644 --- a/src/features/editor/plugins/sourceProjection.test.tsx +++ b/src/features/editor/plugins/sourceProjection.test.tsx @@ -491,6 +491,63 @@ describe("source projection", () => { expect(getSelectedEditorText(mounted)).toBe("bold"); }); + it("projects a link label holding a footnote reference", async () => { + const source = "[Link containing a reference[^follow-up]](./field-report.md)"; + const mounted = await mountProjectionEditor(`${source}\n\n[^follow-up]: Detail`); + const originalDocument = mounted.view.state.doc; + const labelStart = getEditorTextPosition(mounted, "Link containing a reference"); + + setTextSelection(mounted.view, labelStart + "Link".length); + + expect(hasActiveSourceProjection(mounted.view.state)).toBe(true); + expect(getEditorTextContent(mounted)).toContain(source); + expect( + Array.from( + mounted.view.dom.querySelectorAll(".leafdown-source-projection__content--link-label"), + (fragment) => fragment.textContent, + ).join(""), + ).toBe("Link containing a reference[^follow-up]"); + expect( + mounted.view.dom.querySelector(".leafdown-source-projection__content--footnote-reference"), + ).toHaveTextContent("[^follow-up]"); + + setSelectionAtDocumentEnd(mounted.view); + + expect(mounted.view.state.doc.eq(originalDocument)).toBe(true); + + const selectionFrom = getEditorTextPosition(mounted, "containing"); + + setTextSelection(mounted.view, selectionFrom, selectionFrom + "containing".length); + + expect(hasActiveSourceProjection(mounted.view.state)).toBe(true); + expect(getSelectedEditorText(mounted)).toBe("containing"); + }); + + it.each([ + { offset: 0, side: "before", sourceOffset: "[Link containing a reference".length }, + { + offset: 1, + side: "after", + sourceOffset: "[Link containing a reference[^follow-up]".length, + }, + ])( + "projects a link label from the caret $side its footnote reference", + async ({ offset, sourceOffset }) => { + const source = "[Link containing a reference[^follow-up]](./field-report.md)"; + const mounted = await mountProjectionEditor(`${source}\n\n[^follow-up]: Detail`); + + setTextSelection( + mounted.view, + getEditorNodePosition(mounted, "footnote_reference") + offset, + ); + + expect(hasActiveSourceProjection(mounted.view.state)).toBe(true); + expect(mounted.view.state.selection.from).toBe( + getEditorTextPosition(mounted, source) + sourceOffset, + ); + }, + ); + it("restores the exact original document after a clean projection", async () => { const mounted = await mountProjectionEditor( '**[Strong Link](https://example.com "Title")** plain', @@ -898,7 +955,7 @@ describe("source projection", () => { it("keeps a valid outer wrapper when marked reference content becomes unsupported", async () => { const source = "**Text[^note]**"; - const linkLikeSource = "[Text[^note]](https://example.com)"; + const imageSource = "![Text[^note]](./pic.png)"; const mounted = await mountProjectionEditor(`${source}\n\n[^note]: Detail`); selectFootnoteReference(mounted); @@ -906,18 +963,42 @@ describe("source projection", () => { const sourceStart = getEditorTextPosition(mounted, source); setTextSelection(mounted.view, sourceStart + 2, sourceStart + source.length - 2); - typeText(mounted.view, linkLikeSource); + typeText(mounted.view, imageSource); setSelectionAtDocumentEnd(mounted.view); const strongMark = mounted.view.state.schema.marks.strong; - const literalNode = findEditorTextNode(mounted, linkLikeSource); + const literalNode = findEditorTextNode(mounted, imageSource); expect(literalNode).not.toBeNull(); expect(strongMark.isInSet(literalNode!.marks)).toBeDefined(); expect(() => getEditorNodePosition(mounted, "footnote_reference")).toThrow( "Could not find footnote_reference node.", ); - expect(mounted.view.dom.querySelector("a")).not.toBeInTheDocument(); + expect(mounted.view.dom.querySelector("img")).not.toBeInTheDocument(); + }); + + it("rehydrates an edited marked link label holding a reference", async () => { + const source = "**left[^note][link[^other]](https://example.com)right**"; + const mounted = await mountProjectionEditor(`${source}\n\n[^note]: D\n\n[^other]: O`); + + selectFootnoteReference(mounted); + + expect(getProjectedFootnoteSource(mounted)).toBe(source); + + const sourceStart = getEditorTextPosition(mounted, source); + + setTextSelection(mounted.view, sourceStart + "**left[^note][link".length); + typeText(mounted.view, "ed"); + setSelectionAtDocumentEnd(mounted.view); + + expect(mounted.getMarkdown()).toContain(source.replace("[link", "[linked")); + expect( + getEditorNodePosition( + mounted, + "footnote_reference", + (node) => node.attrs.label === "other", + ), + ).toBeGreaterThan(0); }); it("commits incomplete footnote source as exact literal document text", async () => { @@ -1550,6 +1631,32 @@ describe("source projection", () => { }, ); + it("commits an edited label holding a footnote reference", async () => { + const source = "[Link containing a reference[^follow-up]](./field-report.md)"; + const mounted = await mountProjectionEditor(`${source}\n\n[^follow-up]: Detail`); + const labelStart = getEditorTextPosition(mounted, "Link containing a reference"); + + setTextSelection(mounted.view, labelStart + "Link".length); + + const sourceStart = getEditorTextPosition(mounted, source); + + setTextSelection(mounted.view, sourceStart + "[Link".length); + typeText(mounted.view, "ed"); + setSelectionAtDocumentEnd(mounted.view); + + expect(hasActiveSourceProjection(mounted.view.state)).toBe(false); + expect(mounted.getMarkdown()).toBe( + `${source.replace("[Link", "[Linked")}\n\n[^follow-up]: Detail\n`, + ); + + const reference = mounted.view.state.doc.nodeAt( + getEditorNodePosition(mounted, "footnote_reference"), + ); + + expect(getMarkNames(reference!)).toEqual(["link"]); + expect(reference?.marks[0].attrs.href).toBe("./field-report.md"); + }); + it("commits destination edits while preserving a mixed-format label", async () => { const mounted = await mountProjectionEditor("[**Bold** and *soft*](https://example.com)"); diff --git a/src/features/editor/tests/markdownCompatibility.test.tsx b/src/features/editor/tests/markdownCompatibility.test.tsx index d667dc3..abaf637 100644 --- a/src/features/editor/tests/markdownCompatibility.test.tsx +++ b/src/features/editor/tests/markdownCompatibility.test.tsx @@ -229,6 +229,17 @@ describe("Markdown compatibility", () => { expect(mounted.getMarkdown()).toBe(`${source}\n`); }); + it.each([ + "[label[^note]](./doc.md)", + "[**bold** label[^note]](./doc.md)", + "[**bold**[^note]](./doc.md)", + "**[label[^note]](./doc.md)**", + ])("preserves logical link wrappers around footnote references in %s", async (source) => { + const mounted = await mountEditor(`${source}\n\n[^note]: Detail`); + + expect(mounted.getMarkdown()).toBe(`${source}\n\n[^note]: Detail\n`); + }); + it("uses logical link serialization for Markdown update listeners", async () => { const onMarkdownUpdated = vi.fn(); const mounted = await mountEditor("[plain **bold**](first)\n\nTail", { onMarkdownUpdated }); diff --git a/src/features/editor/tests/sourceProjectionClipboard.test.tsx b/src/features/editor/tests/sourceProjectionClipboard.test.tsx index 5622ad7..599adf1 100644 --- a/src/features/editor/tests/sourceProjectionClipboard.test.tsx +++ b/src/features/editor/tests/sourceProjectionClipboard.test.tsx @@ -13,6 +13,7 @@ import { getEditorTextContent, getEditorTextPosition, getMarkNames, + setSelectionAtDocumentEnd, setTextSelection, typeText, } from "@/test/utils/prosemirror"; @@ -368,7 +369,7 @@ describe("source projection clipboard slices", () => { expect(mounted.getMarkdown()).toBe(`[wor${image}d](./doc.md) ${image}\n`); }); - it("pastes no line break for a node the projected source cannot hold", async () => { + it("pastes a footnote reference into a projected link label as its Markdown source", async () => { const mounted = await mountEditor("[word](./doc.md) Text[^note]\n\n[^note]: Detail"); enterProjection(mounted, "a"); @@ -383,6 +384,31 @@ describe("source projection clipboard slices", () => { getEditorTextPosition(mounted, "[word](./doc.md)") + "[wor".length, ); + expect( + mounted.view.someProp("handlePaste", (handler) => handler(mounted.view, event, slice)), + ).toBe(true); + expect(getEditorTextContent(mounted)).toBe("[wor[^note]d](./doc.md) TextDetail"); + + setSelectionAtDocumentEnd(mounted.view); + + expect(mounted.getMarkdown()).toBe("[wor[^note]d](./doc.md) Text[^note]\n\n[^note]: Detail\n"); + }); + + it("pastes no line break for a node the projected source cannot hold", async () => { + const mounted = await mountEditor("[word](./doc.md) Text\\\nmore"); + + enterProjection(mounted, "a"); + + const breakPosition = getEditorNodePosition(mounted, "hardbreak"); + const slice = mounted.view.state.doc.slice(breakPosition, breakPosition + 1); + const event = new Event("paste", { bubbles: true, cancelable: true }) as ClipboardEvent; + + Object.defineProperty(event, "clipboardData", { value: createClipboardData() }); + setTextSelection( + mounted.view, + getEditorTextPosition(mounted, "[word](./doc.md)") + "[wor".length, + ); + const before = getEditorTextContent(mounted); expect( diff --git a/src/features/editor/tests/sourceProjectionIntegration.test.tsx b/src/features/editor/tests/sourceProjectionIntegration.test.tsx index d317564..0df04a3 100644 --- a/src/features/editor/tests/sourceProjectionIntegration.test.tsx +++ b/src/features/editor/tests/sourceProjectionIntegration.test.tsx @@ -441,17 +441,15 @@ describe("source projection integration", () => { }); it("drops no line break for a node the projected source cannot hold", async () => { - const mounted = await mountProjectionEditor( - "[word](./doc.md) Text[^note]\n\n[^note]: Detail", - ); + const mounted = await mountProjectionEditor("[word](./doc.md) Text\\\nmore"); enterProjection(mounted, "a"); - const referencePosition = getEditorNodePosition(mounted, "footnote_reference"); + const breakPosition = getEditorNodePosition(mounted, "hardbreak"); const labelPosition = getEditorTextPosition(mounted, "[word](./doc.md)") + "[wor".length; const before = getEditorTextContent(mounted); - dropNode(mounted, labelPosition, { nodePosition: referencePosition, copy: true }); + dropNode(mounted, labelPosition, { nodePosition: breakPosition, copy: true }); expect(hasActiveSourceProjection(mounted.view.state)).toBe(true); expect(getEditorTextContent(mounted)).toBe(before); diff --git a/src/features/editor/utils/logicalLinkMarkdown.ts b/src/features/editor/utils/logicalLinkMarkdown.ts index ccde34e..728483a 100644 --- a/src/features/editor/utils/logicalLinkMarkdown.ts +++ b/src/features/editor/utils/logicalLinkMarkdown.ts @@ -2,6 +2,8 @@ import { Fragment, Mark, type Node as ProseMirrorNode } from "@milkdown/kit/pros import type { EditorState } from "@milkdown/kit/prose/state"; import type { Serializer } from "@milkdown/kit/transformer"; +import { FOOTNOTE_REFERENCE_NODE_NAME } from "./sourceProjectionFootnoteReferenceSyntax"; + interface LogicalLinkReplacement { source: string; token: string; @@ -29,7 +31,10 @@ const isInlineSoftBreak = (node: ProseMirrorNode) => node.type.name === "hardbreak" && node.attrs.isInline === true; const isSerializableLinkNode = (node: ProseMirrorNode) => - node.isText || isInlineSoftBreak(node) || node.type.name === "image"; + node.isText || + isInlineSoftBreak(node) || + node.type.name === "image" || + node.type.name === FOOTNOTE_REFERENCE_NODE_NAME; const isMixedLinkRun = (nodes: readonly ProseMirrorNode[], linkMark: Mark) => { if (!nodes.every(isSerializableLinkNode)) { diff --git a/src/features/editor/utils/sourceProjectionAdapters.ts b/src/features/editor/utils/sourceProjectionAdapters.ts index 5ab4d9d..097f236 100644 --- a/src/features/editor/utils/sourceProjectionAdapters.ts +++ b/src/features/editor/utils/sourceProjectionAdapters.ts @@ -7,6 +7,7 @@ import { isNonNullish } from "@/lib/predicates"; import { getCandidateMarksAtSelection, getMarkRangeAtPosition } from "./marks"; import { FOOTNOTE_REFERENCE_NODE_NAME } from "./sourceProjectionFootnoteReferenceSyntax"; +import { isAtomicLinkSegment } from "./sourceProjectionLinkSyntax"; import { createMarkedFragmentSourceStructure, mapMarkedFragmentDocumentOffsetToSource, @@ -868,12 +869,10 @@ const getAtomicSourceRanges = (map: MarkedFragmentSourceMap): TextRange[] => return []; } - return segment.map.segments - .filter((linkSegment) => linkSegment.type === "image") - .map((linkSegment) => ({ - from: segment.sourceFrom + linkSegment.sourceFrom, - to: segment.sourceFrom + linkSegment.sourceTo, - })); + return segment.map.segments.filter(isAtomicLinkSegment).map((linkSegment) => ({ + from: segment.sourceFrom + linkSegment.sourceFrom, + to: segment.sourceFrom + linkSegment.sourceTo, + })); }); const shouldHandleMarkTextInput = (source: string, { from, text, to }: SourceProjectionEdit) => diff --git a/src/features/editor/utils/sourceProjectionFootnoteReferenceSyntax.test.tsx b/src/features/editor/utils/sourceProjectionFootnoteReferenceSyntax.test.tsx index 3b0bd7d..094de30 100644 --- a/src/features/editor/utils/sourceProjectionFootnoteReferenceSyntax.test.tsx +++ b/src/features/editor/utils/sourceProjectionFootnoteReferenceSyntax.test.tsx @@ -9,6 +9,7 @@ import { mapFootnoteReferenceSourceOffsetToDocument, parseFootnoteReferenceSource, serializeFootnoteReference, + withFootnoteDefinitions, } from "./sourceProjectionFootnoteReferenceSyntax"; const mountEditor = setupMilkdownEditorMount(); @@ -41,6 +42,24 @@ describe("footnote-reference source syntax", () => { }, ); + it.each([ + { description: "no reference", expected: [], source: "[label](./doc.md)" }, + { description: "one reference", expected: ["[^note]"], source: "[a[^note]](./doc.md)" }, + { description: "repeated labels", expected: ["[^note]"], source: "[^note] and [^note]" }, + { + description: "an escaped bracket in the label", + expected: ["[^archive\\]]"], + source: "[^archive\\]]", + }, + { description: "an unescaped bracket in the label", expected: [], source: "[^a[b] tail" }, + ])("defines $description for a projected source", ({ expected, source }) => { + const definitions = expected.map((reference) => `${reference}: Leafdown`); + + expect(withFootnoteDefinitions(source)).toBe( + definitions.length ? `${source}\n\n${definitions.join("\n\n")}` : source, + ); + }); + it("maps between the atomic document node and its editable label", () => { const bounds = getFootnoteReferenceSourceBounds("[^archive]"); diff --git a/src/features/editor/utils/sourceProjectionFootnoteReferenceSyntax.ts b/src/features/editor/utils/sourceProjectionFootnoteReferenceSyntax.ts index 9edcb2f..7ffce3a 100644 --- a/src/features/editor/utils/sourceProjectionFootnoteReferenceSyntax.ts +++ b/src/features/editor/utils/sourceProjectionFootnoteReferenceSyntax.ts @@ -7,6 +7,8 @@ export const FOOTNOTE_REFERENCE_OPENING = "[^"; export const FOOTNOTE_REFERENCE_CLOSING = "]"; const FOOTNOTE_DEFINITION_NODE_NAME = "footnote_definition"; +const FOOTNOTE_REFERENCE_CANDIDATE_PATTERN = /\[\^(?:\\.|[^[\]\\\r\n])+\]/gu; +const PARAGRAPH_NODE_NAME = "paragraph"; const VALIDATION_DEFINITION_CONTENT = "Leafdown"; export interface FootnoteReferenceSourceBounds { @@ -35,6 +37,28 @@ export const getFootnoteReferenceSourceBounds = ( export const hasCompleteFootnoteReferenceWrapper = (source: string) => getFootnoteReferenceSourceBounds(source) !== null; +export const withFootnoteDefinitions = (source: string) => { + const definitions = new Set( + Array.from( + source.matchAll(FOOTNOTE_REFERENCE_CANDIDATE_PATTERN), + ([reference]) => `${reference}: ${VALIDATION_DEFINITION_CONTENT}`, + ), + ); + + return definitions.size ? `${source}\n\n${[...definitions].join("\n\n")}` : source; +}; + +export const getFootnoteAugmentedParagraph = (document: ProseMirrorNode) => { + const paragraph = document.firstChild; + let isValid = paragraph?.type.name === PARAGRAPH_NODE_NAME; + + document.forEach((node, _offset, index) => { + isValid &&= index === 0 || node.type.name === FOOTNOTE_DEFINITION_NODE_NAME; + }); + + return isValid ? paragraph : null; +}; + export const serializeFootnoteReference = ( state: EditorState, serializer: Serializer, @@ -71,7 +95,7 @@ export const parseFootnoteReferenceSource = ( const reference = paragraph?.childCount === 1 ? paragraph.firstChild : null; if ( - paragraph?.type.name !== "paragraph" || + paragraph?.type.name !== PARAGRAPH_NODE_NAME || reference?.type.name !== FOOTNOTE_REFERENCE_NODE_NAME || definition?.type.name !== FOOTNOTE_DEFINITION_NODE_NAME || reference.attrs.label !== definition.attrs.label diff --git a/src/features/editor/utils/sourceProjectionLinkAdapter.ts b/src/features/editor/utils/sourceProjectionLinkAdapter.ts index 93dda80..2cfce28 100644 --- a/src/features/editor/utils/sourceProjectionLinkAdapter.ts +++ b/src/features/editor/utils/sourceProjectionLinkAdapter.ts @@ -14,8 +14,14 @@ import { type SourceProjectionSessionRange, type SourceProjectionTarget, } from "./sourceProjectionAdapters"; +import { + FOOTNOTE_REFERENCE_NODE_NAME, + getFootnoteAugmentedParagraph, + withFootnoteDefinitions, +} from "./sourceProjectionFootnoteReferenceSyntax"; import { createLinkSourceMap, + isAtomicLinkSegment, mapLinkDocumentPositionToSource, mapLinkSourcePositionToDocument, type LinkSourceMap, @@ -37,7 +43,10 @@ const isInlineSoftBreak = (node: ProseMirrorNode) => node.type.name === "hardbreak" && node.attrs.isInline === true; const isSupportedLinkNode = (node: ProseMirrorNode) => - node.isText || isInlineSoftBreak(node) || node.type.name === "image"; + node.isText || + isInlineSoftBreak(node) || + node.type.name === "image" || + node.type.name === FOOTNOTE_REFERENCE_NODE_NAME; interface LinkSourceProjectionTarget extends SourceProjectionTarget { adapterId: typeof LINK_ADAPTER_ID; @@ -231,12 +240,12 @@ const parseLinkSource = ( let document: ProseMirrorNode; try { - document = parser(source); + document = parser(withFootnoteDefinitions(source)); } catch { return null; } - const paragraph = document.childCount === 1 ? document.firstChild : null; + const paragraph = getFootnoteAugmentedParagraph(document); if (!paragraph?.isTextblock || paragraph.type !== state.schema.nodes.paragraph) { return null; @@ -498,7 +507,7 @@ const isLinkSelectionSemantic = ( const selectionTo = selection.to - session.from; return map.segments.every((segment) => { - if (segment.type !== "image") { + if (!isAtomicLinkSegment(segment)) { return true; } @@ -580,9 +589,8 @@ export const createLinkSourceProjectionAdapter = ({ serializer, }: LinkAdapterDependencies): SourceProjectionAdapter => ({ id: LINK_ADAPTER_ID, - // Part of an image's source has no semantic equivalent: whichever characters the selection - // covers, the rich payload can only carry the whole image or none of it. A partial soft - // break stays semantic, since the break and the characters it spans read the same. + // A partial soft break stays semantic, since the break and the characters it spans read the + // same. canCopySelectionSemantically: (selection, session, parsed) => isLinkSelectionSemantic(selection, session, createLinkSourceMap(remark, parsed.source)), createEnterTransaction: createEnterLinkProjectionTransaction, diff --git a/src/features/editor/utils/sourceProjectionLinkSyntax.ts b/src/features/editor/utils/sourceProjectionLinkSyntax.ts index a05d942..7d0ae3b 100644 --- a/src/features/editor/utils/sourceProjectionLinkSyntax.ts +++ b/src/features/editor/utils/sourceProjectionLinkSyntax.ts @@ -2,6 +2,11 @@ import type { MarkdownNode, RemarkParser } from "@milkdown/kit/transformer"; import { isTruthy } from "@/lib/predicates"; +import { + getFootnoteReferenceSourceBounds, + withFootnoteDefinitions, +} from "./sourceProjectionFootnoteReferenceSyntax"; + interface LinkSourceSegmentBase { className: string; documentFrom: number; @@ -19,11 +24,16 @@ interface LinkImageSourceSegment extends LinkSourceSegmentBase { type: "image"; } +interface LinkFootnoteReferenceSourceSegment extends LinkSourceSegmentBase { + type: "footnoteReference"; +} + interface LinkInlineBreakSourceSegment extends LinkSourceSegmentBase { type: "inlineBreak"; } export type LinkSourceSegment = + | LinkFootnoteReferenceSourceSegment | LinkImageSourceSegment | LinkInlineBreakSourceSegment | LinkTextSourceSegment; @@ -42,6 +52,9 @@ interface MarkdownPosition { } const LINK_MARK_NAME = "link"; +const FOOTNOTE_REFERENCE_SOURCE_TYPE = "footnote-reference"; +const FOOTNOTE_REFERENCE_CONTENT_CLASS_NAME = + "leafdown-source-projection__content--footnote-reference"; const INLINE_BREAK_PATTERN = /\r\n?|\n/gu; const SOURCE_INLINE_BREAK_PATTERN = /^[\t ]*(?:\r\n?|\n)/u; @@ -134,12 +147,15 @@ const getLinkContentClassName = (ancestorTypes: readonly string[]) => const isInlineSoftBreak = (node: MarkdownNode) => node.type === "break" && (node.data as { isInline?: boolean } | undefined)?.isInline === true; +export const isAtomicLinkSegment = (segment: LinkSourceSegment) => + segment.type === "image" || segment.type === "footnoteReference"; + export const isSupportedLinkChild = (node: MarkdownNode): boolean => { if (node.type === "text" || node.type === "inlineCode") { return typeof node.value === "string"; } - if (node.type === "image" || isInlineSoftBreak(node)) { + if (node.type === "image" || node.type === "footnoteReference" || isInlineSoftBreak(node)) { return true; } @@ -150,8 +166,12 @@ export const isSupportedLinkChild = (node: MarkdownNode): boolean => { return Boolean(node.children?.length) && node.children!.every(isSupportedLinkChild); }; -const getLogicalLinkNode = (root: MarkdownNode) => { - if (root.type !== "root" || root.children?.length !== 1) { +const getLogicalLinkNode = (root: MarkdownNode, sourceLength: number) => { + if ( + root.type !== "root" || + !root.children?.length || + root.children.slice(1).some((node) => (getMarkdownPosition(node)?.from ?? -1) < sourceLength) + ) { return null; } @@ -204,15 +224,16 @@ const getLinkLabelBounds = (link: MarkdownNode) => { }; export const createLinkSourceMap = (remark: RemarkParser, source: string): LinkSourceMap | null => { + const parseSource = withFootnoteDefinitions(source); let root: MarkdownNode; try { - root = remark.parse(source) as MarkdownNode; + root = remark.parse(parseSource) as MarkdownNode; } catch { return null; } - const logicalLink = getLogicalLinkNode(root); + const logicalLink = getLogicalLinkNode(root, source.length); if (!logicalLink) { return null; @@ -333,6 +354,31 @@ export const createLinkSourceMap = (remark: RemarkParser, source: string): LinkS return true; } + if (node.type === "footnoteReference") { + const position = getMarkdownPosition(node); + + if ( + !position || + !getFootnoteReferenceSourceBounds(source.slice(position.from, position.to)) + ) { + return false; + } + + addSourceTypes(nextAncestorTypes); + sourceTypes.add(FOOTNOTE_REFERENCE_SOURCE_TYPE); + segments.push({ + className: `${getLinkContentClassName(nextAncestorTypes)} ${FOOTNOTE_REFERENCE_CONTENT_CLASS_NAME}`, + documentFrom: documentOffset, + documentTo: documentOffset + 1, + sourceFrom: position.from, + sourceTo: position.to, + type: "footnoteReference", + }); + documentOffset += 1; + + return true; + } + return node.children?.every((child) => visit(child, nextAncestorTypes)) ?? false; }; @@ -341,12 +387,12 @@ export const createLinkSourceMap = (remark: RemarkParser, source: string): LinkS } try { - root = remark.runSync(root, source) as MarkdownNode; + root = remark.runSync(root, parseSource) as MarkdownNode; } catch { return null; } - if (!getLogicalLinkNode(root)) { + if (!getLogicalLinkNode(root, source.length)) { return null; } diff --git a/src/features/editor/utils/sourceProjectionMarkedFragmentSyntax.ts b/src/features/editor/utils/sourceProjectionMarkedFragmentSyntax.ts index d594bca..d36d87f 100644 --- a/src/features/editor/utils/sourceProjectionMarkedFragmentSyntax.ts +++ b/src/features/editor/utils/sourceProjectionMarkedFragmentSyntax.ts @@ -9,10 +9,12 @@ import type { MarkdownNode, Parser, RemarkParser, Serializer } from "@milkdown/k import { serializeLinkRunSource } from "./logicalLinkMarkdown"; import { + getFootnoteAugmentedParagraph, getFootnoteReferenceSourceBounds, mapFootnoteReferenceSourceOffsetToDocument, parseFootnoteReferenceSource, serializeFootnoteReference, + withFootnoteDefinitions, } from "./sourceProjectionFootnoteReferenceSyntax"; import { createLinkSourceMap, @@ -93,8 +95,6 @@ type MarkdownValidationResult = | { type: "invalidOuter" } | { type: "unsupportedInner" }; -const FOOTNOTE_REFERENCE_CANDIDATE_PATTERN = /\[\^(?:\\.|[^\]\\\r\n])+\]/gu; -const VALIDATION_DEFINITION_CONTENT = "Leafdown"; const MARKDOWN_MARK_TYPES = new Map([ ["emphasis", "emphasis"], ["strike_through", "delete"], @@ -138,12 +138,12 @@ const parseLinkSourceNodes = ( let document: ProseMirrorNode; try { - document = parser(source); + document = parser(withFootnoteDefinitions(source)); } catch { return null; } - const paragraph = document.childCount === 1 ? document.firstChild : null; + const paragraph = getFootnoteAugmentedParagraph(document); if (paragraph?.type !== state.schema.nodes.paragraph || paragraph.content.size !== documentSize) { return null; @@ -229,26 +229,10 @@ const createUnmarkedLiteralStructure = (source: string): MarkedFragmentSourceStr const getValidatedMarkdownChildren = ( source: string, - parser: Parser, remark: RemarkParser, marks: readonly ProjectionMarkDescriptor[], ): MarkdownValidationResult => { - const definitions = new Map(); - - for (const match of source.matchAll(FOOTNOTE_REFERENCE_CANDIDATE_PATTERN)) { - const reference = parseFootnoteReferenceSource(parser, match[0]); - - if (reference) { - definitions.set( - String(reference.attrs.label), - `${match[0]}: ${VALIDATION_DEFINITION_CONTENT}`, - ); - } - } - - const validationSource = definitions.size - ? `${source}\n\n${[...definitions.values()].join("\n\n")}` - : source; + const validationSource = withFootnoteDefinitions(source); let root: MarkdownNode; try { @@ -428,7 +412,7 @@ export const createMarkedFragmentSourceStructure = ( return null; } - const validation = getValidatedMarkdownChildren(source, parser, remark, parsed.marks); + const validation = getValidatedMarkdownChildren(source, remark, parsed.marks); if (validation.type === "invalidOuter") { return createUnmarkedLiteralStructure(source);