diff --git a/packages/core/src/lib/selection.ts b/packages/core/src/lib/selection.ts index 57a54f9a7..8499f8a82 100644 --- a/packages/core/src/lib/selection.ts +++ b/packages/core/src/lib/selection.ts @@ -1,5 +1,6 @@ import { Renderable } from "../Renderable.js" import type { ViewportBounds } from "../types.js" +import { stringWidth } from "../platform/runtime.js" import { coordinateToCharacterIndex, fonts } from "./ascii.font.js" class SelectionAnchor { @@ -111,8 +112,14 @@ export class Selection { } getSelectedText(): string { - const selectedTextsByLine = new Map>() - const selectedRenderables = this._selectedRenderables + const selectedTextsByLine = new Map>() + const anchor = this.anchor + const focus = this.focus + const selectionStart = anchor.y < focus.y || (anchor.y === focus.y && anchor.x <= focus.x) ? anchor : focus + let baselineX = Number.POSITIVE_INFINITY + let firstSelectedTextX: number | null = null + let preserveFirstLineIndentation = false + const selectedRenderables = [...this._selectedRenderables] // Sort by reading order: top-to-bottom, then left-to-right .sort((a, b) => { const aY = a.y @@ -128,23 +135,79 @@ export class Selection { const text = renderable.getSelectedText() if (!text) continue const lines = text.split("\n") + const firstSelectedY = Math.max(renderable.y, selectionStart.y) + const visualEndY = renderable.y + renderable.height - 1 + for (let index = 0; index < lines.length; index += 1) { - const y = renderable.y + index + const y = firstSelectedY + index + const selectedLine = lines[index] + // Some renderables include newline delimiter rows outside their visual + // bounds. Skip those boundary-only empties so we can synthesize gaps from + // actual vertical spacing instead of double-counting them. + if (selectedLine === "" && (y < renderable.y || y > visualEndY)) { + continue + } + + const x = y === selectionStart.y ? Math.max(renderable.x, selectionStart.x) : renderable.x const line = selectedTextsByLine.get(y) ?? [] - line.push({ x: renderable.x, text: lines[index] }) + line.push({ x, text: selectedLine, spanEndY: visualEndY }) selectedTextsByLine.set(y, line) + + if (selectedLine !== "") { + if (firstSelectedTextX === null) { + firstSelectedTextX = x + preserveFirstLineIndentation = x === renderable.x + } + + baselineX = Math.min(baselineX, x) + } + } + } + + const sortedEntries = [...selectedTextsByLine.entries()].sort(([leftY], [rightY]) => leftY - rightY) + if (sortedEntries.length === 0) return "" + + const lineStartX = + firstSelectedTextX === null + ? 0 + : preserveFirstLineIndentation && Number.isFinite(baselineX) + ? baselineX + : firstSelectedTextX + const selectedLines: string[] = [] + let previousSpanEndY: number | null = null + + for (const [y, line] of sortedEntries) { + if (previousSpanEndY !== null && y > previousSpanEndY + 1) { + for (let gapY = previousSpanEndY + 1; gapY < y; gapY += 1) { + selectedLines.push("") + } } + + const currentSpanEndY = line.reduce((max, segment) => Math.max(max, segment.spanEndY), y) + + if (line.every((segment) => segment.text === "")) { + selectedLines.push("") + previousSpanEndY = previousSpanEndY === null ? currentSpanEndY : Math.max(previousSpanEndY, currentSpanEndY) + continue + } + + let cursorX = lineStartX + let selectedLine = "" + + for (const segment of line.sort((left, right) => left.x - right.x)) { + if (segment.x > cursorX) { + selectedLine += " ".repeat(segment.x - cursorX) + } + + selectedLine += segment.text + cursorX = Math.max(cursorX, segment.x + stringWidth(segment.text)) + } + + selectedLines.push(selectedLine) + previousSpanEndY = previousSpanEndY === null ? currentSpanEndY : Math.max(previousSpanEndY, currentSpanEndY) } - return [...selectedTextsByLine.entries()] - .sort(([leftY], [rightY]) => leftY - rightY) - .map(([, line]) => - line - .sort((left, right) => left.x - right.x) - .map((segment) => segment.text) - .join(""), - ) - .join("\n") + return selectedLines.join("\n") } } diff --git a/packages/core/src/renderables/__tests__/Markdown.test.ts b/packages/core/src/renderables/__tests__/Markdown.test.ts index 42317961f..49e9f50d1 100644 --- a/packages/core/src/renderables/__tests__/Markdown.test.ts +++ b/packages/core/src/renderables/__tests__/Markdown.test.ts @@ -1029,9 +1029,11 @@ Outro line below table.` const selectedText = renderer.getSelection()?.getSelectedText() ?? "" + expect(selectedText).toContain("table.\n\nComponent") expect(selectedText).toContain("Authentication") expect(selectedText).toContain("Payments API") expect(selectedText).toContain("Retry + idempotency") + expect(selectedText).toContain("Ranking + typo fix\n\nOutro line below table.") }) // Code block tests @@ -1356,6 +1358,49 @@ test("selection across top-level ordered list copies marker and text on same lin expect(renderer.getSelection()?.getSelectedText()).toBe(" 9. Nine\n10. Ten") }) +test("selection across top-level ordered list does not add skipped leading indentation", async () => { + const md = createMarkdownRenderable({ + id: "markdown-structured-ordered-list-selection-partial-indent", + content: `9. Nine +10. Ten`, + syntaxStyle, + internalBlockMode: "top-level", + }) + + renderer.root.add(md) + await renderMarkdownRenderable(md) + + const list = md._blockStates[0]?.renderable + expect(list).toBeInstanceOf(BoxRenderable) + + await mockMouse.drag(list!.x + 1, list!.y, list!.x + 20, list!.y + 1) + await renderer.idle() + + expect(renderer.getSelection()?.getSelectedText()).toBe("9. Nine\n10. Ten") +}) + +test("selection across nested unordered list preserves visual indentation", async () => { + const md = createMarkdownRenderable({ + id: "markdown-nested-structured-list-selection", + content: `- parent + - child + - grandchild`, + syntaxStyle, + internalBlockMode: "top-level", + }) + + renderer.root.add(md) + await renderMarkdownRenderable(md) + + const list = md._blockStates[0]?.renderable + expect(list).toBeInstanceOf(BoxRenderable) + + await mockMouse.drag(list!.x, list!.y, list!.x + 20, list!.y + 2) + await renderer.idle() + + expect(renderer.getSelection()?.getSelectedText()).toBe("- parent\n - child\n - grandchild") +}) + test("top-level structured lists align nested fenced code under nested content", async () => { const md = createMarkdownRenderable({ id: "markdown-structured-list-code", diff --git a/packages/core/src/tests/renderer.selection.test.ts b/packages/core/src/tests/renderer.selection.test.ts index 864b7283c..b5ae8e35c 100644 --- a/packages/core/src/tests/renderer.selection.test.ts +++ b/packages/core/src/tests/renderer.selection.test.ts @@ -79,10 +79,151 @@ test("selected text joins same-row renderables without newlines", () => { expect(renderer.getSelection()?.getSelectedText()).toBe("Hello World") }) -test("selected text keeps newlines between different rows", () => { +test("selected text preserves visual gaps between same-row renderables", () => { + const row = new BoxRenderable(renderer, { + flexDirection: "row", + width: 20, + height: 1, + }) + const left = new TextRenderable(renderer, { + content: "Hello", + width: 5, + height: 1, + selectable: true, + }) + const spacer = new BoxRenderable(renderer, { + width: 2, + height: 1, + }) + const right = new TextRenderable(renderer, { + content: "World", + width: 5, + height: 1, + selectable: true, + }) + + row.add(left) + row.add(spacer) + row.add(right) + renderer.root.add(row) + renderOnce() + + renderer.startSelection(left, left.x, left.y) + renderer.updateSelection(right, right.x + right.width, right.y, { finishDragging: true }) + + expect(renderer.getSelection()?.getSelectedText()).toBe("Hello World") +}) + +test("selected text joins adjacent rows with a single newline", () => { + const top = new TextRenderable(renderer, { + content: "First row", + left: 0, + top: 0, + width: 9, + height: 1, + selectable: true, + }) + const bottom = new TextRenderable(renderer, { + content: "Second row", + left: 0, + top: 1, + width: 10, + height: 1, + selectable: true, + }) + + renderer.root.add(top) + renderer.root.add(bottom) + renderOnce() + + renderer.startSelection(top, top.x, top.y) + renderer.updateSelection(bottom, bottom.x + bottom.width, bottom.y, { finishDragging: true }) + + const gapLines = Math.max(bottom.y - top.y - 1, 0) + expect(renderer.getSelection()?.getSelectedText()).toBe(["First row", ...Array(gapLines).fill(""), "Second row"].join("\n")) +}) + +test("selected text preserves blank lines within multiline renderables", () => { + const text = new TextRenderable(renderer, { + content: "First\n\nSecond", + left: 0, + top: 0, + width: 10, + height: 3, + selectable: true, + }) + + renderer.root.add(text) + renderOnce() + + renderer.startSelection(text, text.x, text.y) + renderer.updateSelection(text, text.x + 6, text.y + 2, { finishDragging: true }) + + expect(renderer.getSelection()?.getSelectedText()).toBe("First\n\nSecond") +}) + +test("selected text preserves vertical gaps between separated rows", () => { + const top = new TextRenderable(renderer, { + content: "First row", + left: 0, + top: 0, + width: 9, + height: 1, + selectable: true, + }) + const bottom = new TextRenderable(renderer, { + content: "Second row", + left: 0, + top: 3, + width: 10, + height: 1, + selectable: true, + }) + + renderer.root.add(top) + renderer.root.add(bottom) + renderOnce() + + renderer.startSelection(top, top.x, top.y) + renderer.updateSelection(bottom, bottom.x + bottom.width, bottom.y, { finishDragging: true }) + + const gapLines = Math.max(bottom.y - top.y - 1, 0) + expect(renderer.getSelection()?.getSelectedText()).toBe(["First row", ...Array(gapLines).fill(""), "Second row"].join("\n")) +}) + +test("selected text preserves first-line indentation when selection starts at local column zero", () => { const top = new TextRenderable(renderer, { content: "First row", + left: 4, + top: 0, + width: 9, + height: 1, + selectable: true, + }) + const bottom = new TextRenderable(renderer, { + content: "Second row", left: 0, + top: 1, + width: 10, + height: 1, + selectable: true, + }) + + renderer.root.add(top) + renderer.root.add(bottom) + renderOnce() + + renderer.startSelection(top, top.x, top.y) + renderer.updateSelection(bottom, bottom.x + bottom.width, bottom.y, { finishDragging: true }) + + const gapLines = Math.max(bottom.y - top.y - 1, 0) + expect(renderer.getSelection()?.getSelectedText()).toBe([" First row", ...Array(gapLines).fill(""), "Second row"].join("\n")) +}) + +test("selected text does not synthesize skipped indentation on the first copied line", () => { + const top = new TextRenderable(renderer, { + content: "First row", + left: 4, top: 0, width: 9, height: 1, @@ -101,10 +242,41 @@ test("selected text keeps newlines between different rows", () => { renderer.root.add(bottom) renderOnce() + renderer.startSelection(top, top.x + 1, top.y) + renderer.updateSelection(bottom, bottom.x + bottom.width, bottom.y, { finishDragging: true }) + + const gapLines = Math.max(bottom.y - top.y - 1, 0) + expect(renderer.getSelection()?.getSelectedText()).toBe(["irst row", ...Array(gapLines).fill(""), "Second row"].join("\n")) +}) + +test("selected text does not infer blank rows from renderable height", () => { + const top = new TextRenderable(renderer, { + content: "First row", + left: 0, + top: 0, + width: 9, + height: 3, + selectable: true, + }) + const bottom = new TextRenderable(renderer, { + content: "Second row", + left: 0, + top: 4, + width: 10, + height: 1, + selectable: true, + }) + + renderer.root.add(top) + renderer.root.add(bottom) + renderOnce() + renderer.startSelection(top, top.x, top.y) renderer.updateSelection(bottom, bottom.x + bottom.width, bottom.y, { finishDragging: true }) - expect(renderer.getSelection()?.getSelectedText()).toBe("First row\nSecond row") + const topVisualEndY = top.y + top.height - 1 + const gapLines = Math.max(bottom.y - topVisualEndY - 1, 0) + expect(renderer.getSelection()?.getSelectedText()).toBe(["First row", ...Array(gapLines).fill(""), "Second row"].join("\n")) }) test("selected text merges multiline same-row renderables by visual row", () => {