Skip to content

Commit ab02f37

Browse files
committed
feat(mdviewer): line-scoped reflow with per-source-line cursor sync
Turndown emits each block as one physical line regardless of width, so typing or pasting a long paragraph in the md viewer wrote one very long line to CM on every save. Adds a line-scoped reflow that wraps only the lines the user actually edited, behind a new opt-out preference mdViewerWrapEditedLines (default true) using EditorOptionHandlers .getMaxLineLength() as the width source. New module markdown-line-wrap.js (with 26 unit specs): - Prefix/suffix line-range diff so unchanged lines stay byte-identical. - Indent-aware continuation: bullets (- * +), ordered lists (N. / N)), and blockquotes drive the continuation indent on wrapped lines. - Inline atoms (image-only links like [![a](u) ![b](u)](href), plain image links, inline links, inline code, inline HTML tags) are kept whole by tokenization. Single oversize tokens are left intact. - Conservative skip rules: fenced code, tables, ATX headings, setext underlines, link reference definitions, hr, HTML blocks, frontmatter. - Balanced packer: binary-searches for the smallest width that still produces the greedy line count, so the two physical lines come out e.g. 95+90 instead of 118+67 for a 186-char paragraph. Critical for the cursor-sync precision in the rendered viewer (see below). - Fast-path early exit when no changed line exceeds printWidth, so typical typing keystrokes never run the regex-heavy state scan. Per-source-line cursor sync in the markdown viewer (bridge.js + editor.js): - The custom paragraph renderer in bridge.js now wraps each source line of a multi-line paragraph in <span data-source-line="N">. Without these the whole <p> shared a single data-source-line and cursor sync always resolved to the block's first line, so moving the caret through a wrapped paragraph never updated the CM highlight. - editor.js _updateSourceLineAttrs keeps those spans in sync as the user edits. _refreshParagraphSourceSpans no-ops when the layout is unchanged, updates attributes in place when only the start line shifted, and rebuilds inner HTML (preserving caret by character offset) when the wrap line count actually changed. - Fixes a pre-existing _updateSourceLineAttrs filter bug: it was skipping any element with the cursor-sync-highlight class, but that class is added to real content blocks (<p>, <h1>) for highlighting purposes. Skipping them threw mdLineIdx off-by-one for every block after the highlighted one. Now we only skip the standalone overlay variants (cursor-sync-br-line, cursor-sync-code-line). Tests: - 26 unit specs in unit:markdown-line-wrap cover bullets, nested lists, ordered lists, blockquotes, fences, tables, headings, link refs, inline atoms, frontmatter, and the badge-row regression. - The existing 58-spec livepreview:Markdown Editor 1 suite still passes.
1 parent 0da2c61 commit ab02f37

6 files changed

Lines changed: 844 additions & 3 deletions

File tree

src-mdviewer/src/bridge.js

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,10 +188,34 @@ function _withSourceLine(protoFn, tagRegex) {
188188
};
189189
}
190190

191+
// Paragraphs that span multiple source lines (e.g. lines wrapped by Phoenix's
192+
// reflow on save) get per-source-line <span data-source-line="N"> children.
193+
// Without these, the whole <p> shares a single data-source-line attribute and
194+
// cursor sync always maps to the block's first line — so clicking anywhere in
195+
// a wrapped paragraph snaps CM to the same line, regardless of caret position.
196+
// Each line is re-parsed via marked.parseInline so inline markdown inside the
197+
// span renders correctly. The Phoenix-side wrap step deliberately never splits
198+
// inline atoms across source lines, so each line is independently parseable.
199+
function _renderParagraphWithSourceSpans(token) {
200+
const startLine = token._sourceLine;
201+
if (startLine == null) {
202+
return _proto.paragraph.call(this, token);
203+
}
204+
const sourceLines = (token.raw || "").split("\n").filter(l => l !== "");
205+
if (sourceLines.length <= 1) {
206+
return _proto.paragraph.call(this, token)
207+
.replace(/^<p/, `<p data-source-line="${startLine}"`);
208+
}
209+
const spans = sourceLines.map((line, i) =>
210+
`<span data-source-line="${startLine + i}">${marked.parseInline(line)}</span>`
211+
);
212+
return `<p data-source-line="${startLine}">${spans.join(" ")}</p>`;
213+
}
214+
191215
marked.use({
192216
renderer: {
193217
heading: _withSourceLine(_proto.heading, /^<h[1-6]/),
194-
paragraph: _withSourceLine(_proto.paragraph, /^<p/),
218+
paragraph: _renderParagraphWithSourceSpans,
195219
list: _withSourceLine(_proto.list, /^<[ou]l/),
196220
listitem: _withSourceLine(_proto.listitem, /^<li/),
197221
table: _withSourceLine(_proto.table, /^<table/),

src-mdviewer/src/components/editor.js

Lines changed: 111 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Adapted editor — no save/file I/O, no CM6, no recovery, emits to bridge
22
import Prism from "prismjs";
3+
import { marked } from "marked";
34
import TurndownService from "turndown";
45
import { gfm } from "turndown-plugin-gfm";
56
import { on, emit } from "../core/events.js";
@@ -1799,12 +1800,18 @@ function _updateSourceLineAttrs(contentEl, markdown) {
17991800

18001801
for (let i = 0; i < children.length; i++) {
18011802
const el = children[i];
1802-
// Skip UI elements (handles, overlays, etc.)
1803+
// Skip standalone overlay elements (table handles, code-line/br-line
1804+
// cursor-sync overlays). Note: cursor-sync-highlight alone is NOT a
1805+
// skip signal — it's added to real content blocks (<p>, <h1>, etc.)
1806+
// to highlight them; skipping those would misalign mdLineIdx for the
1807+
// rest of the walk. The standalone overlay variants always carry the
1808+
// cursor-sync-br-line or cursor-sync-code-line companion class.
18031809
if (el.classList.contains("table-row-handles") ||
18041810
el.classList.contains("table-col-handles") ||
18051811
el.classList.contains("table-add-row-btn") ||
18061812
el.classList.contains("table-col-add-btn") ||
1807-
el.classList.contains("cursor-sync-highlight")) {
1813+
el.classList.contains("cursor-sync-br-line") ||
1814+
el.classList.contains("cursor-sync-code-line")) {
18081815
continue;
18091816
}
18101817

@@ -1840,11 +1847,113 @@ function _updateSourceLineAttrs(contentEl, markdown) {
18401847
// Single-line or multi-line block: advance to next blank line.
18411848
// Paragraphs with <br> (soft line breaks) are a single block —
18421849
// the data-source-line on the <p> points to the block's start.
1850+
const blockStart = mdLineIdx;
18431851
mdLineIdx++;
18441852
while (mdLineIdx < mdLines.length && mdLines[mdLineIdx].trim() !== "") {
18451853
mdLineIdx++;
18461854
}
1855+
// For paragraphs that span multiple source lines (Phoenix-side
1856+
// wrap reflow), keep per-line <span data-source-line> children in
1857+
// sync so cursor sync resolves to the exact line the caret is on,
1858+
// not just the block start.
1859+
if (tag === "P") {
1860+
const blockLines = mdLines.slice(blockStart, mdLineIdx)
1861+
.filter(l => l !== "");
1862+
_refreshParagraphSourceSpans(el, blockLines, blockStart + 1);
1863+
}
1864+
}
1865+
}
1866+
}
1867+
1868+
// Maintain the per-source-line <span> structure inside a multi-line paragraph.
1869+
// Cases:
1870+
// - Span count matches sourceLines.length and starting line matches → no-op
1871+
// (line numbers may have shifted upward, in which case attributes are
1872+
// updated in place without touching content or cursor).
1873+
// - Single source line and no spans → no-op.
1874+
// - Single source line but spans present (paragraph just stopped wrapping)
1875+
// → unwrap them, preserving caret position by character offset.
1876+
// - Multi source line and span structure mismatched (just started wrapping,
1877+
// or wrap line count changed) → rebuild innerHTML from source lines, with
1878+
// caret position preserved by character offset.
1879+
function _refreshParagraphSourceSpans(p, sourceLines, startLineNum) {
1880+
const expectedCount = sourceLines.length;
1881+
if (expectedCount === 0) {
1882+
return;
1883+
}
1884+
const existingSpans = Array.from(p.children)
1885+
.filter(c => c.tagName === "SPAN" && c.hasAttribute("data-source-line"));
1886+
1887+
if (expectedCount === 1) {
1888+
if (existingSpans.length === 0) {
1889+
return;
1890+
}
1891+
// Paragraph just stopped wrapping — unwrap the spans into the <p>.
1892+
_rebuildParagraphInner(p, marked.parseInline(sourceLines[0] || ""));
1893+
return;
1894+
}
1895+
1896+
if (existingSpans.length === expectedCount) {
1897+
// Span count matches. The user is typing inside one of the spans; do
1898+
// not disturb their content. Just update the data-source-line values
1899+
// in case the paragraph shifted up/down in the source.
1900+
const firstAttr = parseInt(existingSpans[0].getAttribute("data-source-line"), 10);
1901+
if (firstAttr !== startLineNum) {
1902+
for (let i = 0; i < existingSpans.length; i++) {
1903+
existingSpans[i].setAttribute("data-source-line", String(startLineNum + i));
1904+
}
18471905
}
1906+
return;
1907+
}
1908+
1909+
// Span structure doesn't match expected — rebuild from source.
1910+
const newHtml = sourceLines.map((line, i) =>
1911+
`<span data-source-line="${startLineNum + i}">${marked.parseInline(line)}</span>`
1912+
).join(" ");
1913+
_rebuildParagraphInner(p, newHtml);
1914+
}
1915+
1916+
// Replace a paragraph's innerHTML while preserving the user's caret position
1917+
// by character offset within the block. Used when wrap state changes mid-edit
1918+
// (paragraph starts/stops wrapping, or wrap line count changes).
1919+
function _rebuildParagraphInner(p, newInnerHtml) {
1920+
if (p.innerHTML === newInnerHtml) {
1921+
return;
1922+
}
1923+
const sel = window.getSelection();
1924+
let savedOffset = null;
1925+
if (sel && sel.rangeCount) {
1926+
const range = sel.getRangeAt(0);
1927+
if (p.contains(range.startContainer)) {
1928+
const pre = document.createRange();
1929+
pre.setStart(p, 0);
1930+
pre.setEnd(range.startContainer, range.startOffset);
1931+
savedOffset = pre.toString().length;
1932+
}
1933+
}
1934+
p.innerHTML = newInnerHtml;
1935+
if (savedOffset !== null && sel) {
1936+
const walker = document.createTreeWalker(p, NodeFilter.SHOW_TEXT, null);
1937+
let remaining = savedOffset;
1938+
let node = walker.nextNode();
1939+
while (node) {
1940+
if (remaining <= node.textContent.length) {
1941+
const range = document.createRange();
1942+
range.setStart(node, remaining);
1943+
range.collapse(true);
1944+
sel.removeAllRanges();
1945+
sel.addRange(range);
1946+
return;
1947+
}
1948+
remaining -= node.textContent.length;
1949+
node = walker.nextNode();
1950+
}
1951+
// Fallback: place cursor at end of paragraph
1952+
const range = document.createRange();
1953+
range.selectNodeContents(p);
1954+
range.collapse(false);
1955+
sel.removeAllRanges();
1956+
sel.addRange(range);
18481957
}
18491958
}
18501959

src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,17 @@ define(function (require, exports, module) {
3030
Commands = require("command/Commands"),
3131
KeyBindingManager = require("command/KeyBindingManager"),
3232
Metrics = require("utils/Metrics"),
33+
PreferencesManager = require("preferences/PreferencesManager"),
34+
EditorOptionHandlers = require("editor/EditorOptionHandlers"),
35+
markdownLineWrap = require("./markdown-line-wrap"),
3336
utils = require("./utils");
3437

38+
const PREF_MD_WRAP_EDITED_LINES = "mdViewerWrapEditedLines";
39+
PreferencesManager.definePreference(PREF_MD_WRAP_EDITED_LINES, "boolean", true, {
40+
description: "When editing markdown in the live preview, wrap edited lines " +
41+
"to the editor's max-line-length guide. Other lines stay byte-identical."
42+
});
43+
3544
// Commands whose shortcuts, when forwarded from the md viewer iframe,
3645
// open a parent-side UI that needs to keep keyboard focus. The iframe's
3746
// 100ms auto-refocus must skip these shortcuts — otherwise it yanks
@@ -620,6 +629,21 @@ define(function (require, exports, module) {
620629
return;
621630
}
622631

632+
// Reflow only the edited long lines back to the editor's max-line-length
633+
// guide. Turndown emits each block as a single physical line; without
634+
// this step a long paragraph stays on one line until the user runs the
635+
// full beautifier. Unchanged lines are guaranteed byte-identical, so
636+
// git diffs stay scoped to the actual edit.
637+
if (PreferencesManager.get(PREF_MD_WRAP_EDITED_LINES)) {
638+
const printWidth = EditorOptionHandlers.getMaxLineLength();
639+
if (printWidth && printWidth >= 20) {
640+
newText = markdownLineWrap.wrapEditedLines(oldText, newText, printWidth);
641+
if (oldText === newText) {
642+
return;
643+
}
644+
}
645+
}
646+
623647
// Find first differing character
624648
let prefixLen = 0;
625649
const minLen = Math.min(oldText.length, newText.length);

0 commit comments

Comments
 (0)