diff --git a/CHANGELOG.md b/CHANGELOG.md index fb519e8..33c9c08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Leafdown uses lightweight [Keep a Changelog](https://keepachangelog.com/en/1.1.0 - Present active mixed-format link labels as one coordinated source range. - Extend link label source projection to labels that contain an image, such as badge links. - Keep a URL written on its own as it was written, bare or between angle brackets, instead of putting angle brackets around every bare URL in the file on the first save. +- Turn a typed link, URL, or angle-bracket URL into the link it describes once the caret leaves it, as pasting the same text already did. ### Fixed @@ -28,7 +29,7 @@ Leafdown uses lightweight [Keep a Changelog](https://keepachangelog.com/en/1.1.0 - Keep a link or footnote reference whole when a character is typed at the start of its open Markdown source, instead of turning the whole construct into literal text that saves with escapes. - Write a backslash on save only where the character it precedes would otherwise be read as Markdown, so text such as `garden_sensor_name` keeps its underscores bare, instead of escaping every character that could be syntax somewhere else. - Keep a list item that starts with a code block, table, quote, nested list, heading, or thematic break nested in the saved file, instead of writing an empty item and leaving the block outside the list the next time the document is opened. -- Keep typed link and autolink source literal in the saved file when a space follows it, instead of writing it as live Markdown that turns into a link the next time the document is opened. +- Escape text the editor keeps literal even when a space follows it, instead of writing it as live Markdown that turns into something else the next time the document is opened. - 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. diff --git a/docs/architecture.md b/docs/architecture.md index 1a2423e..b17ac02 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -92,6 +92,8 @@ Source projection temporarily exposes a supported Markdown object as unmarked, e The shared projection engine owns the active session, projected range, projection-local history, dirty-state integration, and finalization. A clean session restores its original content; an edited session rehydrates valid source or commits literal text so projected characters are not discarded. A projected range holds flat text; a session whose range stops holding it ends without committing, leaving what landed there in the document. +Document text that already spells a supported object, rather than having been projected from one, commits through the same adapter validation when the caret leaves it. The engine commits only inside the ranges the session has written, and never inside a run of source the file escaped, which it recognizes from the state a write lands in before that write changes it. A change that only relocates content the document already held, such as a table row move, declares itself and records nothing as written, because its steps re-insert what they took. The same characters reach the document either way, because the escape does not survive parsing. History clears both records, so an undone commit stays undone. + 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, then standalone footnote reference. Adapters that cannot preserve a semantic mapping fall back to literal text. diff --git a/docs/specification.md b/docs/specification.md index 47d138e..3997253 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -150,6 +150,7 @@ The editor is a unified hybrid Markdown surface. Behavior is governed by renderi - 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. 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, 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 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. +- Text written in the current editing session that is exactly the source of one inline link, autolink literal, or URI autolink becomes that link when the caret leaves it, including when a line break ends the line it sits on, so typing and plain-text paste of the same characters reach the same document. The caret has not left while only source characters separate them, which keeps a bare URL whole as it is typed. Backslash-escaped source, incomplete source, and text that is not exactly one link's source stay literal. Source the file escaped is literal text the author asked for, so editing it leaves it literal and only replacing it outright commits it, and `Undo` returns a committed link to the source it was written as. Because a committed link projects its source again whenever the caret returns, the visible text does not change. - 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, 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. diff --git a/src/features/editor/commands/formatting/tables.ts b/src/features/editor/commands/formatting/tables.ts index 830dd57..1380c3d 100644 --- a/src/features/editor/commands/formatting/tables.ts +++ b/src/features/editor/commands/formatting/tables.ts @@ -9,6 +9,7 @@ import type { EditorView } from "@milkdown/kit/prose/view"; import { areNonNullish } from "@/lib/predicates"; +import { SOURCE_PROJECTION_RESTRUCTURE_META } from "../../plugins/sourceProjection"; import { getNodeType, runProseMirrorCommand, setSelectionNear } from "../../utils/milkdown"; import { getSelectedTableRect, @@ -58,7 +59,9 @@ const dispatchTableReplacement = ( ) => { const tablePos = getTablePosition(rect); const tableStart = tablePos + 1; - const tr = view.state.tr.replaceWith(tablePos, tablePos + rect.table.nodeSize, table); + const tr = view.state.tr + .replaceWith(tablePos, tablePos + rect.table.nodeSize, table) + .setMeta(SOURCE_PROJECTION_RESTRUCTURE_META, true); if (selectionCell) { setTableCellSelection(tr, tableStart, table, selectionCell); diff --git a/src/features/editor/plugins/sourceProjection.ts b/src/features/editor/plugins/sourceProjection.ts index 91927ef..8635d24 100644 --- a/src/features/editor/plugins/sourceProjection.ts +++ b/src/features/editor/plugins/sourceProjection.ts @@ -5,7 +5,7 @@ import { remarkCtx, serializerCtx, } from "@milkdown/kit/core"; -import { closeHistory } from "@milkdown/kit/prose/history"; +import { closeHistory, isHistoryTransaction } from "@milkdown/kit/prose/history"; import { DOMParser, type Slice } from "@milkdown/kit/prose/model"; import type { EditorState, Selection, Transaction } from "@milkdown/kit/prose/state"; import { Plugin, PluginKey, TextSelection } from "@milkdown/kit/prose/state"; @@ -19,8 +19,11 @@ import { applyLiteralSourceProjectionEdit, createLiteralSourceProjectionSlice, createMarkSourceProjectionAdapter, + decodeSourceProjectionEscapes, findSourceProjectionInsertionCandidate, + findSourceProjectionLiteralSourceCommit, findSourceProjectionTarget, + type LiteralSourceCommit, type SourceProjectionAdapter, type SourceProjectionEdit, type SourceProjectionTarget, @@ -33,14 +36,17 @@ import { getRangeText, getTextBetween, type TextRange } from "../utils/textRange const EMPTY_PROJECTION_STATE: SourceProjectionPluginState = { isLinkLabelHovered: false, pendingCommit: null, + protectedRanges: [], session: null, suppressedSelection: null, + writtenRanges: [], }; export const leafdownSourceProjectionPluginKey = new PluginKey( "leafdownSourceProjection", ); export const SOURCE_PROJECTION_ENTRY_SUPPRESSION_META = "leafdownSourceProjectionSkipEntry"; +export const SOURCE_PROJECTION_RESTRUCTURE_META = "leafdownSourceProjectionRestructure"; const SOURCE_PROJECTION_SUPPRESSED_HISTORY_META = "leafdownSourceProjectionSuppressedHistory"; const INLINE_BREAK_NODE_NAME = "hardbreak"; @@ -53,6 +59,7 @@ interface ProjectionSession extends TextRange { } interface PendingProjectionCommit extends TextRange { + consumedEscape: boolean; replacement: Slice; selectionAnchor: number | null; selectionHead: number | null; @@ -64,13 +71,20 @@ interface SuppressedProjectionSelection { head: number; } -interface SourceProjectionPluginState { +interface SourceProvenance { + protectedRanges: TextRange[]; + writtenRanges: TextRange[]; +} + +interface SourceProjectionPluginState extends SourceProvenance { isLinkLabelHovered: boolean; pendingCommit: PendingProjectionCommit | null; session: ProjectionSession | null; suppressedSelection: SuppressedProjectionSelection | null; } +type ProjectionSessionState = Omit; + type ProjectionHistoryDirection = "redo" | "undo"; type ProjectionMeta = @@ -87,6 +101,7 @@ type ProjectionMeta = } | { type: "commitAfterRestore"; + escapedRange: TextRange | null; suppressedSelection: SuppressedProjectionSelection | null; }; @@ -95,8 +110,8 @@ export const createSourceProjectionProsePlugin = (adapters: readonly SourceProje return new Plugin({ key: leafdownSourceProjectionPluginKey, - appendTransaction: (transactions, _oldState, newState) => - appendProjectionTransaction(transactions, newState, adapters), + appendTransaction: (transactions, oldState, newState) => + appendProjectionTransaction(transactions, oldState, newState, adapters), // 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. @@ -145,7 +160,7 @@ export const createSourceProjectionProsePlugin = (adapters: readonly SourceProje state: { init: () => EMPTY_PROJECTION_STATE, apply: (transaction, pluginState, oldState, newState) => - applyProjectionTransaction(transaction, pluginState, oldState, newState), + applyProjectionTransaction(transaction, pluginState, oldState, newState, adapters), }, }); }; @@ -201,7 +216,7 @@ export const getSourceProjectionClipboardSlice = (state: EditorState): Slice | n const { session } = getSourceProjectionState(state); const { selection } = state; - if (!session || selection.empty || !isRangeInsideProjection(selection, session)) { + if (!session || selection.empty || !isRangeInside(selection, session)) { return null; } @@ -310,7 +325,7 @@ export const pasteIntoSourceProjection = (view: EditorView, text: string) => { const session = getSourceProjectionState(view.state).session; const { selection } = view.state; - if (!session || !isRangeInsideProjection(selection, session)) { + if (!session || !isRangeInside(selection, session)) { return false; } @@ -328,7 +343,7 @@ export const deleteSourceProjectionSelection = (view: EditorView) => { const session = getSourceProjectionState(view.state).session; const { selection } = view.state; - if (!session || selection.empty || !isRangeInsideProjection(selection, session)) { + if (!session || selection.empty || !isRangeInside(selection, session)) { return false; } @@ -368,6 +383,7 @@ const getProjectionMeta = (transaction: Transaction) => const appendProjectionTransaction = ( transactions: readonly Transaction[], + oldState: EditorState, state: EditorState, adapters: readonly SourceProjectionAdapter[], ) => { @@ -378,7 +394,7 @@ const appendProjectionTransaction = ( } if (projectionState.session) { - if (isRangeInsideProjection(state.selection, projectionState.session)) { + if (isRangeInside(state.selection, projectionState.session)) { return null; } @@ -397,6 +413,21 @@ const appendProjectionTransaction = ( return null; } + const literalSourceCommit = findExitedLiteralSourceCommit( + transactions, + oldState, + state, + adapters, + ); + + if (literalSourceCommit) { + return state.tr.replace( + literalSourceCommit.from, + literalSourceCommit.to, + literalSourceCommit.replacement, + ); + } + const match = findSourceProjectionTarget(state, adapters); if (!match) { @@ -406,12 +437,189 @@ const appendProjectionTransaction = ( return createEnterProjectionTransaction(state, match); }; +const mapRangeThroughTransactions = ( + transactions: readonly Transaction[], + range: TextRange, +): TextRange => + transactions.reduce( + (mapped, transaction) => ({ + from: transaction.mapping.map(mapped.from, -1), + to: transaction.mapping.map(mapped.to, -1), + }), + { from: range.from, to: range.to }, + ); + +// A character written against a run can still move where its source ends, as every character a +// bare URL absorbs does, so only whitespace stands for the caret having left. +const isSelectionSeparatedFrom = (state: EditorState, range: TextRange) => { + const { selection } = state; + + if (range.to <= selection.from) { + return /\s/u.test(getTextBetween(state.doc, range.to, selection.from)); + } + + if (selection.to <= range.from) { + return /\s/u.test(getTextBetween(state.doc, selection.to, range.from)); + } + + return false; +}; + +const overlapsRange = (ranges: readonly TextRange[], range: TextRange) => + ranges.some((candidate) => candidate.from < range.to && range.from < candidate.to); + +// The run holds the previous selection, so the separator measured here contains the run's own: +// nothing that fails it can pass the check on the run. +const findExitedLiteralSourceCommit = ( + transactions: readonly Transaction[], + oldState: EditorState, + state: EditorState, + adapters: readonly SourceProjectionAdapter[], +): LiteralSourceCommit | null => { + const { protectedRanges, writtenRanges } = getSourceProjectionState(state); + const previousRange = mapRangeThroughTransactions(transactions, oldState.selection); + + if (!writtenRanges.length || !isSelectionSeparatedFrom(state, previousRange)) { + return null; + } + + const commit = findSourceProjectionLiteralSourceCommit(state, previousRange, adapters); + + return commit && + isSelectionSeparatedFrom(state, commit) && + overlapsRange(writtenRanges, commit) && + !overlapsRange(protectedRanges, commit) + ? commit + : null; +}; + const applyProjectionTransaction = ( transaction: Transaction, pluginState: SourceProjectionPluginState, oldState: EditorState, newState: EditorState, -): SourceProjectionPluginState => { + adapters: readonly SourceProjectionAdapter[], +): SourceProjectionPluginState => ({ + ...applyProjectionSessionState(transaction, pluginState, oldState, newState), + ...getUpdatedSourceProvenance(pluginState, transaction, oldState, adapters), +}); + +const mergeTextRanges = (ranges: TextRange[]) => + ranges + .sort((left, right) => left.from - right.from || left.to - right.to) + .reduce((merged, range) => { + const previous = merged.at(-1); + + if (previous && range.from <= previous.to) { + previous.to = Math.max(previous.to, range.to); + } else { + merged.push({ ...range }); + } + + return merged; + }, []); + +// A region reads as the file wrote it only until the first write lands in it. +const findLoadedSourceRanges = ( + oldState: EditorState, + transaction: Transaction, + writtenRanges: readonly TextRange[], + adapters: readonly SourceProjectionAdapter[], +) => { + const changedFrom = oldState.doc.content.findDiffStart(transaction.doc.content); + + if (changedFrom === null) { + return []; + } + + const changedTo = oldState.doc.content.findDiffEnd(transaction.doc.content)?.a; + const positions = + changedTo === undefined || changedTo === changedFrom ? [changedFrom] : [changedFrom, changedTo]; + + return positions.flatMap((position) => { + if (writtenRanges.some((range) => range.from <= position && position <= range.to)) { + return []; + } + + const loadedSource = findSourceProjectionLiteralSourceCommit( + oldState, + { from: position, to: position }, + adapters, + ); + + return loadedSource ? [loadedSource] : []; + }); +}; + +// Protected ranges map inward, so a deletion drops them and the escape can be spent deliberately, +// while written ranges map outward to take in what extends them. +const getUpdatedSourceProvenance = ( + { protectedRanges, session, writtenRanges }: SourceProjectionPluginState, + transaction: Transaction, + oldState: EditorState, + adapters: readonly SourceProjectionAdapter[], +): SourceProvenance => { + if (isHistoryTransaction(transaction)) { + return { protectedRanges: [], writtenRanges: [] }; + } + + if (!transaction.docChanged) { + return { protectedRanges, writtenRanges }; + } + + const { mapping } = transaction; + // A change that only moves content the document already held authors nothing, but its steps + // re-insert what they took, which the step maps alone read as text the session wrote. + const isRestructure = transaction.getMeta(SOURCE_PROJECTION_RESTRUCTURE_META) === true; + const written = writtenRanges.map((range) => ({ + from: mapping.map(range.from, -1), + to: mapping.map(range.to, 1), + })); + const loaded = protectedRanges.map((range) => ({ + from: mapping.map(range.from, 1), + to: mapping.map(range.to, -1), + })); + + if (!isRestructure) { + mapping.maps.forEach((stepMap, index) => { + const remaining = mapping.slice(index + 1); + + stepMap.forEach((_stepFrom, _stepTo, insertedFrom, insertedTo) => { + if (insertedFrom < insertedTo) { + written.push({ from: remaining.map(insertedFrom, -1), to: remaining.map(insertedTo, 1) }); + } + }); + }); + } + + const meta = getProjectionMeta(transaction); + + if (meta?.type === "commitAfterRestore" && meta.escapedRange) { + loaded.push(meta.escapedRange); + } + + // Text under an active projection is source the engine placed there, not source the file holds. + const loadedSources = + session || isRestructure + ? [] + : findLoadedSourceRanges(oldState, transaction, writtenRanges, adapters); + + for (const loadedSource of loadedSources) { + loaded.push({ from: mapping.map(loadedSource.from, 1), to: mapping.map(loadedSource.to, -1) }); + } + + return { + protectedRanges: mergeTextRanges(loaded.filter((range) => range.from < range.to)), + writtenRanges: mergeTextRanges(written), + }; +}; + +const applyProjectionSessionState = ( + transaction: Transaction, + pluginState: SourceProjectionPluginState, + oldState: EditorState, + newState: EditorState, +): ProjectionSessionState => { const meta = getProjectionMeta(transaction); if (meta?.type === "enter" || meta?.type === "enterFromUserEdit") { @@ -508,8 +716,7 @@ const applyProjectionTransaction = ( if ( transaction.docChanged && - (!isRangeInsideProjection(newState.selection, session) || - !isProjectionRangeFlatText(newState, session)) + (!isRangeInside(newState.selection, session) || !isProjectionRangeFlatText(newState, session)) ) { return { isLinkLabelHovered: false, @@ -663,7 +870,7 @@ const handleProjectionTextInput = ( return handleProjectionSourceTextInput(view, from, to, text, adapters); } - if (!isRangeInsideProjection({ from, to }, session)) { + if (!isRangeInside({ from, to }, session)) { return false; } @@ -755,7 +962,7 @@ const handleProjectionPaste = (view: EditorView, event: ClipboardEvent, slice?: const session = getSourceProjectionState(view.state).session; const { selection } = view.state; - if (!session || !isRangeInsideProjection(selection, session)) { + if (!session || !isRangeInside(selection, session)) { return false; } @@ -993,7 +1200,7 @@ const getDeletionRange = ( ): TextRange | null => { const { selection } = state; - if (!isRangeInsideProjection(selection, session)) { + if (!isRangeInside(selection, session)) { return null; } @@ -1085,7 +1292,7 @@ const createFinalizeProjectionTransaction = ( replacement: session.target.originalContent, replacementSize: session.target.originalContentSize, }; - const shouldSuppressProjectionAtSelection = isRangeInsideProjection(state.selection, session); + const shouldSuppressProjectionAtSelection = isRangeInside(state.selection, session); const shouldMapCrossingTextSelection = state.selection instanceof TextSelection && state.selection.from < session.to && @@ -1110,6 +1317,7 @@ const createFinalizeProjectionTransaction = ( return createRestoreBeforeCommitTransaction({ commitSelection, + consumedEscape: decodeSourceProjectionEscapes(source) !== source, replacement: parsed.replacement, restoreSelection, session, @@ -1121,6 +1329,7 @@ const createFinalizeProjectionTransaction = ( interface RestoreBeforeCommitTransactionInput { commitSelection: { anchor: number; head: number } | null; + consumedEscape: boolean; replacement: Slice; restoreSelection: { anchor: number; head: number } | null; session: ProjectionSession; @@ -1131,6 +1340,7 @@ interface RestoreBeforeCommitTransactionInput { const createRestoreBeforeCommitTransaction = ({ commitSelection, + consumedEscape, replacement, restoreSelection, session, @@ -1142,6 +1352,7 @@ const createRestoreBeforeCommitTransaction = ({ source === session.target.originalSource ? null : { + consumedEscape, from: session.from, replacement, selectionAnchor: commitSelection?.anchor ?? null, @@ -1226,6 +1437,9 @@ const createCommitAfterRestoreTransaction = ( transaction .setStoredMarks([]) .setMeta(leafdownSourceProjectionPluginKey, { + escapedRange: pendingCommit.consumedEscape + ? { from: pendingCommit.from, to: pendingCommit.from + pendingCommit.replacement.size } + : null, suppressedSelection: pendingCommit.suppressedSelection, type: "commitAfterRestore", } satisfies ProjectionMeta) @@ -1260,8 +1474,8 @@ const replaceProjectionRange = ( replacement: Slice, ) => transaction.replace(from, to, replacement); -const isRangeInsideProjection = (range: TextRange, session: ProjectionSession) => - session.from <= range.from && range.to <= session.to; +const isRangeInside = (range: TextRange, bounds: TextRange) => + bounds.from <= range.from && range.to <= bounds.to; // The projected range is modelled as flat literal text, and `getTextBetween` reads every leaf // node back as a newline. A hard break is the only node that survives that reading, since it diff --git a/src/features/editor/tests/markdownCompatibility.test.tsx b/src/features/editor/tests/markdownCompatibility.test.tsx index 0884bbe..6a27c6b 100644 --- a/src/features/editor/tests/markdownCompatibility.test.tsx +++ b/src/features/editor/tests/markdownCompatibility.test.tsx @@ -417,7 +417,7 @@ describe("Typed link source", () => { ]; it.each(typedLinkSourceFixtures)( - "keeps a typed $name literal when it ends the paragraph", + "keeps a typed $name literal while the caret is still on it", async ({ expected, typed }) => { const mounted = await mountEditor(""); @@ -429,14 +429,14 @@ describe("Typed link source", () => { ); it.each(typedLinkSourceFixtures)( - "keeps a typed $name literal when a space follows it", - async ({ expected, typed }) => { + "writes a typed $name as the link it describes once a space follows it", + async ({ typed }) => { const mounted = await mountEditor(""); setSelectionAtDocumentEnd(mounted.view); typeText(mounted.view, `${typed} `); - expect(mounted.getMarkdown()).toBe(`${expected} \n`); + expect(mounted.getMarkdown()).toBe(`${typed} \n`); }, ); diff --git a/src/features/editor/tests/sourceProjectionTypedLink.test.tsx b/src/features/editor/tests/sourceProjectionTypedLink.test.tsx new file mode 100644 index 0000000..b6689c0 --- /dev/null +++ b/src/features/editor/tests/sourceProjectionTypedLink.test.tsx @@ -0,0 +1,384 @@ +import { describe, expect, it } from "vitest"; + +import { TEXT_PLAIN_MIME_TYPE } from "@/lib/mime"; +import { EDITOR_TEST_ROOT_CLASS_NAME } from "@/test/factories/editor"; +import { dispatchClipboardEvent } from "@/test/utils/events"; +import { setupMilkdownEditorMount, type MountedMilkdownEditor } from "@/test/utils/milkdown"; +import { + getEditorTextContent, + getEditorTextPosition, + runKeyDownHandlers, + setSelectionAtDocumentEnd, + setTextSelection, + typeText, +} from "@/test/utils/prosemirror"; +import { enterProjection } from "@/test/utils/sourceProjection"; + +import { runEditorCommand } from "../commands"; +import { hasActiveSourceProjection } from "../plugins/sourceProjection"; + +const mountProjectionEditor = setupMilkdownEditorMount({ + rootClassName: EDITOR_TEST_ROOT_CLASS_NAME, +}); + +const typedLinkSourceFixtures = [ + { name: "inline link", target: "./test.html", typed: "[test link](./test.html)" }, + { name: "autolink literal", target: "https://example.com", typed: "https://example.com" }, + { name: "URI autolink", target: "https://example.com", typed: "" }, +]; + +const getLinkTargets = (mounted: MountedMilkdownEditor) => + Array.from(mounted.view.dom.querySelectorAll("a"), (link) => link.getAttribute("href")); + +const dispatchDropEvent = (target: EventTarget, moved: boolean) => { + const event = new Event("drop", { bubbles: true, cancelable: true }); + + Object.assign(event, { + clientX: 0, + clientY: 0, + ctrlKey: !moved, + dataTransfer: { + dropEffect: moved ? "move" : "copy", + effectAllowed: "all", + getData: () => "", + types: [], + }, + metaKey: false, + }); + + target.dispatchEvent(event); +}; + +const typeAtDocumentEnd = (mounted: MountedMilkdownEditor, text: string) => { + setSelectionAtDocumentEnd(mounted.view); + typeText(mounted.view, text); +}; + +describe("typed link source", () => { + it.each(typedLinkSourceFixtures)( + "commits a typed $name when the caret leaves it", + async ({ target, typed }) => { + const mounted = await mountProjectionEditor("start"); + + typeAtDocumentEnd(mounted, ` ${typed}`); + + expect(getLinkTargets(mounted)).toEqual([]); + + setTextSelection(mounted.view, 1); + + expect(getLinkTargets(mounted)).toEqual([target]); + expect(mounted.getMarkdown()).toBe(`start ${typed}\n`); + }, + ); + + it.each(typedLinkSourceFixtures)( + "commits a typed $name when the sentence continues past it", + async ({ target, typed }) => { + const mounted = await mountProjectionEditor("start"); + + typeAtDocumentEnd(mounted, ` ${typed} tail`); + + expect(getLinkTargets(mounted)).toEqual([target]); + expect(mounted.getMarkdown()).toBe(`start ${typed} tail\n`); + }, + ); + + // The parser reads `https://example.com/path.` as a link that stops before the dot. + it("commits a typed URL once, at its full length", async () => { + const mounted = await mountProjectionEditor("start"); + + typeAtDocumentEnd(mounted, " https://example.com/path.html"); + setTextSelection(mounted.view, 1); + + expect(getLinkTargets(mounted)).toEqual(["https://example.com/path.html"]); + expect(mounted.getMarkdown()).toBe("start https://example.com/path.html\n"); + }); + + it.each([ + { name: "Enter", shiftKey: false }, + { name: "Shift+Enter", shiftKey: true }, + ])("commits typed source through $name", async ({ shiftKey }) => { + const mounted = await mountProjectionEditor("start"); + + typeAtDocumentEnd(mounted, " [test link](./test.html)"); + runKeyDownHandlers(mounted.view, "Enter", { shiftKey }); + typeText(mounted.view, "tail"); + + expect(getLinkTargets(mounted)).toEqual(["./test.html"]); + expect(mounted.getMarkdown()).toBe( + shiftKey + ? "start [test link](./test.html)\\\ntail\n" + : "start [test link](./test.html)\n\ntail\n", + ); + }); + + it.each([ + { name: "backslash-escaped source", typed: "\\[test link](./test.html)" }, + { name: "incomplete source", typed: "[test link] (./test.html)" }, + ])("leaves $name literal", async ({ typed }) => { + const mounted = await mountProjectionEditor("start"); + + typeAtDocumentEnd(mounted, ` ${typed}`); + setTextSelection(mounted.view, 1); + + expect(getLinkTargets(mounted)).toEqual([]); + expect(getEditorTextContent(mounted)).toBe(`start ${typed}`); + }); + + it("keeps source an escape in projection wrote literal through later caret moves", async () => { + const mounted = await mountProjectionEditor("[test link](./test.html) tail"); + + enterProjection(mounted, "a"); + setTextSelection(mounted.view, getEditorTextPosition(mounted, "[test link](./test.html)")); + typeText(mounted.view, "\\"); + setSelectionAtDocumentEnd(mounted.view); + + expect(getLinkTargets(mounted)).toEqual([]); + + setTextSelection(mounted.view, 3); + setSelectionAtDocumentEnd(mounted.view); + + expect(getLinkTargets(mounted)).toEqual([]); + expect(mounted.getMarkdown()).toBe("\\[test link](./test.html) tail\n"); + }); + + it("keeps source an escape in projection wrote literal when it is edited inside", async () => { + const mounted = await mountProjectionEditor("[test link](./test.html) tail"); + + enterProjection(mounted, "a"); + setTextSelection(mounted.view, getEditorTextPosition(mounted, "[test link](./test.html)")); + typeText(mounted.view, "\\"); + setSelectionAtDocumentEnd(mounted.view); + + const linkStart = getEditorTextPosition(mounted, "[test link](./test.html)"); + + setTextSelection(mounted.view, linkStart + 1); + typeText(mounted.view, "X"); + setSelectionAtDocumentEnd(mounted.view); + + expect(getEditorTextContent(mounted)).toBe("[Xtest link](./test.html) tail"); + expect(getLinkTargets(mounted)).toEqual([]); + }); + + it("keeps source the file escaped literal when the caret visits it", async () => { + const mounted = await mountProjectionEditor("\\[test link](./test.html) tail"); + + setTextSelection(mounted.view, 12); + setSelectionAtDocumentEnd(mounted.view); + + expect(getLinkTargets(mounted)).toEqual([]); + expect(mounted.getMarkdown()).toBe("\\[test link](./test.html) tail\n"); + }); + + it("keeps source the file escaped literal when the paragraph is edited elsewhere", async () => { + const mounted = await mountProjectionEditor("\\[test link](./test.html) tail"); + + setTextSelection(mounted.view, 1); + typeText(mounted.view, "edit "); + setSelectionAtDocumentEnd(mounted.view); + + expect(getLinkTargets(mounted)).toEqual([]); + expect(mounted.getMarkdown()).toBe("edit \\[test link](./test.html) tail\n"); + }); + + it("keeps source the file escaped literal when it is edited inside", async () => { + const mounted = await mountProjectionEditor("\\[test link](./test.html) tail"); + + setTextSelection(mounted.view, 6); + typeText(mounted.view, "ed"); + setSelectionAtDocumentEnd(mounted.view); + + expect(getLinkTargets(mounted)).toEqual([]); + expect(mounted.getMarkdown()).toBe("\\[tested link](./test.html) tail\n"); + }); + + // Auto-pairing around a selection is one change that writes on both sides of it. + it("keeps source the file escaped literal when one change writes into it twice", async () => { + const mounted = await mountProjectionEditor("\\[test link](./test.html) tail"); + + mounted.view.dispatch(mounted.view.state.tr.insertText("(", 6).insertText(")", 11)); + setSelectionAtDocumentEnd(mounted.view); + + expect(getLinkTargets(mounted)).toEqual([]); + expect(mounted.getMarkdown()).toBe("\\[test( lin)k](./test.html) tail\n"); + }); + + it.each([ + { moved: true, name: "moved" }, + { moved: false, name: "copied" }, + ])("keeps source the file escaped literal when a word is $name into it", async ({ moved }) => { + const mounted = await mountProjectionEditor("\\[test link](./test.html) tail"); + const { view } = mounted; + + setTextSelection(view, 26, 30); + view.posAtCoords = () => ({ inside: -1, pos: 7 }); + view.dragging = { move: moved, slice: view.state.doc.slice(26, 30) }; + dispatchDropEvent(view.dom, moved); + setSelectionAtDocumentEnd(view); + + expect(getLinkTargets(mounted)).toEqual([]); + expect(mounted.getMarkdown()).toBe( + moved ? "\\[test taillink](./test.html) \n" : "\\[test taillink](./test.html) tail\n", + ); + }); + + // Restructuring a table rebuilds every cell in it, including the ones the session never touched. + describe("with source the file escaped in a table cell", () => { + const mountTableEditor = () => + mountProjectionEditor( + "| a | b |\n| --- | --- |\n| \\[test link](./test.html) tail | c |\n| d | e |\n| f | g |", + ); + const escapedCellRow = "| \\[test link](./test.html) tail | c |"; + + it.each(["format.table.moveRowDown", "format.table.moveColumnRight"] as const)( + "keeps it literal through %s from the cell", + async (commandId) => { + const mounted = await mountTableEditor(); + + setTextSelection(mounted.view, 20); + await runEditorCommand(mounted.editor, commandId); + setSelectionAtDocumentEnd(mounted.view); + + expect(getLinkTargets(mounted)).toEqual([]); + }, + ); + + it("keeps it literal when the caret returns to the cell later", async () => { + const mounted = await mountTableEditor(); + + setTextSelection(mounted.view, 20); + await runEditorCommand(mounted.editor, "format.table.addRowBelow"); + setSelectionAtDocumentEnd(mounted.view); + setTextSelection(mounted.view, 20); + setSelectionAtDocumentEnd(mounted.view); + + expect(getLinkTargets(mounted)).toEqual([]); + expect(mounted.getMarkdown()).toContain(escapedCellRow); + }); + + it("keeps it literal when another row is moved", async () => { + const mounted = await mountTableEditor(); + + setSelectionAtDocumentEnd(mounted.view); + await runEditorCommand(mounted.editor, "format.table.moveRowUp"); + setTextSelection(mounted.view, 20); + setSelectionAtDocumentEnd(mounted.view); + + expect(getLinkTargets(mounted)).toEqual([]); + expect(mounted.getMarkdown()).toContain(escapedCellRow); + }); + + it("still commits source typed into a cell after the table is restructured", async () => { + const mounted = await mountTableEditor(); + + setTextSelection(mounted.view, 20); + await runEditorCommand(mounted.editor, "format.table.addRowBelow"); + setSelectionAtDocumentEnd(mounted.view); + typeText(mounted.view, " [typed](./typed.html) "); + + expect(getLinkTargets(mounted)).toEqual(["./typed.html"]); + }); + }); + + it("commits source the file escaped once it is replaced outright", async () => { + const mounted = await mountProjectionEditor("\\[test link](./test.html) tail"); + + mounted.view.dispatch(mounted.view.state.tr.delete(1, 25)); + setTextSelection(mounted.view, 1); + typeText(mounted.view, "[test link](./test.html)"); + setSelectionAtDocumentEnd(mounted.view); + + expect(getLinkTargets(mounted)).toEqual(["./test.html"]); + expect(mounted.getMarkdown()).toBe("[test link](./test.html) tail\n"); + }); + + it("commits source written by hand around words the file already held", async () => { + const mounted = await mountProjectionEditor("test link tail", { + autoPairBracketsAndQuotes: false, + }); + + setTextSelection(mounted.view, 1); + typeText(mounted.view, "["); + setTextSelection(mounted.view, 11); + typeText(mounted.view, "](./test.html)"); + setSelectionAtDocumentEnd(mounted.view); + + expect(getLinkTargets(mounted)).toEqual(["./test.html"]); + expect(mounted.getMarkdown()).toBe("[test link](./test.html) tail\n"); + }); + + it("reverts a commit through undo and leaves it reverted", async () => { + const mounted = await mountProjectionEditor("start"); + + typeAtDocumentEnd(mounted, " [test link](./test.html) tail"); + + expect(getLinkTargets(mounted)).toEqual(["./test.html"]); + expect(await runEditorCommand(mounted.editor, "edit.undo")).toBe(true); + + setTextSelection(mounted.view, 1); + + expect(getLinkTargets(mounted)).toEqual([]); + expect(mounted.getMarkdown()).toBe("start \\[test link](./test.html)\n"); + }); + + it("leaves typed source in a code block literal", async () => { + const mounted = await mountProjectionEditor("```\ncode\n```"); + + typeAtDocumentEnd(mounted, " [test link](./test.html)"); + setTextSelection(mounted.view, 1); + + expect(getLinkTargets(mounted)).toEqual([]); + expect(mounted.getMarkdown()).toBe("```\ncode [test link](./test.html)\n```\n"); + }); + + it("leaves typed source split by a line ending literal", async () => { + const mounted = await mountProjectionEditor("start"); + + typeAtDocumentEnd(mounted, " [test link"); + runKeyDownHandlers(mounted.view, "Enter", { shiftKey: true }); + typeText(mounted.view, "](./test.html) tail"); + + expect(getLinkTargets(mounted)).toEqual([]); + expect(mounted.getMarkdown()).toBe("start \\[test link\\\n]\\(./test.html) tail\n"); + }); + + it.each(typedLinkSourceFixtures)( + "reaches the same document typing or pasting a $name", + async ({ typed }) => { + const typedEditor = await mountProjectionEditor("start"); + + typeAtDocumentEnd(typedEditor, ` ${typed}`); + setTextSelection(typedEditor.view, 1); + + const pastedEditor = await mountProjectionEditor("start"); + + typeAtDocumentEnd(pastedEditor, " "); + dispatchClipboardEvent(pastedEditor.view.dom, "paste", { [TEXT_PLAIN_MIME_TYPE]: typed }); + setTextSelection(pastedEditor.view, 1); + + expect(typedEditor.view.state.doc.toString()).toBe(pastedEditor.view.state.doc.toString()); + expect(typedEditor.getMarkdown()).toBe(pastedEditor.getMarkdown()); + }, + ); + + it("projects a committed link as the source it was typed as", async () => { + const mounted = await mountProjectionEditor("start"); + + typeAtDocumentEnd(mounted, " [test link](./test.html)"); + setTextSelection(mounted.view, 1); + setTextSelection(mounted.view, 9); + + expect(getEditorTextContent(mounted)).toBe("start [test link](./test.html)"); + }); + + it("projects the link the caret lands in while committing the run it left", async () => { + const mounted = await mountProjectionEditor("[first](./first.md) start"); + + typeAtDocumentEnd(mounted, " [test link](./test.html)"); + setTextSelection(mounted.view, 3); + + expect(hasActiveSourceProjection(mounted.view.state)).toBe(true); + expect(getEditorTextContent(mounted)).toBe("[first](./first.md) start test link"); + expect(mounted.getMarkdown()).toBe("[first](./first.md) start [test link](./test.html)\n"); + }); +}); diff --git a/src/features/editor/utils/sourceProjectionAdapters.ts b/src/features/editor/utils/sourceProjectionAdapters.ts index 08d829f..614e673 100644 --- a/src/features/editor/utils/sourceProjectionAdapters.ts +++ b/src/features/editor/utils/sourceProjectionAdapters.ts @@ -95,6 +95,10 @@ export interface SourceProjectionInsertionCandidate< target: TTarget; } +export interface LiteralSourceCommit extends TextRange { + replacement: Slice; +} + export interface SourceProjectionAdapter< TTarget extends SourceProjectionTarget = SourceProjectionTarget, > { @@ -111,6 +115,7 @@ export interface SourceProjectionAdapter< position: number, text: string, ): SourceProjectionInsertionCandidate | null; + findLiteralSourceCommit?(state: EditorState, range: TextRange): LiteralSourceCommit | null; findTarget(state: EditorState): TTarget | null; getPresentation(target: TTarget, source: string): SourceProjectionPresentation; mapSelectionFromSource( @@ -449,7 +454,7 @@ const getProjectionMarksFromInlineNode = ( }); }; -const isPlainTextRange = (state: EditorState, from: number, to: number) => { +export const isPlainTextRange = (state: EditorState, from: number, to: number) => { let isPlain = true; state.doc.nodesBetween(from, to, (node) => { @@ -1161,3 +1166,19 @@ export const findSourceProjectionInsertionCandidate = ( return null; }; + +export const findSourceProjectionLiteralSourceCommit = ( + state: EditorState, + range: TextRange, + adapters: readonly SourceProjectionAdapter[], +): LiteralSourceCommit | null => { + for (const adapter of adapters) { + const commit = adapter.findLiteralSourceCommit?.(state, range) ?? null; + + if (commit) { + return commit; + } + } + + return null; +}; diff --git a/src/features/editor/utils/sourceProjectionLinkAdapter.ts b/src/features/editor/utils/sourceProjectionLinkAdapter.ts index 2f4e61e..90c2921 100644 --- a/src/features/editor/utils/sourceProjectionLinkAdapter.ts +++ b/src/features/editor/utils/sourceProjectionLinkAdapter.ts @@ -10,8 +10,10 @@ import { getCandidateMarksAtSelection, getMarkRangeAtSelection } from "./marks"; import { createLiteralSourceProjectionSlice, decodeSourceProjectionEscapes, + isPlainTextRange, mapLiteralSourceOffsetToDocument, shouldHandleInlineObjectTextInput, + type LiteralSourceCommit, type SourceProjectionAdapter, type SourceProjectionParseResult, type SourceProjectionPresentationSpan, @@ -25,12 +27,13 @@ import { } from "./sourceProjectionFootnoteReferenceSyntax"; import { createLinkSourceMap, + findLinkSourceBounds, isAtomicLinkSegment, mapLinkDocumentPositionToSource, mapLinkSourcePositionToDocument, type LinkSourceMap, } from "./sourceProjectionLinkSyntax"; -import { getTextBetween } from "./textRanges"; +import { getTextBetween, type TextRange } from "./textRanges"; const LINK_ADAPTER_ID = "link"; const LINK_MARK_NAME = "link"; @@ -286,6 +289,57 @@ const parseLinkSource = ( }; }; +// Markers further from the caret than the radius, or in a form the pattern misses, leave their +// source literal text. +const LINK_SOURCE_HINT_PATTERN = /\]\(|<|:\/\/|www\.|@/u; +const LINK_SOURCE_HINT_RADIUS = 1000; + +const findLiteralLinkSourceCommit = ( + state: EditorState, + range: TextRange, + parser: Parser, + remark: RemarkParser, +): LiteralSourceCommit | null => { + const linkType = state.schema.marks[LINK_MARK_NAME]; + const $position = state.doc.resolve(range.from); + const textBlock = $position.parent; + + if (!linkType || !textBlock.isTextblock || !textBlock.type.allowsMarkType(linkType)) { + return null; + } + + const text = getTextBetween(textBlock, 0, textBlock.content.size); + const start = $position.start(); + const offset = range.from - start; + + if ( + !LINK_SOURCE_HINT_PATTERN.test( + text.slice(Math.max(offset - LINK_SOURCE_HINT_RADIUS, 0), offset + LINK_SOURCE_HINT_RADIUS), + ) + ) { + return null; + } + + const bounds = findLinkSourceBounds(remark, text, { + from: offset, + to: range.to - start, + }); + + if (!bounds) { + return null; + } + + const commitRange = { from: start + bounds.from, to: start + bounds.to }; + + if (!isPlainTextRange(state, commitRange.from, commitRange.to)) { + return null; + } + + const parsed = parseLinkSource(state, text.slice(bounds.from, bounds.to), parser, remark, []); + + return parsed ? { ...commitRange, replacement: parsed.replacement } : null; +}; + // Every node in a label stands for one document position, so the text has to spend one // character on each of them to stay aligned with the source map's document offsets. const getLinkContentText = (target: LinkSourceProjectionTarget) => @@ -603,6 +657,8 @@ export const createLinkSourceProjectionAdapter = ({ canCopySelectionSemantically: (selection, session, parsed) => isLinkSelectionSemantic(selection, session, createLinkSourceMap(remark, parsed.source)), createEnterTransaction: createEnterLinkProjectionTransaction, + findLiteralSourceCommit: (state, range) => + findLiteralLinkSourceCommit(state, range, parser, remark), findTarget: (state) => findLinkTarget(state, serializer, remark), getPresentation: (linkTarget, source) => { const parsedMap = createLinkSourceMap(remark, source); diff --git a/src/features/editor/utils/sourceProjectionLinkSyntax.ts b/src/features/editor/utils/sourceProjectionLinkSyntax.ts index a7341a8..533fbc9 100644 --- a/src/features/editor/utils/sourceProjectionLinkSyntax.ts +++ b/src/features/editor/utils/sourceProjectionLinkSyntax.ts @@ -6,6 +6,7 @@ import { getFootnoteReferenceSourceBounds, withFootnoteDefinitions, } from "./sourceProjectionFootnoteReferenceSyntax"; +import type { TextRange } from "./textRanges"; interface LinkSourceSegmentBase { className: string; @@ -409,6 +410,37 @@ export const createLinkSourceMap = (remark: RemarkParser, source: string): LinkS }; }; +const findLinkNodeBounds = (node: MarkdownNode, range: TextRange): TextRange | null => { + const position = getMarkdownPosition(node); + + if (node.type === "link" && position && position.from <= range.from && range.to <= position.to) { + return position; + } + + for (const child of node.children ?? []) { + const bounds = findLinkNodeBounds(child, range); + + if (bounds) { + return bounds; + } + } + + return null; +}; + +// Text that was never a link carries no mark whose range could bound it. +export const findLinkSourceBounds = ( + remark: RemarkParser, + text: string, + range: TextRange, +): TextRange | null => { + try { + return findLinkNodeBounds(remark.parse(text) as MarkdownNode, range); + } catch { + return null; + } +}; + export const mapLinkDocumentPositionToSource = ( position: number, map: LinkSourceMap,