From ab02f37db7a86a95e81e1b18bd987fbed0c93e9e Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 23 May 2026 11:51:19 +0530 Subject: [PATCH 1/2] 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 . Without these the whole

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 (

,

) 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. --- src-mdviewer/src/bridge.js | 26 +- src-mdviewer/src/components/editor.js | 113 ++++- .../Phoenix-live-preview/MarkdownSync.js | 24 + .../markdown-line-wrap.js | 416 ++++++++++++++++++ test/UnitTestSuite.js | 1 + test/spec/markdown-line-wrap-test.js | 267 +++++++++++ 6 files changed, 844 insertions(+), 3 deletions(-) create mode 100644 src/extensionsIntegrated/Phoenix-live-preview/markdown-line-wrap.js create mode 100644 test/spec/markdown-line-wrap-test.js diff --git a/src-mdviewer/src/bridge.js b/src-mdviewer/src/bridge.js index 684313c2fa..40dd451eac 100644 --- a/src-mdviewer/src/bridge.js +++ b/src-mdviewer/src/bridge.js @@ -188,10 +188,34 @@ function _withSourceLine(protoFn, tagRegex) { }; } +// Paragraphs that span multiple source lines (e.g. lines wrapped by Phoenix's +// reflow on save) get per-source-line children. +// Without these, the whole

shares a single data-source-line attribute and +// cursor sync always maps to the block's first line — so clicking anywhere in +// a wrapped paragraph snaps CM to the same line, regardless of caret position. +// Each line is re-parsed via marked.parseInline so inline markdown inside the +// span renders correctly. The Phoenix-side wrap step deliberately never splits +// inline atoms across source lines, so each line is independently parseable. +function _renderParagraphWithSourceSpans(token) { + const startLine = token._sourceLine; + if (startLine == null) { + return _proto.paragraph.call(this, token); + } + const sourceLines = (token.raw || "").split("\n").filter(l => l !== ""); + if (sourceLines.length <= 1) { + return _proto.paragraph.call(this, token) + .replace(/^

+ `${marked.parseInline(line)}` + ); + return `

${spans.join(" ")}

`; +} + marked.use({ renderer: { heading: _withSourceLine(_proto.heading, /^,

, etc.) + // to highlight them; skipping those would misalign mdLineIdx for the + // rest of the walk. The standalone overlay variants always carry the + // cursor-sync-br-line or cursor-sync-code-line companion class. if (el.classList.contains("table-row-handles") || el.classList.contains("table-col-handles") || el.classList.contains("table-add-row-btn") || el.classList.contains("table-col-add-btn") || - el.classList.contains("cursor-sync-highlight")) { + el.classList.contains("cursor-sync-br-line") || + el.classList.contains("cursor-sync-code-line")) { continue; } @@ -1840,11 +1847,113 @@ function _updateSourceLineAttrs(contentEl, markdown) { // Single-line or multi-line block: advance to next blank line. // Paragraphs with
(soft line breaks) are a single block — // the data-source-line on the

points to the block's start. + const blockStart = mdLineIdx; mdLineIdx++; while (mdLineIdx < mdLines.length && mdLines[mdLineIdx].trim() !== "") { mdLineIdx++; } + // For paragraphs that span multiple source lines (Phoenix-side + // wrap reflow), keep per-line children in + // sync so cursor sync resolves to the exact line the caret is on, + // not just the block start. + if (tag === "P") { + const blockLines = mdLines.slice(blockStart, mdLineIdx) + .filter(l => l !== ""); + _refreshParagraphSourceSpans(el, blockLines, blockStart + 1); + } + } + } +} + +// Maintain the per-source-line structure inside a multi-line paragraph. +// Cases: +// - Span count matches sourceLines.length and starting line matches → no-op +// (line numbers may have shifted upward, in which case attributes are +// updated in place without touching content or cursor). +// - Single source line and no spans → no-op. +// - Single source line but spans present (paragraph just stopped wrapping) +// → unwrap them, preserving caret position by character offset. +// - Multi source line and span structure mismatched (just started wrapping, +// or wrap line count changed) → rebuild innerHTML from source lines, with +// caret position preserved by character offset. +function _refreshParagraphSourceSpans(p, sourceLines, startLineNum) { + const expectedCount = sourceLines.length; + if (expectedCount === 0) { + return; + } + const existingSpans = Array.from(p.children) + .filter(c => c.tagName === "SPAN" && c.hasAttribute("data-source-line")); + + if (expectedCount === 1) { + if (existingSpans.length === 0) { + return; + } + // Paragraph just stopped wrapping — unwrap the spans into the

. + _rebuildParagraphInner(p, marked.parseInline(sourceLines[0] || "")); + return; + } + + if (existingSpans.length === expectedCount) { + // Span count matches. The user is typing inside one of the spans; do + // not disturb their content. Just update the data-source-line values + // in case the paragraph shifted up/down in the source. + const firstAttr = parseInt(existingSpans[0].getAttribute("data-source-line"), 10); + if (firstAttr !== startLineNum) { + for (let i = 0; i < existingSpans.length; i++) { + existingSpans[i].setAttribute("data-source-line", String(startLineNum + i)); + } } + return; + } + + // Span structure doesn't match expected — rebuild from source. + const newHtml = sourceLines.map((line, i) => + `${marked.parseInline(line)}` + ).join(" "); + _rebuildParagraphInner(p, newHtml); +} + +// Replace a paragraph's innerHTML while preserving the user's caret position +// by character offset within the block. Used when wrap state changes mid-edit +// (paragraph starts/stops wrapping, or wrap line count changes). +function _rebuildParagraphInner(p, newInnerHtml) { + if (p.innerHTML === newInnerHtml) { + return; + } + const sel = window.getSelection(); + let savedOffset = null; + if (sel && sel.rangeCount) { + const range = sel.getRangeAt(0); + if (p.contains(range.startContainer)) { + const pre = document.createRange(); + pre.setStart(p, 0); + pre.setEnd(range.startContainer, range.startOffset); + savedOffset = pre.toString().length; + } + } + p.innerHTML = newInnerHtml; + if (savedOffset !== null && sel) { + const walker = document.createTreeWalker(p, NodeFilter.SHOW_TEXT, null); + let remaining = savedOffset; + let node = walker.nextNode(); + while (node) { + if (remaining <= node.textContent.length) { + const range = document.createRange(); + range.setStart(node, remaining); + range.collapse(true); + sel.removeAllRanges(); + sel.addRange(range); + return; + } + remaining -= node.textContent.length; + node = walker.nextNode(); + } + // Fallback: place cursor at end of paragraph + const range = document.createRange(); + range.selectNodeContents(p); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); } } diff --git a/src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js b/src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js index 97902ade7d..4b52c86388 100644 --- a/src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js +++ b/src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js @@ -30,8 +30,17 @@ define(function (require, exports, module) { Commands = require("command/Commands"), KeyBindingManager = require("command/KeyBindingManager"), Metrics = require("utils/Metrics"), + PreferencesManager = require("preferences/PreferencesManager"), + EditorOptionHandlers = require("editor/EditorOptionHandlers"), + markdownLineWrap = require("./markdown-line-wrap"), utils = require("./utils"); + const PREF_MD_WRAP_EDITED_LINES = "mdViewerWrapEditedLines"; + PreferencesManager.definePreference(PREF_MD_WRAP_EDITED_LINES, "boolean", true, { + description: "When editing markdown in the live preview, wrap edited lines " + + "to the editor's max-line-length guide. Other lines stay byte-identical." + }); + // Commands whose shortcuts, when forwarded from the md viewer iframe, // open a parent-side UI that needs to keep keyboard focus. The iframe's // 100ms auto-refocus must skip these shortcuts — otherwise it yanks @@ -620,6 +629,21 @@ define(function (require, exports, module) { return; } + // Reflow only the edited long lines back to the editor's max-line-length + // guide. Turndown emits each block as a single physical line; without + // this step a long paragraph stays on one line until the user runs the + // full beautifier. Unchanged lines are guaranteed byte-identical, so + // git diffs stay scoped to the actual edit. + if (PreferencesManager.get(PREF_MD_WRAP_EDITED_LINES)) { + const printWidth = EditorOptionHandlers.getMaxLineLength(); + if (printWidth && printWidth >= 20) { + newText = markdownLineWrap.wrapEditedLines(oldText, newText, printWidth); + if (oldText === newText) { + return; + } + } + } + // Find first differing character let prefixLen = 0; const minLen = Math.min(oldText.length, newText.length); diff --git a/src/extensionsIntegrated/Phoenix-live-preview/markdown-line-wrap.js b/src/extensionsIntegrated/Phoenix-live-preview/markdown-line-wrap.js new file mode 100644 index 0000000000..90fd4e920e --- /dev/null +++ b/src/extensionsIntegrated/Phoenix-live-preview/markdown-line-wrap.js @@ -0,0 +1,416 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License + * for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +// Line-scoped markdown reflow. Used by MarkdownSync to wrap only the lines +// the user actually edited in the md viewer, leaving everything else +// byte-identical. Turndown emits each block as one logical line regardless +// of width; this module reintroduces wrapping per line, indent-aware, so +// the editor's max-line-length guide is honored without running a full +// markdown formatter on save. +// +// Pure function module — no Phoenix dependencies. Wrap algorithm is +// deliberately conservative: when in doubt, do not touch the line. + +define(function (require, exports, module) { + + // Lines matching any of these patterns are passed through unchanged. + // The renderer treats them as structural and wrapping would corrupt + // them (table cells, link reference definitions, hr, setext underlines). + const RE_HR = /^[ ]{0,3}([-*_])[ \t]*(?:\1[ \t]*){2,}$/; + const RE_ATX_HEADING = /^[ ]{0,3}#{1,6}(\s|$)/; + const RE_SETEXT_UNDERLINE = /^[ ]{0,3}(=+|-+)\s*$/; + const RE_LINK_REF_DEF = /^[ ]{0,3}\[[^\]]+\]:\s/; + const RE_TABLE_LINE = /^\s*\|.*\|\s*$/; + const RE_FENCE_OPEN = /^(\s*)(`{3,}|~{3,})/; + const RE_HTML_BLOCK_OPEN = /^[ ]{0,3}<(?:[a-zA-Z][a-zA-Z0-9-]*|!--)/; + + // Inline atoms we must not split: image-only links, image links, inline + // links, inline code, inline HTML tags. Greedy alternation, in priority + // order — the image-only-link pattern must precede the plain link pattern + // so a nested badge-row like [![a](u) ![b](u)](url) stays a single atom. + const RE_INLINE_ATOM = new RegExp([ + "\\[(?:!\\[[^\\]\\n]*\\]\\([^)\\n]*\\)\\s*)+\\]\\([^)\\n]*\\)", // [![alt](src) ![alt](src) ...](href) + "!\\[[^\\]\\n]*\\]\\([^)\\n]*\\)", // image: ![alt](src) + "\\[[^\\]\\n]*\\]\\([^)\\n]*\\)", // link: [text](url) + "`+[^`\\n]+`+", // inline code + "<[a-zA-Z/][^>\\n]*>" // inline HTML tag + ].join("|"), "g"); + + // Detect the line-leading construct that determines indent context. + // "- foo" -> { indent: "", marker: "- ", contIndent: " " } + // " - foo" -> { indent: " ", marker: "- ", contIndent: " " } + // "1. foo" -> { indent: "", marker: "1. ", contIndent: " " } + // "> foo" -> { indent: "", marker: "> ", contIndent: "> " } + // " foo" -> { indent: " ", marker: "", contIndent: " " } + // "foo" -> { indent: "", marker: "", contIndent: "" } + function _detectLeading(line) { + const m = line.match(/^([ \t]*)(?:([-*+])[ \t]+|(\d+)[.)][ \t]+|(>[ \t]?))?/); + const indent = m[1] || ""; + if (m[2]) { + const marker = m[2] + " "; + return { + indent, + marker, + contIndent: indent + " ".repeat(marker.length), + contentStart: m[0].length + }; + } + if (m[3]) { + const marker = m[3] + ". "; + return { + indent, + marker, + contIndent: indent + " ".repeat(marker.length), + contentStart: m[0].length + }; + } + if (m[4]) { + // Blockquote — continuation must repeat the "> " marker so the + // renderer keeps the next line inside the quote. + return { + indent, + marker: m[4], + contIndent: indent + "> ", + contentStart: m[0].length + }; + } + return { + indent, + marker: "", + contIndent: indent, + contentStart: indent.length + }; + } + + // Split text into atoms: inline markdown constructs stay whole, the + // rest is whitespace-separated tokens. Inter-token whitespace is collapsed + // to a single space at join time. + function _tokenize(text) { + const tokens = []; + let lastIdx = 0; + let m; + RE_INLINE_ATOM.lastIndex = 0; + while ((m = RE_INLINE_ATOM.exec(text)) !== null) { + // Words before this atom + const pre = text.substring(lastIdx, m.index); + for (const w of pre.split(/\s+/)) { + if (w) { tokens.push(w); } + } + tokens.push(m[0]); + lastIdx = m.index + m[0].length; + } + // Trailing words + const tail = text.substring(lastIdx); + for (const w of tail.split(/\s+/)) { + if (w) { tokens.push(w); } + } + return tokens; + } + + // Greedy packer: fill each line up to `width`, never splitting a token. + // A single token longer than `width` (e.g. a long URL) gets its own line + // even though it overflows — splitting it would change semantics. + function _packTokens(tokens, width) { + const lines = []; + let cur = ""; + for (const tok of tokens) { + if (cur === "") { + cur = tok; + } else if (cur.length + 1 + tok.length <= width) { + cur += " " + tok; + } else { + lines.push(cur); + cur = tok; + } + } + if (cur) { lines.push(cur); } + return lines; + } + + // Balanced packer: distribute tokens so the resulting lines are roughly + // equal length while still respecting `width` as a hard maximum. Critical + // for cursor-sync alignment in the markdown viewer: with greedy packing, + // the last line is a tiny remainder and the first line absorbs ~all the + // content, so the per-source-line spans in the iframe become wildly + // uneven and visual line N rarely matches source line N. Balancing makes + // each span cover a comparable visual area in the rendered paragraph. + // + // Strategy: greedy first to learn the minimum line count N. Then binary + // search for the smallest width that still produces exactly N lines — + // that width gives the most even distribution (each line fills as much + // as it can without spilling into an extra line). + function _balancedPack(tokens, width) { + if (tokens.length <= 1) { + return _packTokens(tokens, width); + } + const greedy = _packTokens(tokens, width); + if (greedy.length <= 1) { + return greedy; + } + const N = greedy.length; + let lo = 0; + for (const t of tokens) { + if (t.length > lo) { lo = t.length; } + } + let hi = width; + let best = greedy; + while (lo <= hi) { + const mid = Math.floor((lo + hi) / 2); + const trial = _packTokens(tokens, mid); + if (trial.length <= N) { + best = trial; + hi = mid - 1; + } else { + lo = mid + 1; + } + } + return best; + } + + // Wrap one logical line to printWidth, respecting indent context. + // Returns an array of physical lines (the input line replaced by these). + function _wrapLine(line, printWidth) { + const ctx = _detectLeading(line); + const content = line.substring(ctx.contentStart); + if (!content.trim()) { + return [line]; + } + const tokens = _tokenize(content); + if (tokens.length <= 1) { + // Nothing to break on + return [line]; + } + const firstWidth = printWidth - (ctx.indent.length + ctx.marker.length); + const restWidth = printWidth - ctx.contIndent.length; + if (firstWidth <= 0 || restWidth <= 0) { + return [line]; + } + // For a single logical line we balance across ALL output lines as one + // unit (first + rest share the same width, since contIndent matches + // indent + marker width for paragraph/list contexts in practice). + // If first-line and continuation widths differ significantly we fall + // back to greedy on the rest portion. + if (firstWidth === restWidth) { + const allLines = _balancedPack(tokens, firstWidth); + if (allLines.length === 0) { + return [line]; + } + const out = [ctx.indent + ctx.marker + allLines[0]]; + for (let i = 1; i < allLines.length; i++) { + out.push(ctx.contIndent + allLines[i]); + } + return out; + } + // Asymmetric first/continuation widths (e.g. ordered list "12. " marker + // wider than continuation indent). Pack the first line greedily, then + // balance the remainder. + const firstLineTokens = []; + const restTokens = []; + let used = 0; + let consumed = 0; + for (const tok of tokens) { + const extra = firstLineTokens.length === 0 ? tok.length : tok.length + 1; + if (used + extra <= firstWidth) { + firstLineTokens.push(tok); + used += extra; + consumed++; + } else { + break; + } + } + for (let i = consumed; i < tokens.length; i++) { + restTokens.push(tokens[i]); + } + if (firstLineTokens.length === 0) { + firstLineTokens.push(tokens[0]); + for (let i = 1; i < tokens.length; i++) { restTokens.push(tokens[i]); } + } + const out = [ctx.indent + ctx.marker + firstLineTokens.join(" ")]; + if (restTokens.length === 0) { + return out; + } + const restLines = _balancedPack(restTokens, restWidth); + for (const rl of restLines) { + out.push(ctx.contIndent + rl); + } + return out; + } + + // True iff the line, with its surrounding context, is a candidate for + // wrapping. Lines inside fenced code, tables, HTML blocks etc. are not. + function _isWrappable(line, state) { + if (state.inFence || state.inHtmlBlock || state.inFrontmatter) { + return false; + } + if (!line.trim()) { + return false; + } + if (RE_HR.test(line) || RE_ATX_HEADING.test(line) || + RE_SETEXT_UNDERLINE.test(line) || RE_LINK_REF_DEF.test(line) || + RE_TABLE_LINE.test(line)) { + return false; + } + return true; + } + + // Walk lines tracking enter/exit of fenced code, HTML blocks, frontmatter. + // Caller uses the per-line state to gate wrapping decisions. + function _scanState(lines) { + const state = new Array(lines.length); + let inFence = false; + let fenceChar = ""; + let fenceLen = 0; + let inHtmlBlock = false; + let inFrontmatter = false; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (i === 0 && line.trim() === "---") { + inFrontmatter = true; + state[i] = { inFence, inHtmlBlock, inFrontmatter }; + continue; + } + if (inFrontmatter) { + state[i] = { inFence, inHtmlBlock, inFrontmatter }; + if (line.trim() === "---" || line.trim() === "...") { + inFrontmatter = false; + } + continue; + } + if (inFence) { + state[i] = { inFence, inHtmlBlock, inFrontmatter }; + const close = new RegExp("^\\s*" + fenceChar + "{" + fenceLen + ",}\\s*$"); + if (close.test(line)) { + inFence = false; + fenceChar = ""; + fenceLen = 0; + } + continue; + } + const fm = line.match(RE_FENCE_OPEN); + if (fm) { + inFence = true; + fenceChar = fm[2][0]; + fenceLen = fm[2].length; + state[i] = { inFence: true, inHtmlBlock, inFrontmatter }; + continue; + } + if (inHtmlBlock) { + state[i] = { inFence, inHtmlBlock, inFrontmatter }; + if (!line.trim()) { + inHtmlBlock = false; + } + continue; + } + if (RE_HTML_BLOCK_OPEN.test(line)) { + inHtmlBlock = true; + state[i] = { inFence, inHtmlBlock: true, inFrontmatter }; + continue; + } + state[i] = { inFence, inHtmlBlock, inFrontmatter }; + } + return state; + } + + /** + * Wrap lines in `newText` that differ from `oldText` and exceed `printWidth`. + * Lines that didn't change stay byte-identical. Lines inside fenced code, + * tables, HTML blocks, frontmatter, and other structural constructs are + * never wrapped. + * + * @param {string} oldText - Previous content (CM document text). + * @param {string} newText - New content from Turndown roundtrip. + * @param {number} printWidth - Max line length (e.g. editor guide). + * @returns {string} Possibly modified `newText` with edited long lines wrapped. + */ + function wrapEditedLines(oldText, newText, printWidth) { + if (typeof printWidth !== "number" || printWidth < 20) { + return newText; + } + if (oldText === newText) { + return newText; + } + const oldLines = oldText.split("\n"); + const newLines = newText.split("\n"); + + // Line-range diff using prefix/suffix scan — mirrors _applyDiffToEditor's + // character-level approach but at line granularity. Lines outside this + // range are guaranteed identical and stay untouched. + let prefix = 0; + const minLen = Math.min(oldLines.length, newLines.length); + while (prefix < minLen && oldLines[prefix] === newLines[prefix]) { + prefix++; + } + let oldSuf = oldLines.length; + let newSuf = newLines.length; + while (oldSuf > prefix && newSuf > prefix && + oldLines[oldSuf - 1] === newLines[newSuf - 1]) { + oldSuf--; + newSuf--; + } + + if (prefix === newSuf) { + // Only deletions — nothing to wrap. + return newText; + } + + // Fast path: if no changed line exceeds printWidth, skip the regex-heavy + // state scan and the result rebuild entirely. This is the common case + // for every keystroke that doesn't push a line past the guide. + let anyOverflow = false; + for (let i = prefix; i < newSuf; i++) { + if (newLines[i].length > printWidth) { + anyOverflow = true; + break; + } + } + if (!anyOverflow) { + return newText; + } + + const state = _scanState(newLines); + const result = []; + for (let i = 0; i < prefix; i++) { + result.push(newLines[i]); + } + for (let i = prefix; i < newSuf; i++) { + const line = newLines[i]; + if (line.length <= printWidth) { + result.push(line); + continue; + } + if (!_isWrappable(line, state[i])) { + result.push(line); + continue; + } + const wrapped = _wrapLine(line, printWidth); + for (const w of wrapped) { result.push(w); } + } + for (let i = newSuf; i < newLines.length; i++) { + result.push(newLines[i]); + } + return result.join("\n"); + } + + exports.wrapEditedLines = wrapEditedLines; + // Exported for tests: + exports._detectLeading = _detectLeading; + exports._tokenize = _tokenize; + exports._wrapLine = _wrapLine; +}); diff --git a/test/UnitTestSuite.js b/test/UnitTestSuite.js index 97d84344be..f773202e2f 100644 --- a/test/UnitTestSuite.js +++ b/test/UnitTestSuite.js @@ -126,6 +126,7 @@ define(function (require, exports, module) { require("spec/Generic-integ-test"); require("spec/spacing-auto-detect-integ-test"); require("spec/LocalizationUtils-test"); + require("spec/markdown-line-wrap-test"); require("spec/ScrollTrackHandler-integ-test"); // Integrated extension tests require("spec/Extn-RemoteFileAdapter-integ-test"); diff --git a/test/spec/markdown-line-wrap-test.js b/test/spec/markdown-line-wrap-test.js new file mode 100644 index 0000000000..cdaaa75e6d --- /dev/null +++ b/test/spec/markdown-line-wrap-test.js @@ -0,0 +1,267 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License + * for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +/*global describe, it, expect*/ + +define(function (require, exports, module) { + const lineWrap = require("extensionsIntegrated/Phoenix-live-preview/markdown-line-wrap"); + + describe("unit:markdown-line-wrap", function () { + + describe("wrapEditedLines", function () { + + it("should leave unchanged lines byte-identical", function () { + const old = "para one\npara two\npara three\n"; + const out = lineWrap.wrapEditedLines(old, old, 80); + expect(out).toBe(old); + }); + + it("should not touch lines that already fit", function () { + const old = "alpha\n"; + const next = "alpha\nbeta\n"; + const out = lineWrap.wrapEditedLines(old, next, 80); + expect(out).toBe(next); + }); + + it("should wrap a long edited paragraph at the print width", function () { + const old = "short\n"; + const longLine = "lorem ipsum dolor sit amet consectetur adipiscing elit " + + "sed do eiusmod tempor incididunt ut labore et dolore magna aliqua"; + const next = longLine + "\n"; + const out = lineWrap.wrapEditedLines(old, next, 40); + const lines = out.split("\n"); + // First line must be <= width + expect(lines[0].length).toBeLessThanOrEqual(40); + // All resulting lines combined must contain the original tokens + expect(out.replace(/\s+/g, " ").trim()) + .toBe(longLine.trim()); + }); + + it("should preserve list marker and indent continuation lines", function () { + const old = "- short item\n"; + const longItem = "- this is a much longer list item that exceeds the " + + "print width and needs to wrap across multiple lines cleanly"; + const out = lineWrap.wrapEditedLines(old, longItem + "\n", 40); + const lines = out.split("\n").filter(l => l !== ""); + expect(lines.length).toBeGreaterThan(1); + expect(lines[0].startsWith("- ")).toBe(true); + for (let i = 1; i < lines.length; i++) { + // Continuation indent must be " " (2 spaces — width of "- ") + expect(lines[i].startsWith(" ")).toBe(true); + expect(lines[i].length).toBeLessThanOrEqual(40); + } + }); + + it("should preserve nested list indent", function () { + const old = " - short\n"; + const longNested = " - nested item content that goes on and on " + + "past the limit and should wrap with the right indent"; + const out = lineWrap.wrapEditedLines(old, longNested + "\n", 40); + const lines = out.split("\n").filter(l => l !== ""); + expect(lines[0].startsWith(" - ")).toBe(true); + for (let i = 1; i < lines.length; i++) { + // Continuation indent for " - " is " " (4 spaces) + expect(lines[i].startsWith(" ")).toBe(true); + } + }); + + it("should preserve ordered list marker", function () { + const old = "1. short\n"; + const longOrdered = "1. ordered list item with enough text to need " + + "wrapping past the print width limit"; + const out = lineWrap.wrapEditedLines(old, longOrdered + "\n", 40); + const lines = out.split("\n").filter(l => l !== ""); + expect(lines[0].startsWith("1. ")).toBe(true); + for (let i = 1; i < lines.length; i++) { + // Continuation indent matches "1. " (3 spaces) + expect(lines[i].startsWith(" ")).toBe(true); + } + }); + + it("should keep blockquote prefix on continuation lines", function () { + const old = "> short\n"; + const longQuote = "> blockquote content that is much longer than the " + + "configured print width and needs to be wrapped while preserving the quote marker"; + const out = lineWrap.wrapEditedLines(old, longQuote + "\n", 40); + const lines = out.split("\n").filter(l => l !== ""); + expect(lines.length).toBeGreaterThan(1); + for (const l of lines) { + expect(l.startsWith("> ")).toBe(true); + } + }); + + it("should not wrap inside fenced code blocks", function () { + const old = "para\n"; + const longCode = "this line inside a code fence is intentionally " + + "very long because code formatting must be preserved exactly"; + const next = "```js\n" + longCode + "\n```\n"; + const out = lineWrap.wrapEditedLines(old, next, 40); + expect(out).toBe(next); + }); + + it("should not wrap table lines", function () { + const old = "para\n"; + const longRow = "| this is a very long table cell content that " + + "must not be wrapped because table syntax requires the line stay intact |"; + const next = "| header |\n| --- |\n" + longRow + "\n"; + const out = lineWrap.wrapEditedLines(old, next, 40); + expect(out).toBe(next); + }); + + it("should not wrap ATX headings", function () { + const old = "para\n"; + const longHeading = "## this is a really long heading that goes on " + + "past forty columns easily"; + const next = longHeading + "\n"; + const out = lineWrap.wrapEditedLines(old, next, 40); + expect(out).toBe(next); + }); + + it("should not wrap link reference definitions", function () { + const old = "para\n"; + const linkRef = "[my-ref]: https://example.com/some/very/long/path?with=query¶ms=here"; + const next = linkRef + "\n"; + const out = lineWrap.wrapEditedLines(old, next, 40); + expect(out).toBe(next); + }); + + it("should not split an inline link across lines", function () { + const old = "short\n"; + const longLinkLine = "see [the docs](https://example.com/very/long/path/here) for more details on this topic"; + const out = lineWrap.wrapEditedLines(old, longLinkLine + "\n", 40); + // The link atom must appear intact on exactly one line + const lines = out.split("\n"); + const linksFound = lines.filter(l => l.includes("[the docs]")); + expect(linksFound.length).toBe(1); + expect(linksFound[0]).toContain("[the docs](https://example.com/very/long/path/here)"); + }); + + it("should not split inline code spans", function () { + const old = "short\n"; + const next = "inline `code_with_many_underscores_inside` followed by enough words to push past the print width easily"; + const out = lineWrap.wrapEditedLines(old, next + "\n", 40); + const lines = out.split("\n"); + const codeFound = lines.filter(l => l.includes("`code_with_many_underscores_inside`")); + expect(codeFound.length).toBe(1); + }); + + it("should leave a single-token line that already overflows alone", function () { + const old = "short\n"; + // One huge URL token — splitting would break it + const next = "https://example.com/an/extremely/long/url/that/cannot/be/wrapped/without/breaking\n"; + const out = lineWrap.wrapEditedLines(old, next, 40); + expect(out).toBe(next); + }); + + it("should skip wrapping inside YAML frontmatter", function () { + const old = "---\ntitle: short\n---\npara\n"; + const longFm = "---\ntitle: this is a much longer frontmatter title that exceeds the print width\n---\npara\n"; + const out = lineWrap.wrapEditedLines(old, longFm, 40); + expect(out).toBe(longFm); + }); + + it("should only touch lines in the edited range", function () { + const old = "first\nsecond\nthird\n"; + const longMiddle = "second line is now very long and definitely exceeds " + + "the print width forcing it to wrap"; + const next = "first\n" + longMiddle + "\nthird\n"; + const out = lineWrap.wrapEditedLines(old, next, 40); + const lines = out.split("\n"); + expect(lines[0]).toBe("first"); + // last non-empty line stays "third" + expect(lines[lines.length - 2]).toBe("third"); + }); + + it("should not modify when printWidth is below the safety floor", function () { + const old = "x\n"; + const next = "this is a longer line being added at width 0\n"; + expect(lineWrap.wrapEditedLines(old, next, 0)).toBe(next); + expect(lineWrap.wrapEditedLines(old, next, 10)).toBe(next); + }); + + it("should preserve the README sonarcloud badges line (regression)", function () { + // The Turndown fix already guarantees this comes through clean + // (no leading spaces). The wrap step must then leave it alone + // because the line is one giant link atom — non-splittable. + const old = "old\n"; + const badges = "[![Sonar code quality check](https://sonarcloud.io/api/project_badges/measure?project=phcode-dev_phoenix&metric=alert_status) ![Security rating](https://sonarcloud.io/api/project_badges/measure?project=phcode-dev_phoenix&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=phcode-dev_phoenix)"; + const next = badges + "\n"; + const out = lineWrap.wrapEditedLines(old, next, 80); + // The whole line is two link atoms with one space between them — + // wrap may break between them but each atom stays whole. + const lines = out.split("\n").filter(l => l !== ""); + for (const l of lines) { + // Both opening brackets must stay paired with their closing parens + const openLinks = (l.match(/\[/g) || []).length; + const closeLinks = (l.match(/\]\(/g) || []).length; + expect(openLinks).toBe(closeLinks); + } + }); + }); + + describe("_detectLeading", function () { + it("identifies bullet markers", function () { + expect(lineWrap._detectLeading("- foo").marker).toBe("- "); + expect(lineWrap._detectLeading("- foo").contIndent).toBe(" "); + }); + + it("identifies nested indent", function () { + const ctx = lineWrap._detectLeading(" - foo"); + expect(ctx.indent).toBe(" "); + expect(ctx.marker).toBe("- "); + expect(ctx.contIndent).toBe(" "); + }); + + it("identifies ordered list markers", function () { + expect(lineWrap._detectLeading("12. foo").marker).toBe("12. "); + expect(lineWrap._detectLeading("12. foo").contIndent).toBe(" "); + }); + + it("identifies blockquote", function () { + const ctx = lineWrap._detectLeading("> foo"); + expect(ctx.marker).toBe("> "); + expect(ctx.contIndent).toBe("> "); + }); + + it("returns no marker for plain paragraph", function () { + const ctx = lineWrap._detectLeading("hello world"); + expect(ctx.marker).toBe(""); + expect(ctx.contIndent).toBe(""); + }); + }); + + describe("_tokenize", function () { + it("keeps link atoms whole", function () { + const toks = lineWrap._tokenize("see [the docs](https://e.com/p) here"); + expect(toks).toContain("[the docs](https://e.com/p)"); + }); + + it("keeps image atoms whole", function () { + const toks = lineWrap._tokenize("![alt text](https://e.com/img.png) end"); + expect(toks).toContain("![alt text](https://e.com/img.png)"); + }); + + it("keeps inline code whole", function () { + const toks = lineWrap._tokenize("use `npm install -g foo` then"); + expect(toks).toContain("`npm install -g foo`"); + }); + }); + }); +}); From 4ff79e059998f52838f9f774482e99516d707855 Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 23 May 2026 11:53:11 +0530 Subject: [PATCH 2/2] perf(mdviewer): use bitwise shift for binary-search midpoint Hot path in _balancedPack: >>1 is faster than Math.floor for the midpoint of two non-negative ints, and the values here stay well under 2^31. Adds a local no-bitwise eslint disable with rationale. --- .../Phoenix-live-preview/markdown-line-wrap.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/extensionsIntegrated/Phoenix-live-preview/markdown-line-wrap.js b/src/extensionsIntegrated/Phoenix-live-preview/markdown-line-wrap.js index 90fd4e920e..5575160765 100644 --- a/src/extensionsIntegrated/Phoenix-live-preview/markdown-line-wrap.js +++ b/src/extensionsIntegrated/Phoenix-live-preview/markdown-line-wrap.js @@ -172,7 +172,10 @@ define(function (require, exports, module) { let hi = width; let best = greedy; while (lo <= hi) { - const mid = Math.floor((lo + hi) / 2); + // Midpoint of two non-negative ints; >>1 is faster than + // Math.floor and the values stay well under 2^31. + // eslint-disable-next-line no-bitwise + const mid = (lo + hi) >> 1; const trial = _packTokens(tokens, mid); if (trial.length <= N) { best = trial;