Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
115 changes: 111 additions & 4 deletions src/features/editor/plugins/sourceProjection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -898,26 +955,50 @@ 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);

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 () => {
Expand Down Expand Up @@ -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)");

Expand Down
11 changes: 11 additions & 0 deletions src/features/editor/tests/markdownCompatibility.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
28 changes: 27 additions & 1 deletion src/features/editor/tests/sourceProjectionClipboard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
getEditorTextContent,
getEditorTextPosition,
getMarkNames,
setSelectionAtDocumentEnd,
setTextSelection,
typeText,
} from "@/test/utils/prosemirror";
Expand Down Expand Up @@ -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");
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
7 changes: 6 additions & 1 deletion src/features/editor/utils/logicalLinkMarkdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)) {
Expand Down
11 changes: 5 additions & 6 deletions src/features/editor/utils/sourceProjectionAdapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
mapFootnoteReferenceSourceOffsetToDocument,
parseFootnoteReferenceSource,
serializeFootnoteReference,
withFootnoteDefinitions,
} from "./sourceProjectionFootnoteReferenceSyntax";

const mountEditor = setupMilkdownEditorMount();
Expand Down Expand Up @@ -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]");

Expand Down
Loading