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: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ sections and version names that match the application package version.
- Stop the context popup from forcing an extra caret move from right-click
coordinates.
- Position context popups around the active selection.
- Keep foreign Markdown markers outside active inline source projections.
- Keep foreign Markdown markers outside active source projections.
- Preserve empty link destinations and GFM link titles while editing projected
link source.

Expand Down
5 changes: 2 additions & 3 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,5 @@ Decisions use a lightweight ADR format:
- [Milkdown API Plan](./spikes/milkdown-api-plan.md): package, import,
lifecycle, highlighting, HTML safety, settings, and follow-up issue guidance
for the Milkdown editor foundation.
- [Inline Source Projection](./spikes/inline-source-projection.md): architecture
evaluation and follow-up issue guidance for seamless editable inline Markdown
markers.
- [Source Projection](./spikes/source-projection.md): architecture, adapter
contract, and original spike guidance for seamless editable Markdown source.
25 changes: 25 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,31 @@ Syntax highlighting uses bundled Shiki assets through Milkdown highlighting
plugins. Raw Markdown HTML is preserved as text-like editor content instead of
being rendered as browser DOM.

### Source Projection

Source projection temporarily exposes a supported Markdown object as unmarked,
editable document text while Milkdown's canonical model remains the resting
document representation.

The shared plugin engine owns the active session, projected range, exact original
ProseMirror `Slice`, projection-local undo and redo, transaction metadata,
dirty-state integration, serialization finalization, and the native-history
restore-before-commit sequence. A clean session restores the immutable original
slice exactly. An edited session first restores that slice outside history, then
commits the adapter-produced replacement as the native history change. Invalid
source is committed literally so projected characters are never discarded.

Registered object adapters own target discovery and precedence, source
generation, entry and clean-restoration transforms, validation and rehydration,
presentation spans, and selection mapping. The current mark adapter supports
strong, emphasis, strikethrough, inline code, links, and autolinks. Future rich
inline wrappers and atomic inline nodes extend the adapter boundary independently;
the shared lifecycle must not acquire mark-specific syntax assumptions.

Marker presentation is a separate capability. Decorations may style active
source, but projected Markdown remains real ProseMirror document text rather than
widget or NodeView input state.

## Backend Responsibilities

The Rust backend manages:
Expand Down
2 changes: 1 addition & 1 deletion docs/specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ The editor is a unified hybrid Markdown surface. Behavior is governed by renderi
- Link and autolink projection exposes their source directly in the document;
links preserve their label, target, optional title, and compatible uniform
outer inline formatting.
- Selection may cross into or out of an active inline projection. In that case,
- Selection may cross into or out of an active source projection. In that case,
the projection finalizes and preserves the user's selection range.
- Normal click places the caret in a link; `Mod+click` opens it.
- Footnote references render inline and expose editable raw Markdown syntax near
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
# Inline Source Projection Spike
# Source Projection

Issue: https://github.com/Azganoth/leafdown/issues/44

Date checked: 2026-06-06
Date checked: 2026-07-12

## Question

Which ProseMirror/Milkdown architecture should Leafdown use to expose inline
Which ProseMirror/Milkdown architecture should Leafdown use to expose
Markdown marker characters directly in the editor surface?

The target behavior is seamless source projection: when the caret enters a
Expand All @@ -15,20 +15,20 @@ ordinary editable editor content. The caret should move through those marker
characters, deleting only part of a marker should be possible, and the editor
should recover without data loss when the edited syntax becomes invalid.

## Context
## Original Context

Leafdown currently uses Milkdown Kit directly through a Leafdown-owned React
wrapper. The installed editor packages checked for this spike are:
Leafdown uses Milkdown Kit directly through a Leafdown-owned React wrapper. The
installed editor packages checked for this document are:

- `@milkdown/kit@7.21.1`
- `@milkdown/plugin-highlight@7.21.1`
- `@milkdown/kit@7.21.2`
- `@milkdown/plugin-highlight@7.21.2`
- `prosemirror-view@1.41.8`
- `prosemirror-model@1.25.7`
- `prosemirror-state@1.4.4`
- `prosemirror-transform@1.12.0`

The current marker implementation exposes inline and source-oriented Markdown
through detached input controls:
At the time of the original spike, marker implementation exposed inline and
source-oriented Markdown through detached input controls:

- inline marks use widget decorations with `.leafdown-source-edit` inputs;
- footnote references and raw HTML use widget decorations with source inputs;
Expand All @@ -50,7 +50,35 @@ Relevant source-of-truth docs:
foundation; programmatic housekeeping transactions should be marked out of
history; `getMarkdown()` is the serialization bridge.

## Probe Results
## Current Architecture

Issue #63 generalized the original mark-specific implementation into a shared
source-projection session engine with object-specific adapters.

- The plugin owns the active session, projected range, projection-local history,
transaction metadata, dirty-state integration, finalization, and the
restore-before-commit native-history bridge.
- Every target stores its exact original ProseMirror `Slice` as immutable session
data. Clean finalization restores that content exactly; edited finalization
restores it before committing the adapter-produced replacement.
- Registered adapters own target discovery and precedence, source generation,
entry and clean-restoration transforms, validation and rehydration,
presentation spans, and selection mapping.
- Active source is unmarked, editable document text. Invalid source commits as
literal document text so no projected character is lost.
- The first adapter preserves the existing mark behavior for strong, emphasis,
strikethrough, inline code, links, and autolinks.
- A link-wrapper adapter for #58 and an atomic-node adapter for #60 can extend
the same engine independently. Neither feature depends on the other.

The implementation is split between
`src/features/editor/plugins/sourceProjection.ts`, the shared lifecycle engine;
`src/features/editor/utils/sourceProjectionAdapters.ts`, the adapter contract,
registry, and current mark adapter; and
`src/features/editor/utils/sourceProjectionSyntax.ts`, the current mark syntax
parser and source builder. Marker presentation remains a separate capability.

## Original Probe Results

Focused probes were added in
`src/features/editor/plugins/sourceProjectionSpike.test.tsx`.
Expand Down Expand Up @@ -187,7 +215,7 @@ Undo/redo is the highest-risk part of the architecture. The follow-up issue
should require an explicit projection-session history design before broadening
syntax support.

## First Implementation Slice
## Original Implementation Slice

Implement strong and emphasis only.

Expand Down Expand Up @@ -248,14 +276,14 @@ When implementation lands, update:
object-specific marker decision. The spike recommendation does not require a
decision change.

## Follow-Up Issue Body
## Original Follow-Up Issue Body

Title: `Implement inline source projection for strong and emphasis`
Title: `Implement source projection for strong and emphasis`

```md
### Summary

Implement the first seamless inline Markdown source projection slice for strong
Implement the first seamless Markdown source projection slice for strong
and emphasis markers.

Leafdown should keep Milkdown/ProseMirror as the editor engine. This issue adds
Expand Down
2 changes: 1 addition & 1 deletion src/features/editor/commands/editing/clipboard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,8 @@ describe("editor clipboard commands", () => {
await expect(paste(markdownEditor.editor, "markdown")).resolves.toBe(true);

expect(markdownEditor.view.dom).toHaveTextContent("**Bold**");
expect(markdownEditor.view.dom.querySelector("strong")).toBeInTheDocument();
expect(markdownEditor.getMarkdown()).toBe("**Bold**\n");
expect(markdownEditor.view.dom.querySelector("strong")).toBeInTheDocument();
});

it("pastes rich text from clipboard HTML when available", async () => {
Expand Down
14 changes: 7 additions & 7 deletions src/features/editor/commands/editing/clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ import { markdownToSlice } from "@milkdown/kit/utils";
import { TEXT_HTML_MIME_TYPE, TEXT_PLAIN_MIME_TYPE } from "@/lib/mime";

import {
hasActiveInlineSourceProjection,
pasteIntoInlineSourceProjection,
} from "../../plugins/inlineSourceProjection";
hasActiveSourceProjection,
pasteIntoSourceProjection,
} from "../../plugins/sourceProjection";
import { getEditorView, runProseMirrorCommand } from "../../utils/milkdown";

interface ClipboardPayload {
Expand Down Expand Up @@ -134,13 +134,13 @@ const pasteRichText = async (view: EditorView) => {
return pasteText(view);
};

const pasteInlineSourceProjectionText = async (view: EditorView) => {
const pasteSourceProjectionText = async (view: EditorView) => {
const text = await readClipboardText();
if (text === null) {
return false;
}

return pasteIntoInlineSourceProjection(view, text);
return pasteIntoSourceProjection(view, text);
};

/* Commands */
Expand All @@ -165,8 +165,8 @@ export const cutSelection = async (view: EditorView) => {

export const paste = async (editor: Editor, format: ClipboardPasteFormat) => {
const view = getEditorView(editor);
if (hasActiveInlineSourceProjection(view.state)) {
return pasteInlineSourceProjectionText(view);
if (hasActiveSourceProjection(view.state)) {
return pasteSourceProjectionText(view);
}

switch (format) {
Expand Down
26 changes: 13 additions & 13 deletions src/features/editor/commands/editing/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@ import type { EditorState } from "@milkdown/kit/prose/state";
import type { EditorView } from "@milkdown/kit/prose/view";

import {
canDeferInlineSourceProjectionToNativeHistory,
canRedoInlineSourceProjection,
canUndoInlineSourceProjection,
hasActiveInlineSourceProjection,
redoInlineSourceProjection,
undoInlineSourceProjection,
} from "../../plugins/inlineSourceProjection";
canDeferSourceProjectionToNativeHistory,
canRedoSourceProjection,
canUndoSourceProjection,
hasActiveSourceProjection,
redoSourceProjection,
undoSourceProjection,
} from "../../plugins/sourceProjection";
import { runProseMirrorCommand } from "../../utils/milkdown";

const canUseHistory = (
Expand All @@ -24,28 +24,28 @@ const canUseHistory = (
) => {
const nativeHistoryDepth = getNativeHistoryDepth(state);

if (!hasActiveInlineSourceProjection(state)) {
if (!hasActiveSourceProjection(state)) {
return nativeHistoryDepth > 0;
}

return (
canUseProjectionHistory(state) ||
(canDeferInlineSourceProjectionToNativeHistory(state) && nativeHistoryDepth > 0)
(canDeferSourceProjectionToNativeHistory(state) && nativeHistoryDepth > 0)
);
};

/* Commands */

export const undo = (view: EditorView) =>
undoInlineSourceProjection(view) || runProseMirrorCommand(view, milkdownUndo);
undoSourceProjection(view) || runProseMirrorCommand(view, milkdownUndo);

export const redo = (view: EditorView) =>
redoInlineSourceProjection(view) || runProseMirrorCommand(view, milkdownRedo);
redoSourceProjection(view) || runProseMirrorCommand(view, milkdownRedo);

/* State */

export const canUndo = (state: EditorState) =>
canUseHistory(state, canUndoInlineSourceProjection, undoDepth);
canUseHistory(state, canUndoSourceProjection, undoDepth);

export const canRedo = (state: EditorState) =>
canUseHistory(state, canRedoInlineSourceProjection, redoDepth);
canUseHistory(state, canRedoSourceProjection, redoDepth);
16 changes: 8 additions & 8 deletions src/features/editor/commands/formatting/inline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ import type { EditorView } from "@milkdown/kit/prose/view";
import { isNonNullish } from "@/lib/predicates";

import {
finalizeInlineSourceProjection,
hasActiveInlineSourceProjection,
} from "../../plugins/inlineSourceProjection";
finalizeSourceProjection,
hasActiveSourceProjection,
} from "../../plugins/sourceProjection";
import {
getCandidateMarksAtSelection,
getMarkRangeAtSelection,
Expand Down Expand Up @@ -108,8 +108,8 @@ const CLEARABLE_INLINE_MARK_NAMES = [
] as const;

const toggleInlineFormatting = (view: EditorView, markName: InlineMarkName) => {
if (hasActiveInlineSourceProjection(view.state)) {
finalizeInlineSourceProjection(view);
if (hasActiveSourceProjection(view.state)) {
finalizeSourceProjection(view);
}

const markType = getMarkType(view.state, markName);
Expand Down Expand Up @@ -154,8 +154,8 @@ export const toggleStrikethrough = (view: EditorView) =>
export const toggleInlineCode = (view: EditorView) => toggleInlineFormatting(view, "inlineCode");

export const clearInlineFormat = (view: EditorView) => {
if (hasActiveInlineSourceProjection(view.state)) {
finalizeInlineSourceProjection(view);
if (hasActiveSourceProjection(view.state)) {
finalizeSourceProjection(view);
}

const { selection } = view.state;
Expand Down Expand Up @@ -207,7 +207,7 @@ export const clearInlineFormat = (view: EditorView) => {
/* State */

export const canClearInlineFormat = (state: EditorState) => {
if (hasActiveInlineSourceProjection(state)) {
if (hasActiveSourceProjection(state)) {
return true;
}

Expand Down
24 changes: 14 additions & 10 deletions src/features/editor/components/MilkdownEditor.css
Original file line number Diff line number Diff line change
Expand Up @@ -264,34 +264,38 @@
content: attr(data-leafdown-marker);
}

.leafdown-inline-source-projection {
.leafdown-source-projection {
color: inherit;
}

.leafdown-inline-source-projection__marker {
.leafdown-source-projection__marker {
@apply text-muted-foreground;
font-style: normal;
font-weight: 400;
}

.leafdown-inline-source-projection__content--strong {
.leafdown-source-projection__content--strong {
@apply font-semibold text-foreground;
}

.leafdown-inline-source-projection__content--emphasis {
.leafdown-source-projection__content--emphasis {
@apply italic;
}

.leafdown-inline-source-projection__content--strikethrough {
@apply line-through;
.leafdown-source-projection__content--strikethrough {
@apply text-muted-foreground line-through;
}

.leafdown-inline-source-projection__content--inline-code {
@apply text-inherit;
.leafdown-source-projection__content--inline-code {
@apply rounded-sm border border-white/10 bg-muted px-1 py-1 font-mono text-sm text-muted-foreground;
}

.leafdown-inline-source-projection__content--link {
@apply text-primary underline underline-offset-2;
.leafdown-source-projection__content--link {
@apply text-primary underline decoration-primary/45 underline-offset-2 transition-colors;

&:hover {
@apply decoration-primary;
}
}

.leafdown-source-edit {
Expand Down
2 changes: 1 addition & 1 deletion src/features/editor/plugins/dirtyTracker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ describe("Leafdown dirty tracker plugin", () => {
expect(mounted.getMarkdown()).toBe("Hello!\n");
});

it("tracks inline projection source edits without counting projection housekeeping", async () => {
it("tracks source-projection edits without counting projection housekeeping", async () => {
const onContentChanged = vi.fn();
const mounted = await mountEditor(BOLD_PLAIN_MARKDOWN, { onContentChanged });
const strong = getEditorDomElement(mounted, "strong");
Expand Down
10 changes: 5 additions & 5 deletions src/features/editor/plugins/dirtyTracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import { Plugin, PluginKey } from "@milkdown/kit/prose/state";
import { $prose } from "@milkdown/kit/utils";

import {
isInlineSourceProjectionDirtyTransaction,
isInlineSourceProjectionHousekeepingTransaction,
} from "./inlineSourceProjection";
isSourceProjectionDirtyTransaction,
isSourceProjectionHousekeepingTransaction,
} from "./sourceProjection";

interface DirtyTrackerPluginState {
trackedChangeCount: number;
Expand All @@ -24,9 +24,9 @@ const getDirtyTrackerState = (state: EditorState) =>

const shouldTrackDirtyTransaction = (transaction: Transaction) =>
transaction.docChanged &&
(isInlineSourceProjectionDirtyTransaction(transaction) ||
(isSourceProjectionDirtyTransaction(transaction) ||
(transaction.getMeta("addToHistory") !== false &&
!isInlineSourceProjectionHousekeepingTransaction(transaction)));
!isSourceProjectionHousekeepingTransaction(transaction)));

export const createLeafdownDirtyTrackerPlugin = (onContentChanged: () => void) =>
$prose(
Expand Down
Loading