diff --git a/__tests__/commands/insert-commands.test.ts b/__tests__/commands/insert-commands.test.ts index fef6640..c7868f1 100644 --- a/__tests__/commands/insert-commands.test.ts +++ b/__tests__/commands/insert-commands.test.ts @@ -5,7 +5,7 @@ import { EditorState, TextSelection } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; import { WaterproofSchema } from "../../src/schema"; -import { getCmdInsertCode } from "../../src/commands/insert-command"; +import { getCmdInsertCode, getCmdInsertMarkdown, getCmdInsertLatex } from "../../src/commands/insert-command"; import { InsertionPlace } from "../../src/commands"; import { configuration } from "../../src/markdown-defaults"; @@ -53,7 +53,78 @@ test("Insert code below twice (selection static)", () => { expect(view.state.toJSON()).toStrictEqual(newState2); }); +// States for testing insertion adjacent to code blocks using the real configuration +// (code has openRequiresNewline: true, closeRequiresNewline: true) +// +// doc: [code("Content.")][newline][math_display("Content.")] +// Positions: code size=10 (pos 0-10), newline size=1 (pos 10), math_display starts at pos 11 +const stateCodeNewlineMath_mathSelected = {"doc":{"type":"doc","content":[{"type":"code","content":[{"type":"text","text":"Content."}]},{"type":"newline"},{"type":"math_display","content":[{"type":"text","text":"Content."}]}]},"selection":{"type":"node","anchor":11}}; + +// doc: [math_display("Content.")][newline][code("Content.")] +// math_display size=10 (pos 0-10), newline size=1 (pos 10), code starts at pos 11 +const stateMathNewlineCode_mathSelected = {"doc":{"type":"doc","content":[{"type":"math_display","content":[{"type":"text","text":"Content."}]},{"type":"newline"},{"type":"code","content":[{"type":"text","text":"Content."}]}]},"selection":{"type":"node","anchor":0}}; + +test("Insert markdown above math_display when code is before the newline adds extra newline", () => { + const view = new EditorView(null, {state: EditorState.fromJSON({schema: WaterproofSchema}, stateCodeNewlineMath_mathSelected)}); + const cmd = getCmdInsertMarkdown(InsertionPlace.Above, tagConf); + expect(cmd(view.state, view.dispatch, view)).toBe(true); + + const content = view.state.toJSON().doc.content; + expect(content).toStrictEqual([ + {"type":"code","content":[{"type":"text","text":"Content."}]}, + {"type":"newline"}, + {"type":"markdown"}, + {"type":"newline"}, + {"type":"math_display","content":[{"type":"text","text":"Content."}]} + ]); +}); + +test("Insert math above math_display when code is before the newline adds extra newline", () => { + const view = new EditorView(null, {state: EditorState.fromJSON({schema: WaterproofSchema}, stateCodeNewlineMath_mathSelected)}); + const cmd = getCmdInsertLatex(InsertionPlace.Above, tagConf); + expect(cmd(view.state, view.dispatch, view)).toBe(true); + + const content = view.state.toJSON().doc.content; + expect(content).toStrictEqual([ + {"type":"code","content":[{"type":"text","text":"Content."}]}, + {"type":"newline"}, + {"type":"math_display"}, + {"type":"newline"}, + {"type":"math_display","content":[{"type":"text","text":"Content."}]} + ]); +}); + +test("Insert markdown below math_display when code is after the newline adds extra newline", () => { + const view = new EditorView(null, {state: EditorState.fromJSON({schema: WaterproofSchema}, stateMathNewlineCode_mathSelected)}); + const cmd = getCmdInsertMarkdown(InsertionPlace.Below, tagConf); + expect(cmd(view.state, view.dispatch, view)).toBe(true); + + const content = view.state.toJSON().doc.content; + expect(content).toStrictEqual([ + {"type":"math_display","content":[{"type":"text","text":"Content."}]}, + {"type":"newline"}, + {"type":"markdown"}, + {"type":"newline"}, + {"type":"code","content":[{"type":"text","text":"Content."}]} + ]); +}); + +test("Insert math below math_display when code is after the newline adds extra newline", () => { + const view = new EditorView(null, {state: EditorState.fromJSON({schema: WaterproofSchema}, stateMathNewlineCode_mathSelected)}); + const cmd = getCmdInsertLatex(InsertionPlace.Below, tagConf); + expect(cmd(view.state, view.dispatch, view)).toBe(true); + + const content = view.state.toJSON().doc.content; + expect(content).toStrictEqual([ + {"type":"math_display","content":[{"type":"text","text":"Content."}]}, + {"type":"newline"}, + {"type":"math_display"}, + {"type":"newline"}, + {"type":"code","content":[{"type":"text","text":"Content."}]} + ]); +}); test("Insert code below twice (selection moves down)", () => { + const view = new EditorView(null, {state: EditorState.fromJSON({schema: WaterproofSchema}, state)}); const cmd = getCmdInsertCode(InsertionPlace.Below, tagConf); diff --git a/__tests__/mapping/mapping-update.test.ts b/__tests__/mapping/mapping-update.test.ts index 2203b4f..5060965 100644 --- a/__tests__/mapping/mapping-update.test.ts +++ b/__tests__/mapping/mapping-update.test.ts @@ -77,4 +77,110 @@ test("Mapping.update text insert inside input shifts wrapper and later blocks", expect(updatedInput.contentRange.to).toBe(inputContentEnd + 1); expect(updatedAfter.contentRange.from).toBe(afterContentStart + 1); expect(updatedAfter.tagRange.from).toBe(afterTagStart + 1); + + // Inserting "X" (no newlines) should preserve the code block's lineStart + // The parser sets the code block lineStart to 2 for this document structure + const updatedCode = findFirstCodeNode(updatedTree.root); + expect(updatedCode).not.toBeNull(); + expect(updatedCode!.lineStart).toBe(2); + expect(updatedTree.computeLineNumbers()).toStrictEqual([2]); +}); + +test("Mapping.update node insert shifts lineStart of subsequent code blocks", () => { + // Document: ```coq\nFirst\n```\n```coq\nSecond\n``` + // Two code blocks: first at line 1, second at line 4 + const doc = "```coq\nFirst\n```\n```coq\nSecond\n```"; + + const blocks = parse(doc, {language: "coq"}); + const mapping = new Mapping(blocks, 0, config, serializer); + const proseDoc = constructDocument(blocks); + + const tree = mapping.getMapping(); + const codeNodes = tree.root.children.filter(node => node.type === "code"); + expect(codeNodes.length).toBe(2); + const firstLineStart = codeNodes[0].lineStart; + const secondLineStart = codeNodes[1].lineStart; + expect(firstLineStart).toBe(1); + expect(secondLineStart).toBe(4); + + // Insert a new code block before the first code block (at position 0) + const slice = new Slice(Fragment.from([ + WaterproofSchema.nodes.code.create(null, Fragment.from(WaterproofSchema.text("New"))), + WaterproofSchema.nodes.newline.create() + ]), 0, 0); + const step = new ReplaceStep(0, 0, slice); + + mapping.update(step, proseDoc); + + const updatedTree = mapping.getMapping(); + sanityCheckTree(updatedTree.root); + + const updatedCodeNodes: TreeNode[] = []; + updatedTree.traverseDepthFirst(node => { + if (node.type === "code") updatedCodeNodes.push(node); + }); + + expect(updatedCodeNodes.length).toBe(3); + + // The newly inserted code block should have a computed lineStart + // ```coq\nNew\n``` starts at the beginning, so lineStart = 1 + expect(updatedCodeNodes[0].lineStart).toBe(1); + + // The original first code block should now be shifted by the newlines in the inserted content + // Inserted text: "```coq\nNew\n```\n" = 3 newlines, so original first shifts from 1 to 1+3 = 4 + expect(updatedCodeNodes[1].lineStart).toBe(firstLineStart + 3); + + // The original second code block should also shift by the same amount + expect(updatedCodeNodes[2].lineStart).toBe(secondLineStart + 3); + + expect(updatedTree.computeLineNumbers()).toStrictEqual([1, 4, 7]); +}); + +test("Mapping.update node insert in the middle shifts lineStart of later code blocks", () => { + // Document: ```coq\nFirst\n```\n```coq\nSecond\n``` + // Two code blocks: first at line 1, second at line 4 + const doc = "```coq\nFirst\n```\n```coq\nSecond\n```"; + + const blocks = parse(doc, {language: "coq"}); + const mapping = new Mapping(blocks, 0, config, serializer); + const proseDoc = constructDocument(blocks); + + const tree = mapping.getMapping(); + const codeNodes = tree.root.children.filter(node => node.type === "code"); + expect(codeNodes.length).toBe(2); + expect(codeNodes[0].lineStart).toBe(1); + expect(codeNodes[1].lineStart).toBe(4); + + // Insert a new code block between the two existing ones + // The newline between them is at pmRange {7, 8}, so inserting at position 8 + // places the new node right before the second code block + const slice = new Slice(Fragment.from([ + WaterproofSchema.nodes.code.create(null, Fragment.from(WaterproofSchema.text("Middle"))), + WaterproofSchema.nodes.newline.create() + ]), 0, 0); + const step = new ReplaceStep(8, 8, slice); + + mapping.update(step, proseDoc); + + const updatedTree = mapping.getMapping(); + sanityCheckTree(updatedTree.root); + + const updatedCodeNodes: TreeNode[] = []; + updatedTree.traverseDepthFirst(node => { + if (node.type === "code") updatedCodeNodes.push(node); + }); + + expect(updatedCodeNodes.length).toBe(3); + + // First code block is unchanged + expect(updatedCodeNodes[0].lineStart).toBe(1); + + // Inserted code block: after "```coq\nFirst\n```\n" (3 newlines), so lineStart = 4 + // The open tag ```coq\n adds 1 more, content starts at line 5 + expect(updatedCodeNodes[1].lineStart).toBe(4); + + // Second code block shifted by 3 newlines (```coq\nMiddle\n```\n) + expect(updatedCodeNodes[2].lineStart).toBe(4 + 3); + + expect(updatedTree.computeLineNumbers()).toStrictEqual([1, 4, 7]); }); diff --git a/__tests__/mapping/newmapping.test.ts b/__tests__/mapping/newmapping.test.ts index fedff8f..1270851 100644 --- a/__tests__/mapping/newmapping.test.ts +++ b/__tests__/mapping/newmapping.test.ts @@ -12,8 +12,6 @@ function createTestMapping(blocks: WaterproofDocument) { return tree; } -const PLACEHOLDER_LINENR = 0; - test("testMapping markdown only", () => { const blocks = [new MarkdownBlock("Hello", {from: 0, to: 5}, {from: 0, to: 5}, 0)]; const nodes = createTestMapping(blocks); @@ -30,11 +28,15 @@ test("testMapping markdown only", () => { expect(markdownNode.prosemirrorEnd).toBe(6); expect(markdownNode.pmRange).toStrictEqual({from: 0, to: 7}); expect(markdownNode.lineStart).toBe(0); + + // No code blocks → no line numbers + expect(nodes.computeLineNumbers()).toStrictEqual([]); }); test("testMapping code", () => { const blocks = [new CodeBlock("Lemma test", {from: 0, to: 21}, {from: 7, to: 17}, 0)]; - const nodes = createTestMapping(blocks).root.children; + const tree = createTestMapping(blocks); + const nodes = tree.root.children; expect(nodes.length).toBe(1); @@ -46,20 +48,23 @@ test("testMapping code", () => { expect(codeNode.prosemirrorEnd).toBe(11); expect(codeNode.pmRange).toStrictEqual({from: 0, to: 12}); expect(codeNode.lineStart).toBe(0); + + expect(tree.computeLineNumbers()).toStrictEqual([0]); }); -// TODO: Test for line nrs test("Input-area with nested code", () => { // \n```lan\nTest\n```\nHello + // Line counting: \n at pos 12 (line 1), \n at pos 19 in ```lan\n (line 2) → code starts at line 2 const blocks = [ - new InputAreaBlock("```lan\nTest\n```", {from: 0, to: 42}, {from: 12, to: 29}, PLACEHOLDER_LINENR, [ - new NewlineBlock({from: 12, to: 13}, {from: 12, to: 13}, PLACEHOLDER_LINENR), - new CodeBlock("Test", {from: 13, to: 28}, {from: 20, to: 24}, PLACEHOLDER_LINENR), - new NewlineBlock({from: 28, to: 29}, {from: 28, to: 29}, PLACEHOLDER_LINENR) + new InputAreaBlock("```lan\nTest\n```", {from: 0, to: 42}, {from: 12, to: 29}, 0, [ + new NewlineBlock({from: 12, to: 13}, {from: 12, to: 13}, 0), + new CodeBlock("Test", {from: 13, to: 28}, {from: 20, to: 24}, 2), + new NewlineBlock({from: 28, to: 29}, {from: 28, to: 29}, 0) ]), - new MarkdownBlock("Hello", {from: 42, to: 47}, {from: 42, to: 47}, PLACEHOLDER_LINENR) + new MarkdownBlock("Hello", {from: 42, to: 47}, {from: 42, to: 47}, 0) ]; - const nodes = createTestMapping(blocks).root.children; + const tree = createTestMapping(blocks); + const nodes = tree.root.children; expect(nodes.length).toBe(2); @@ -96,6 +101,7 @@ test("Input-area with nested code", () => { expect(second.prosemirrorStart).toBe(3); expect(second.prosemirrorEnd).toBe(7); expect(second.pmRange).toStrictEqual({from: 2, to: 8}); + expect(second.lineStart).toBe(2); expect(third.type).toBe("newline"); expect(third.contentRange).toStrictEqual({from: 28, to: 29}); @@ -103,20 +109,24 @@ test("Input-area with nested code", () => { expect(third.prosemirrorStart).toBe(8); expect(third.prosemirrorEnd).toBe(8); expect(third.pmRange).toStrictEqual({from: 8, to: 9}); + + // One code block at line 2 + expect(tree.computeLineNumbers()).toStrictEqual([2]); }); -// TODO: Test for line nrs test("Hint block with code and markdown inside", () => { // \n```lan\nRequire Import Rbase.\n```\n + // Line counting: \n at pos 31 (line 1), \n at pos 38 in ```lan\n (line 2) → code starts at line 2 const blocks = [ - new HintBlock("\n```lan\nRequire Import Rbase.\n```\n", "Import libraries", {from: 0, to: 72}, {from: 31, to: 65}, PLACEHOLDER_LINENR, [ - new NewlineBlock({from: 31, to: 32}, {from: 31, to: 32}, PLACEHOLDER_LINENR), - new CodeBlock("Require Import Rbase.", {from: 32, to: 64}, {from: 39, to: 60}, PLACEHOLDER_LINENR), - new NewlineBlock({from: 60, to: 61}, {from: 60, to: 61}, PLACEHOLDER_LINENR) + new HintBlock("\n```lan\nRequire Import Rbase.\n```\n", "Import libraries", {from: 0, to: 72}, {from: 31, to: 65}, 0, [ + new NewlineBlock({from: 31, to: 32}, {from: 31, to: 32}, 0), + new CodeBlock("Require Import Rbase.", {from: 32, to: 64}, {from: 39, to: 60}, 2), + new NewlineBlock({from: 60, to: 61}, {from: 60, to: 61}, 0) ]) ]; - const nodes = createTestMapping(blocks).root.children; + const tree = createTestMapping(blocks); + const nodes = tree.root.children; expect(nodes.length).toBe(1); @@ -146,6 +156,7 @@ test("Hint block with code and markdown inside", () => { expect(second.prosemirrorStart).toBe(3); expect(second.prosemirrorEnd).toBe(24); expect(second.pmRange).toStrictEqual({from: 2, to: 25}); + expect(second.lineStart).toBe(2); expect(third.type).toBe("newline"); expect(third.contentRange).toStrictEqual({from: 60, to: 61}); @@ -153,23 +164,31 @@ test("Hint block with code and markdown inside", () => { expect(third.prosemirrorStart).toBe(25); expect(third.prosemirrorEnd).toBe(25); expect(third.pmRange).toStrictEqual({from: 25, to: 26}); + + // One code block at line 2 + expect(tree.computeLineNumbers()).toStrictEqual([2]); }); -// TODO: Test for line nrs test("Mixed content: markdown, code, input-area, markdown", () => { // ### Example:\n```lan\nLemma\nTest\n```\n\n```lan\n(* Your solution here *)\n```\n + // Line counting: + // \n at pos 12 (line 1), \n at pos 19 in ```lan\n (line 2) → first code starts at line 2 + // Content "Lemma\nTest" has \n at pos 25 (line 3), then \n``` at pos 30 (line 4), trailing \n at pos 34 (line 5) + // at pos 35 has no newlines, \n at pos 47 (line 6), \n at pos 54 in ```lan\n (line 7) + // → second code starts at line 7 const blocks = [ - new MarkdownBlock("### Example:", {from: 0, to: 12}, {from: 0, to: 12}, PLACEHOLDER_LINENR), - new NewlineBlock({from: 12, to: 13}, {from: 12, to: 13}, PLACEHOLDER_LINENR), - new CodeBlock("Lemma\nTest", {from: 13, to: 34}, {from: 20, to: 30}, PLACEHOLDER_LINENR), - new NewlineBlock({from: 34, to: 35}, {from: 34, to: 35}, PLACEHOLDER_LINENR), - new InputAreaBlock("```lan\n(* Your solution here *)\n```", {from: 35, to: 97}, {from: 47, to: 84}, PLACEHOLDER_LINENR, [ - new NewlineBlock({from: 47, to: 48}, {from: 47, to: 48}, PLACEHOLDER_LINENR), - new CodeBlock("(* Your solution here *)", {from: 48, to: 83}, {from: 55, to: 79}, PLACEHOLDER_LINENR), - new NewlineBlock({from: 83, to: 84}, {from: 83, to: 84}, PLACEHOLDER_LINENR) + new MarkdownBlock("### Example:", {from: 0, to: 12}, {from: 0, to: 12}, 0), + new NewlineBlock({from: 12, to: 13}, {from: 12, to: 13}, 0), + new CodeBlock("Lemma\nTest", {from: 13, to: 34}, {from: 20, to: 30}, 2), + new NewlineBlock({from: 34, to: 35}, {from: 34, to: 35}, 0), + new InputAreaBlock("```lan\n(* Your solution here *)\n```", {from: 35, to: 97}, {from: 47, to: 84}, 0, [ + new NewlineBlock({from: 47, to: 48}, {from: 47, to: 48}, 0), + new CodeBlock("(* Your solution here *)", {from: 48, to: 83}, {from: 55, to: 79}, 7), + new NewlineBlock({from: 83, to: 84}, {from: 83, to: 84}, 0) ]) ]; - const nodes = createTestMapping(blocks).root.children; + const tree = createTestMapping(blocks); + const nodes = tree.root.children; expect(nodes.length).toBe(5); @@ -198,6 +217,7 @@ test("Mixed content: markdown, code, input-area, markdown", () => { expect(code1.prosemirrorStart).toBe(16); expect(code1.prosemirrorEnd).toBe(26); expect(code1.pmRange).toStrictEqual({from: 15, to: 27}); + expect(code1.lineStart).toBe(2); // Newline node expect(nl2.type).toBe("newline"); @@ -232,6 +252,7 @@ test("Mixed content: markdown, code, input-area, markdown", () => { expect(ia_code.prosemirrorStart).toBe(31); expect(ia_code.prosemirrorEnd).toBe(55); expect(ia_code.pmRange).toStrictEqual({from: 30, to: 56}); + expect(ia_code.lineStart).toBe(7); expect(ia_nl2.type).toBe("newline"); expect(ia_nl2.contentRange).toStrictEqual({from: 83, to: 84}); @@ -239,12 +260,16 @@ test("Mixed content: markdown, code, input-area, markdown", () => { expect(ia_nl2.prosemirrorStart).toBe(56); expect(ia_nl2.prosemirrorEnd).toBe(56); expect(ia_nl2.pmRange).toStrictEqual({from: 56, to: 57}); + + // Two code blocks: first at line 2, second at line 7 + expect(tree.computeLineNumbers()).toStrictEqual([2, 7]); }); test("Empty codeblock", () => { // ```lan\n\n``` const blocks = [new CodeBlock("", {from: 0, to: 11}, {from: 7, to: 7}, 0)]; - const nodes = createTestMapping(blocks).root.children; + const tree = createTestMapping(blocks); + const nodes = tree.root.children; expect(nodes.length).toBe(1); const code = nodes[0]; @@ -255,4 +280,6 @@ test("Empty codeblock", () => { expect(code.prosemirrorEnd).toBe(1); expect(code.pmRange).toStrictEqual({from: 0, to: 2}); expect(code.lineStart).toBe(0); + + expect(tree.computeLineNumbers()).toStrictEqual([0]); }); \ No newline at end of file diff --git a/__tests__/mapping/nodeupdate.test.ts b/__tests__/mapping/nodeupdate.test.ts index 21076eb..2218181 100644 --- a/__tests__/mapping/nodeupdate.test.ts +++ b/__tests__/mapping/nodeupdate.test.ts @@ -16,17 +16,14 @@ function createMapping(blocks: WaterproofDocument) { return mapping; } -const PLACEHOLDER_LINENR = 0; - -// TODO: Test linenrs test("Insert code underneath markdown", () => { // # Hello - const mapping = createMapping([new MarkdownBlock("# Hello", {from: 0, to: 7}, {from: 0, to: 7}, PLACEHOLDER_LINENR)]); + const mapping = createMapping([new MarkdownBlock("# Hello", {from: 0, to: 7}, {from: 0, to: 7}, 0)]); const slice: Slice = new Slice(Fragment.from([WaterproofSchema.nodes.newline.create(), WaterproofSchema.nodes.code.create()]), 0, 0); const step: ReplaceStep = new ReplaceStep(9, 9, slice); const nodeUpdate = new NodeUpdate(config, serializer); - const {newTree, result} = nodeUpdate.nodeUpdate(step, mapping); + const {newTree, result} = nodeUpdate.nodeUpdate(step, mapping, "# Hello"); expect(result).toStrictEqual({ finalText: "\n```coq\n\n```", @@ -70,6 +67,9 @@ test("Insert code underneath markdown", () => { lineStart: 0, children: [] }) + // After inserting \n```coq\n\n```, the document becomes: # Hello\n```coq\n\n``` + // Line 0: # Hello, Line 1: ```coq, Line 2: (empty code content) + // The new code block should start at line 2 expect(newTree.root.children[2]).toMatchObject({ type: 'code', contentRange: { from: 15, to: 15 }, @@ -78,29 +78,28 @@ test("Insert code underneath markdown", () => { prosemirrorStart: 11, prosemirrorEnd: 11, pmRange: { from: 10, to: 12 }, - lineStart: 0, + lineStart: 2, children: [] }) - console.log("New tree", newTree.root.children[3]) - // TODO: Check new tree structure + + expect(newTree.computeLineNumbers()).toStrictEqual([2]); }); -// TODO: Test linenrs test("Insert code underneath markdown inside input area", () => { // # Hello const mapping = createMapping([ new InputAreaBlock("# Hello", {from: 0, to: 32}, {from: 12, to: 19}, - PLACEHOLDER_LINENR, + 0, [ - new MarkdownBlock("# Hello", {from: 12, to: 19}, {from: 12, to: 19}, PLACEHOLDER_LINENR) + new MarkdownBlock("# Hello", {from: 12, to: 19}, {from: 12, to: 19}, 0) ])]); const slice: Slice = new Slice(Fragment.from([WaterproofSchema.nodes.newline.create(), WaterproofSchema.nodes.code.create()]), 0, 0); const step: ReplaceStep = new ReplaceStep(10, 10, slice); const nodeUpdate = new NodeUpdate(config, serializer); - const {newTree, result} = nodeUpdate.nodeUpdate(step, mapping); + const {newTree, result} = nodeUpdate.nodeUpdate(step, mapping, "# Hello"); console.log(JSON.stringify(newTree.root, null, " ")) sanityCheckTree(newTree.root); expect(result).toStrictEqual({ @@ -109,7 +108,11 @@ test("Insert code underneath markdown inside input area", () => { endInFile: 19 }); - // TODO: Check new tree structure + // After inserting \n```coq\n\n``` at pos 19, document becomes: + // # Hello\n```coq\n\n``` + // Line 0: # Hello, Line 1: ```coq, Line 2: (empty code) + // The new code block should start at line 2 + expect(newTree.computeLineNumbers()).toStrictEqual([2]); }); test("Unwrap input area", () => { @@ -118,29 +121,31 @@ test("Unwrap input area", () => { new InputAreaBlock("# Hello", {from: 0, to: 32}, {from: 12, to: 19}, - PLACEHOLDER_LINENR, + 0, [ - new MarkdownBlock("# Hello", {from: 12, to: 19}, {from: 12, to: 19}, PLACEHOLDER_LINENR) + new MarkdownBlock("# Hello", {from: 12, to: 19}, {from: 12, to: 19}, 0) ])]); const slice: Slice = new Slice(Fragment.from([ ]), 0, 0); const step = new ReplaceAroundStep(0, 11, 1, 10, slice, 0); const nodeUpdate = new NodeUpdate(config, serializer); - const {newTree, result} = nodeUpdate.nodeUpdate(step, mapping); + const {newTree, result} = nodeUpdate.nodeUpdate(step, mapping, ""); console.log(JSON.stringify(newTree.root, null, " ")) sanityCheckTree(newTree.root); expect(result).toStrictEqual({ firstEdit: { finalText: "", startInFile: 0, - endInFile: 12 - }, + endInFile: 12 + }, secondEdit : { finalText: "", startInFile: 19, endInFile: 32 }}); + + expect(newTree.computeLineNumbers()).toStrictEqual([]); }); test("Unwrap hint area", () => { @@ -149,29 +154,31 @@ test("Unwrap hint area", () => { new HintBlock("# Hello", "💡 Hint", {from: 0, to: 36}, {from: 22, to: 29}, - PLACEHOLDER_LINENR, + 0, [ - new MarkdownBlock("# Hello", {from: 22, to: 29}, {from: 22, to: 29}, PLACEHOLDER_LINENR) + new MarkdownBlock("# Hello", {from: 22, to: 29}, {from: 22, to: 29}, 0) ])]); const slice: Slice = new Slice(Fragment.from([ ]), 0, 0); const step = new ReplaceAroundStep(0, 11, 1, 10, slice, 0); const nodeUpdate = new NodeUpdate(config, serializer); - const {newTree, result} = nodeUpdate.nodeUpdate(step, mapping); + const {newTree, result} = nodeUpdate.nodeUpdate(step, mapping, ""); console.log(JSON.stringify(newTree.root, null, " ")) sanityCheckTree(newTree.root); expect(result).toStrictEqual({ firstEdit: { finalText: "", startInFile: 0, - endInFile: 22 - }, + endInFile: 22 + }, secondEdit : { finalText: "", startInFile: 29, endInFile: 36 }}); + + expect(newTree.computeLineNumbers()).toStrictEqual([]); }); test("Unwrap hint area with content after", () => { @@ -181,42 +188,44 @@ test("Unwrap hint area with content after", () => { new HintBlock("# Hello", "💡 Hint", {from: 0, to: 36}, {from: 22, to: 29}, - PLACEHOLDER_LINENR, + 0, [ - new MarkdownBlock("# Hello", {from: 22, to: 29}, {from: 22, to: 29}, PLACEHOLDER_LINENR) + new MarkdownBlock("# Hello", {from: 22, to: 29}, {from: 22, to: 29}, 0) ]), - new MarkdownBlock("# Hellotwo", {from: 36, to: 43}, {from: 36, to: 43}, PLACEHOLDER_LINENR)]); + new MarkdownBlock("# Hellotwo", {from: 36, to: 43}, {from: 36, to: 43}, 0)]); const slice: Slice = new Slice(Fragment.from([ ]), 0, 0); const step = new ReplaceAroundStep(0, 11, 1, 10, slice, 0); const nodeUpdate = new NodeUpdate(config, serializer); - const {newTree, result} = nodeUpdate.nodeUpdate(step, mapping); + const {newTree, result} = nodeUpdate.nodeUpdate(step, mapping, ""); console.log(JSON.stringify(newTree.root, null, " ")) sanityCheckTree(newTree.root); expect(result).toStrictEqual({ firstEdit: { finalText: "", startInFile: 0, - endInFile: 22 - }, + endInFile: 22 + }, secondEdit : { finalText: "", startInFile: 29, endInFile: 36 }}); + + expect(newTree.computeLineNumbers()).toStrictEqual([]); }); test("Wrap markdown in hint area", () => { - const mapping = createMapping([new MarkdownBlock("# Hello", {from: 0, to: 7}, {from: 0, to: 7}, PLACEHOLDER_LINENR)]); + const mapping = createMapping([new MarkdownBlock("# Hello", {from: 0, to: 7}, {from: 0, to: 7}, 0)]); const slice: Slice = new Slice(Fragment.from([ WaterproofSchema.nodes.hint.create()]), 0, 0); const step = new ReplaceAroundStep(0, 9, 0, 9, slice, 1); const nodeUpdate = new NodeUpdate(config, serializer); - const {newTree, result} = nodeUpdate.nodeUpdate(step, mapping); + const {newTree, result} = nodeUpdate.nodeUpdate(step, mapping, ""); sanityCheckTree(newTree.root); expect(result).toStrictEqual({ @@ -231,17 +240,19 @@ test("Wrap markdown in hint area", () => { endInFile: 7 } }) + + expect(newTree.computeLineNumbers()).toStrictEqual([]); }) test("Wrap markdown in input area", () => { - const mapping = createMapping([new MarkdownBlock("# Hello", {from: 0, to: 7}, {from: 0, to: 7}, PLACEHOLDER_LINENR)]); + const mapping = createMapping([new MarkdownBlock("# Hello", {from: 0, to: 7}, {from: 0, to: 7}, 0)]); const slice: Slice = new Slice(Fragment.from([ WaterproofSchema.nodes.input.create()]), 0, 0); const step = new ReplaceAroundStep(0, 9, 0, 9, slice, 1); const nodeUpdate = new NodeUpdate(config, serializer); - const {newTree, result} = nodeUpdate.nodeUpdate(step, mapping); + const {newTree, result} = nodeUpdate.nodeUpdate(step, mapping, ""); sanityCheckTree(newTree.root); expect(result).toStrictEqual({ @@ -256,16 +267,17 @@ test("Wrap markdown in input area", () => { endInFile: 7 } }) + + expect(newTree.computeLineNumbers()).toStrictEqual([]); }) test("Delete a code block between markdown blocks", () => { - // Assumption: Code block tags are ```coq\n (length 7) and \n``` (length 4), - // so tagRange length is content length + 11. - // Assumption: Block ranges are contiguous in the document. + // Document: Hello```coq\nLemma.\n```Bye + // Code block opens at position 5 with ```coq\n, lineStart = 1 (one \n in open tag) const blocks: WaterproofDocument = [ - new MarkdownBlock("Hello", {from: 0, to: 5}, {from: 0, to: 5}, PLACEHOLDER_LINENR), - new CodeBlock("Lemma.", {from: 5, to: 22}, {from: 12, to: 18}, PLACEHOLDER_LINENR), - new MarkdownBlock("Bye", {from: 22, to: 25}, {from: 22, to: 25}, PLACEHOLDER_LINENR) + new MarkdownBlock("Hello", {from: 0, to: 5}, {from: 0, to: 5}, 0), + new CodeBlock("Lemma.", {from: 5, to: 22}, {from: 12, to: 18}, 1), + new MarkdownBlock("Bye", {from: 22, to: 25}, {from: 22, to: 25}, 0) ]; const mapping = createMapping(blocks); @@ -277,7 +289,7 @@ test("Delete a code block between markdown blocks", () => { const step = new ReplaceStep(codeNode.pmRange.from, codeNode.pmRange.to, Slice.empty); const nodeUpdate = new NodeUpdate(config, serializer); - const { newTree, result } = nodeUpdate.nodeUpdate(step, mapping); + const { newTree, result } = nodeUpdate.nodeUpdate(step, mapping, ""); expect(result).toStrictEqual({ finalText: "", @@ -295,15 +307,18 @@ test("Delete a code block between markdown blocks", () => { expect(newTree.root.children[1].contentRange).toStrictEqual({from: 5, to: 8}); expect(newTree.root.children[1].tagRange).toStrictEqual({from: 5, to: 8}); expect(newTree.root.contentRange.to).toBe(8); + + // After deleting the code block, no code remains + expect(newTree.computeLineNumbers()).toStrictEqual([]); }); test("Delete adjacent code and markdown blocks", () => { - // Assumption: Block ranges are contiguous in the document. - // Assumption: Deleting a prosemirror range that fully covers nodes removes those nodes entirely. + // Document: A```coq\nX\n```B + // Code block opens at position 1 with ```coq\n, lineStart = 1 const blocks: WaterproofDocument = [ - new MarkdownBlock("A", {from: 0, to: 1}, {from: 0, to: 1}, PLACEHOLDER_LINENR), - new CodeBlock("X", {from: 1, to: 13}, {from: 8, to: 9}, PLACEHOLDER_LINENR), - new MarkdownBlock("B", {from: 13, to: 14}, {from: 13, to: 14}, PLACEHOLDER_LINENR) + new MarkdownBlock("A", {from: 0, to: 1}, {from: 0, to: 1}, 0), + new CodeBlock("X", {from: 1, to: 13}, {from: 8, to: 9}, 1), + new MarkdownBlock("B", {from: 13, to: 14}, {from: 13, to: 14}, 0) ]; const mapping = createMapping(blocks); @@ -315,7 +330,7 @@ test("Delete adjacent code and markdown blocks", () => { const step = new ReplaceStep(codeNode.pmRange.from, trailingMarkdown.pmRange.to, Slice.empty); const nodeUpdate = new NodeUpdate(config, serializer); - const { newTree, result } = nodeUpdate.nodeUpdate(step, mapping); + const { newTree, result } = nodeUpdate.nodeUpdate(step, mapping, ""); expect(result).toStrictEqual({ finalText: "", @@ -329,12 +344,15 @@ test("Delete adjacent code and markdown blocks", () => { expect(newTree.root.children[0].type).toBe("markdown"); expect(newTree.root.children[0].contentRange).toStrictEqual({from: 0, to: 1}); expect(newTree.root.contentRange.to).toBe(1); + + // After deleting code and markdown blocks, no code remains + expect(newTree.computeLineNumbers()).toStrictEqual([]); }); test("Delete markdown cell", () => { const mapping = createMapping([ - new MarkdownBlock("# Hello", {from: 0, to: 7}, {from: 0, to: 7}, PLACEHOLDER_LINENR), - new MarkdownBlock("# Hello", {from: 7, to: 14}, {from: 7, to: 14}, PLACEHOLDER_LINENR) + new MarkdownBlock("# Hello", {from: 0, to: 7}, {from: 0, to: 7}, 0), + new MarkdownBlock("# Hello", {from: 7, to: 14}, {from: 7, to: 14}, 0) ]); const slice: Slice = new Slice(Fragment.from([]), 0, 0); @@ -342,7 +360,7 @@ test("Delete markdown cell", () => { const step = new ReplaceStep(9, 18, slice); const nodeUpdate = new NodeUpdate(config, serializer); - const {newTree, result} = nodeUpdate.nodeUpdate(step, mapping); + const {newTree, result} = nodeUpdate.nodeUpdate(step, mapping, ""); sanityCheckTree(newTree.root); expect(result).toStrictEqual({ @@ -350,6 +368,8 @@ test("Delete markdown cell", () => { startInFile: 7, endInFile: 14 }) + + expect(newTree.computeLineNumbers()).toStrictEqual([]); }) @@ -361,16 +381,17 @@ test("Complex deletion", () => { // Code // // + // Line counting: \n at pos 31 (after Md), \n at pos 38 (in ```coq\n) → code at line 2 const mapping = createMapping([ - new MarkdownBlock("# Hello", {from: 0, to: 7}, {from: 0, to: 7}, PLACEHOLDER_LINENR), + new MarkdownBlock("# Hello", {from: 0, to: 7}, {from: 0, to: 7}, 0), new HintBlock("Md", "💡 Hint", {from: 7, to: 54}, {from: 29, to: 47}, - PLACEHOLDER_LINENR, + 0, [ - new MarkdownBlock("Md", {from: 29, to: 31}, {from: 29, to: 31}, PLACEHOLDER_LINENR), - new NewlineBlock({from: 31, to: 32}, {from: 31, to: 32}, PLACEHOLDER_LINENR), - new CodeBlock("Code", {from: 32, to: 48}, {from: 39, to: 43}, PLACEHOLDER_LINENR) + new MarkdownBlock("Md", {from: 29, to: 31}, {from: 29, to: 31}, 0), + new NewlineBlock({from: 31, to: 32}, {from: 31, to: 32}, 0), + new CodeBlock("Code", {from: 32, to: 48}, {from: 39, to: 43}, 2) ])]); const slice: Slice = new Slice(Fragment.from([]), 0, 0); @@ -379,7 +400,7 @@ test("Complex deletion", () => { const nodeUpdate = new NodeUpdate(config, serializer); - const {newTree, result} = nodeUpdate.nodeUpdate(step, mapping); + const {newTree, result} = nodeUpdate.nodeUpdate(step, mapping, ""); console.log(JSON.stringify(newTree.root, null, " ")); sanityCheckTree(newTree.root); @@ -388,6 +409,9 @@ test("Complex deletion", () => { startInFile: 7, endInFile: 54 }) + + // After deleting the entire hint block, no code blocks remain + expect(newTree.computeLineNumbers()).toStrictEqual([]); }) test("Complex deletion undo", () => { @@ -400,7 +424,7 @@ test("Complex deletion undo", () => { // // Then remove hint block and undo const mapping = createMapping([ - new MarkdownBlock("# Hello", {from: 0, to: 7}, {from: 0, to: 7}, PLACEHOLDER_LINENR), + new MarkdownBlock("# Hello", {from: 0, to: 7}, {from: 0, to: 7}, 0), ]); const hint = WaterproofSchema.nodes.hint.create({title: "💡 Hint"}, @@ -423,7 +447,7 @@ test("Complex deletion undo", () => { const nodeUpdate = new NodeUpdate(config, serializer); - const {newTree, result} = nodeUpdate.nodeUpdate(step, mapping); + const {newTree, result} = nodeUpdate.nodeUpdate(step, mapping, "# Hello"); console.log(JSON.stringify(newTree.root, null, " ")); sanityCheckTree(newTree.root); @@ -432,4 +456,48 @@ test("Complex deletion undo", () => { startInFile: 7, endInFile: 7 }) -}) \ No newline at end of file + // After reinserting the hint with code, the code block starts at line 2: + // Line 0: # HelloMd, Line 1: ```coq, Line 2: Code + expect(newTree.computeLineNumbers()).toStrictEqual([2]);}) + +test("Insert code after markdown-newline-markdown: linecount reflects prior newline", () => { + // Document: "# Hello\n# World" — one newline before the insertion point + const mapping = createMapping([ + new MarkdownBlock("# Hello", {from: 0, to: 7}, {from: 0, to: 7}, 0), + new NewlineBlock({from: 7, to: 8}, {from: 7, to: 8}, 0), + new MarkdownBlock("# World", {from: 8, to: 15}, {from: 8, to: 15}, 0), + ]); + + // Insert [newline, code] after "# World" (prose pos 19 = pmRange.to of "# World") + const slice = new Slice(Fragment.from([WaterproofSchema.nodes.newline.create(), WaterproofSchema.nodes.code.create()]), 0, 0); + const step = new ReplaceStep(19, 19, slice); + + const nodeUpdate = new NodeUpdate(config, serializer); + const {newTree} = nodeUpdate.nodeUpdate(step, mapping, "# Hello\n# World"); + + // The serialized doc has 1 newline before the insertion point (documentPos=15). + // The inserted newline adds 1 more → lineCounter=2 when the code node is built. + // The code open tag ("```coq\n") adds 1 more → contentLineStart=3. + expect(newTree.computeLineNumbers()).toStrictEqual([3]); +}); + +test("Insert code after existing code block: linecount accounts for all prior tags", () => { + // Document: "Hello\n```coq\nCode\n```" — three newlines before the insertion point + const mapping = createMapping([ + new MarkdownBlock("Hello", {from: 0, to: 5}, {from: 0, to: 5}, 0), + new NewlineBlock({from: 5, to: 6}, {from: 5, to: 6}, 0), + new CodeBlock("Code", {from: 6, to: 21}, {from: 13, to: 17}, 1), + ]); + + // Insert [newline, code] after the existing code block (prose pos 14 = pmRange.to of CodeBlock) + const slice = new Slice(Fragment.from([WaterproofSchema.nodes.newline.create(), WaterproofSchema.nodes.code.create()]), 0, 0); + const step = new ReplaceStep(14, 14, slice); + + const nodeUpdate = new NodeUpdate(config, serializer); + const {newTree} = nodeUpdate.nodeUpdate(step, mapping, "Hello\n```coq\nCode\n```"); + + // The serialized doc has 3 newlines before the insertion point (documentPos=21). + // The inserted newline adds 1 more → lineCounter=4 when the code node is built. + // The code open tag ("```coq\n") adds 1 more → contentLineStart=5. + expect(newTree.computeLineNumbers()).toStrictEqual([1, 5]); +}); \ No newline at end of file diff --git a/__tests__/mapping/textupdate.test.ts b/__tests__/mapping/textupdate.test.ts index ba0f775..15bbd24 100644 --- a/__tests__/mapping/textupdate.test.ts +++ b/__tests__/mapping/textupdate.test.ts @@ -15,8 +15,6 @@ function createMapping(doc: WaterproofDocument) { return mapping; } -const PLACEHOLDER_LINENR = 0; - function findFirstCodeNode(root: TreeNode): TreeNode | null { let found: TreeNode | null = null; root.traverseDepthFirst((node: TreeNode) => { @@ -26,9 +24,8 @@ function findFirstCodeNode(root: TreeNode): TreeNode | null { return found; } -// TODO: Test linenrs test("ReplaceStep insert — inserts text into a block", () => { - const blocks = [new MarkdownBlock("Hello", {from: 0, to: 5}, {from: 0, to: 5}, PLACEHOLDER_LINENR)]; + const blocks = [new MarkdownBlock("Hello", {from: 0, to: 5}, {from: 0, to: 5}, 0)]; const mapping = createMapping(blocks); const slice: Slice = new Slice(Fragment.from(WaterproofSchema.text(" world")), 0, 0); const step: ReplaceStep = new ReplaceStep(6, 6, slice); @@ -51,11 +48,13 @@ test("ReplaceStep insert — inserts text into a block", () => { startInFile: 5, endInFile: 5 }); + + // No code blocks + expect(newTree.computeLineNumbers()).toStrictEqual([]); }); -const helloWorldMarkdownBlock = new MarkdownBlock("Hello world", {from: 0, to: 11}, {from: 0, to: 11}, PLACEHOLDER_LINENR); +const helloWorldMarkdownBlock = new MarkdownBlock("Hello world", {from: 0, to: 11}, {from: 0, to: 11}, 0); -// TODO: Test linenrs test("ReplaceStep insert — inserts text in the middle of a block", () => { const mapping = createMapping([helloWorldMarkdownBlock]); const slice: Slice = new Slice(Fragment.from(WaterproofSchema.text("big ")), 0, 0); @@ -79,6 +78,8 @@ test("ReplaceStep insert — inserts text in the middle of a block", () => { startInFile: 6, endInFile: 6 }); + + expect(newTree.computeLineNumbers()).toStrictEqual([]); }); test("ReplaceStep delete — deletes part of a block", () => { @@ -102,6 +103,8 @@ test("ReplaceStep delete — deletes part of a block", () => { startInFile: 6, endInFile: 11 }) + + expect(newTree.computeLineNumbers()).toStrictEqual([]); }); @@ -128,18 +131,20 @@ test("ReplaceStep replace — replaces part of a block", () => { startInFile: 6, endInFile: 11 }); + + expect(newTree.computeLineNumbers()).toStrictEqual([]); }); test("ReplaceStep insert — nested code inside input shifts wrapper and later blocks", () => { - // Assumption: Input areas contain newline, code, newline blocks in order. - // Assumption: Block ranges are contiguous in the document. + // Document: \n```coq\nTest\n```\nAfter + // Line counting: \n at pos 12 (line 1), \n at pos 19 in ```coq\n (line 2) → code starts at line 2 const blocks = [ - new InputAreaBlock("```coq\nTest\n```", {from: 0, to: 42}, {from: 12, to: 29}, PLACEHOLDER_LINENR, [ - new NewlineBlock({from: 12, to: 13}, {from: 12, to: 13}, PLACEHOLDER_LINENR), - new CodeBlock("Test", {from: 13, to: 28}, {from: 20, to: 24}, PLACEHOLDER_LINENR), - new NewlineBlock({from: 28, to: 29}, {from: 28, to: 29}, PLACEHOLDER_LINENR) + new InputAreaBlock("```coq\nTest\n```", {from: 0, to: 42}, {from: 12, to: 29}, 0, [ + new NewlineBlock({from: 12, to: 13}, {from: 12, to: 13}, 0), + new CodeBlock("Test", {from: 13, to: 28}, {from: 20, to: 24}, 2), + new NewlineBlock({from: 28, to: 29}, {from: 28, to: 29}, 0) ]), - new MarkdownBlock("After", {from: 42, to: 47}, {from: 42, to: 47}, PLACEHOLDER_LINENR) + new MarkdownBlock("After", {from: 42, to: 47}, {from: 42, to: 47}, 0) ]; const mapping = createMapping(blocks); @@ -179,4 +184,10 @@ test("ReplaceStep insert — nested code inside input shifts wrapper and later b expect(updatedInput.contentRange.to).toBe(inputContentEnd + 1); expect(updatedAfter.contentRange.from).toBe(afterContentStart + 1); expect(updatedAfter.tagRange.from).toBe(afterTagStart + 1); + + // Inserting "X" (no newlines) should preserve the code block's lineStart + const updatedCode = findFirstCodeNode(newTree.root); + expect(updatedCode).not.toBeNull(); + expect(updatedCode!.lineStart).toBe(2); + expect(newTree.computeLineNumbers()).toStrictEqual([2]); }); \ No newline at end of file diff --git a/__tests__/nodeview.test.ts b/__tests__/nodeview.test.ts index a3e742d..c7a257c 100644 --- a/__tests__/nodeview.test.ts +++ b/__tests__/nodeview.test.ts @@ -136,6 +136,36 @@ function makeView() { return new CodeBlockView(node, {editable: true}, null, () => undefined, null, [], [], ThemeStyle.Light); } +/** This functionality ensures that the selection is displayed (in particular when using the ctrl+. shortcut to select) */ +describe("CodeBlockView selectNode / deselectNode", () => { + test("selectNode adds ProseMirror-selectednode class", () => { + const nv = makeView(); + expect(nv.dom).toBeInstanceOf(HTMLElement); + expect((nv.dom as HTMLElement).classList.contains("ProseMirror-selectednode")).toBe(false); + + nv.selectNode(); + expect((nv.dom as HTMLElement).classList.contains("ProseMirror-selectednode")).toBe(true); + }); + + test("deselectNode removes ProseMirror-selectednode class", () => { + const nv = makeView(); + (nv.dom as HTMLElement).classList.add("ProseMirror-selectednode"); + + nv.deselectNode(); + expect((nv.dom as HTMLElement).classList.contains("ProseMirror-selectednode")).toBe(false); + }); + + test("selectNode then deselectNode round-trips correctly", () => { + const nv = makeView(); + + nv.selectNode(); + expect((nv.dom as HTMLElement).classList.contains("ProseMirror-selectednode")).toBe(true); + + nv.deselectNode(); + expect((nv.dom as HTMLElement).classList.contains("ProseMirror-selectednode")).toBe(false); + }); +}); + describe("CodeBlockView busy indicator", () => { test("removeBusyIndicator is a no-op when codemirror is absent", () => { const nv = makeView(); diff --git a/src/codeview/code-plugin.ts b/src/codeview/code-plugin.ts index 5c18aa8..06336dc 100644 --- a/src/codeview/code-plugin.ts +++ b/src/codeview/code-plugin.ts @@ -16,8 +16,8 @@ import { WaterproofEditor } from "../editor"; export interface ICodePluginState { macros: { [cmd:string] : string }; - /** A list of currently active `NodeView`s, in insertion order. */ - activeNodeViews: Set; // I suspect this will break; + /** A set of currently active `NodeView`s in insertion order. Note that insertion order does not necessarily match document order */ + activeNodeViews: Set; /** The schema of the outer editor */ schema: Schema; /** Should the codemirror cells show line numbers */ @@ -46,6 +46,7 @@ export function createCoqCodeView(completions: Array, symbols: Array const nodeView = new CodeBlockView(node, view, editorInstance, getPos, pluginState.schema, completions, symbols, initialThemeStyle, languageConfig); nodeViews.add(nodeView); + return nodeView; } } @@ -72,9 +73,11 @@ const CoqCodePluginSpec = (completions: Array, symbols: Array= step.from && view._getPos() < step.to)) value.activeNodeViews.delete(view); - } + const pos = view._getPos(); + if (pos === undefined || (pos >= step.from && pos < step.to)) { + value.activeNodeViews.delete(view); + } + } } } } @@ -88,10 +91,10 @@ const CoqCodePluginSpec = (completions: Array, symbols: Array (a._getPos() ?? 0) - (b._getPos() ?? 0)); + for (let i = 0; i < sorted.length; i++) { + sorted[i].updateLineNumbers(newlines[i] + 1, lineState); } } } diff --git a/src/commands/command-helpers.ts b/src/commands/command-helpers.ts index 1b3fbef..9633cf4 100644 --- a/src/commands/command-helpers.ts +++ b/src/commands/command-helpers.ts @@ -5,7 +5,8 @@ import { EditorState, TextSelection, Transaction, Selection, NodeSelection } fro import { INPUT_AREA_PLUGIN_KEY } from "../inputArea"; import { WaterproofSchema } from "../schema"; import { newline } from "../document/blocks/schema"; -import { getParentAndIndex } from "./utils"; +import { getParentAndIndex, needsNewlineAfter, needsNewlineBefore } from "./utils"; +import { TagConfiguration } from "../api"; /////// Helper functions ///////// @@ -16,7 +17,7 @@ import { getParentAndIndex } from "./utils"; * @param nodeType The type of node to insert (one of `WaterproofSchema.nodes`) * @returns An insertion transaction. */ -export function insertAbove(state: EditorState, tr: Transaction, nodeType: NodeType, insertNewlineBeforeIfNotExists: boolean, insertNewlineAfterIfNotExists: boolean): Transaction | undefined { +export function insertAbove(state: EditorState, tr: Transaction, nodeType: NodeType, insertNewlineBeforeIfNotExists: boolean, insertNewlineAfterIfNotExists: boolean, tagConf: TagConfiguration): Transaction | undefined { const sel = state.selection; let trans: Transaction = tr; @@ -50,7 +51,12 @@ export function insertAbove(state: EditorState, tr: Transaction, nodeType: NodeT const toInsert: PNode[] = []; - if (insertNewlineBeforeIfNotExists && !hasNewlineBefore && beforeIsNewline) { + const nodeAboveInsertion = beforeIsNewline ? beforeNewline : nodeAboveSelection; + const newlineAlreadyAbove = beforeIsNewline ? hasNewlineBefore : false; + const aboveNeedsNewlineAfter = nodeAboveInsertion !== null && needsNewlineAfter(nodeAboveInsertion.type, tagConf); + + if ((insertNewlineBeforeIfNotExists && !hasNewlineBefore && beforeIsNewline) || + (aboveNeedsNewlineAfter && !newlineAlreadyAbove)) { toInsert.push(newline()); } toInsert.push(nodeType.create()); @@ -70,7 +76,7 @@ export function insertAbove(state: EditorState, tr: Transaction, nodeType: NodeT * @param nodeType The type of node to insert (one of `WaterproofSchema.nodes`) * @returns An insertion transaction. */ -export function insertBelow(state: EditorState, tr: Transaction, nodeType: NodeType, insertNewlineBeforeIfNotExists: boolean, insertNewlineAfterIfNotExists: boolean): Transaction | undefined { +export function insertBelow(state: EditorState, tr: Transaction, nodeType: NodeType, insertNewlineBeforeIfNotExists: boolean, insertNewlineAfterIfNotExists: boolean, tagConf: TagConfiguration): Transaction | undefined { const sel = state.selection; let trans: Transaction = tr; @@ -101,12 +107,17 @@ export function insertBelow(state: EditorState, tr: Transaction, nodeType: NodeT const hasNewlineAfter = afterNewline === null ? false : afterNewline.type === WaterproofSchema.nodes.newline; + const nodeBelowInsertion = afterIsNewline ? afterNewline : nodeBelowSelection; + const newlineAlreadyBelow = afterIsNewline ? hasNewlineAfter : false; + const belowNeedsNewlineBefore = nodeBelowInsertion !== null && needsNewlineBefore(nodeBelowInsertion.type, tagConf); + const toInsert: PNode[] = []; if (insertNewlineBeforeIfNotExists && !afterIsNewline) { toInsert.push(newline()); } toInsert.push(nodeType.create()); - if (insertNewlineAfterIfNotExists && !hasNewlineAfter && afterIsNewline) { + if ((insertNewlineAfterIfNotExists && !hasNewlineAfter && afterIsNewline) || + (belowNeedsNewlineBefore && !newlineAlreadyBelow)) { toInsert.push(newline()); } diff --git a/src/commands/insert-command.ts b/src/commands/insert-command.ts index a399ba3..009161e 100644 --- a/src/commands/insert-command.ts +++ b/src/commands/insert-command.ts @@ -16,7 +16,7 @@ export function getCmdInsertMarkdown(place: InsertionPlace, tagConf: TagConfigur const f = place === InsertionPlace.Above ? insertAbove : insertBelow; - const trans = f(state, state.tr, WaterproofSchema.nodes.markdown, tagConf.markdown.openRequiresNewline, tagConf.markdown.closeRequiresNewline); + const trans = f(state, state.tr, WaterproofSchema.nodes.markdown, tagConf.markdown.openRequiresNewline, tagConf.markdown.closeRequiresNewline, tagConf); if (trans === undefined) { return false; } @@ -34,7 +34,7 @@ export function getCmdInsertLatex(place: InsertionPlace, tagConf: TagConfigurati if (!allowedToInsert(state)) return false; const f = place === InsertionPlace.Above ? insertAbove : insertBelow; - const trans = f(state, state.tr, WaterproofSchema.nodes.math_display, tagConf.math.openRequiresNewline, tagConf.math.closeRequiresNewline); + const trans = f(state, state.tr, WaterproofSchema.nodes.math_display, tagConf.math.openRequiresNewline, tagConf.math.closeRequiresNewline, tagConf); if (trans === undefined) { return false; } @@ -52,7 +52,7 @@ export function getCmdInsertCode(place: InsertionPlace, tagConf: TagConfiguratio if (!allowedToInsert(state)) return false; const f = place === InsertionPlace.Above ? insertAbove : insertBelow; - const trans = f(state, state.tr, WaterproofSchema.nodes.code, tagConf.code.openRequiresNewline, tagConf.code.closeRequiresNewline); + const trans = f(state, state.tr, WaterproofSchema.nodes.code, tagConf.code.openRequiresNewline, tagConf.code.closeRequiresNewline, tagConf); if (trans === undefined) { return false; } diff --git a/src/embedded-codemirror/embeddedCodemirror.ts b/src/embedded-codemirror/embeddedCodemirror.ts index ebcd690..efef915 100644 --- a/src/embedded-codemirror/embeddedCodemirror.ts +++ b/src/embedded-codemirror/embeddedCodemirror.ts @@ -97,8 +97,17 @@ export class EmbeddedCodeMirrorEditor implements NodeView { return true; } - selectNode?: (() => void) | undefined; - deselectNode?: (() => void) | undefined; + selectNode() { + if (this.dom instanceof HTMLElement) { + this.dom.classList.add("ProseMirror-selectednode"); + } + } + + deselectNode() { + if (this.dom instanceof HTMLElement) { + this.dom.classList.remove("ProseMirror-selectednode"); + } + } setSelection(anchor: number, head: number, _root: Document | ShadowRoot) { // Focus on the internal codemirror instance. diff --git a/src/mapping/Tree.ts b/src/mapping/Tree.ts index ada7b6d..4eaee51 100644 --- a/src/mapping/Tree.ts +++ b/src/mapping/Tree.ts @@ -195,4 +195,4 @@ export class Tree { }); return arr; } -} +} \ No newline at end of file diff --git a/src/mapping/mapping.ts b/src/mapping/mapping.ts index 10090c4..3e57709 100644 --- a/src/mapping/mapping.ts +++ b/src/mapping/mapping.ts @@ -18,6 +18,7 @@ export class Mapping { /** The version of the underlying textDocument */ private _version: number; + private readonly serializer: DocumentSerializer; private readonly nodeUpdate: NodeUpdate; private readonly textUpdate: TextUpdate; @@ -26,6 +27,7 @@ export class Mapping { * @param inputBlocks Array containing the blocks that make up this document. */ constructor(inputBlocks: Block[], versionNum: number, tMap: TagConfiguration, serializer: DocumentSerializer) { + this.serializer = serializer; this.textUpdate = new TextUpdate(); this.nodeUpdate = new NodeUpdate(tMap, serializer); this._version = versionNum; @@ -120,8 +122,13 @@ export class Mapping { let result: ParsedStep; // Parse the step into a text document change - if (step instanceof ReplaceStep && isText) result = this.textUpdate.textUpdate(step, this); - else result = this.nodeUpdate.nodeUpdate(step, this); + if (step instanceof ReplaceStep && isText) { + result = this.textUpdate.textUpdate(step, this); + } else { + // The entire document is serialized here. This is done to be able to produce an accurate linecount + // If this leads to performance issues, this could likely be resolved by being smarter about this. + result = this.nodeUpdate.nodeUpdate(step, this, this.serializer.serializeDocument(doc), this.serializer, doc); + } this.tree = result.newTree diff --git a/src/mapping/nodeUpdate.ts b/src/mapping/nodeUpdate.ts index 8d740d6..e3f17c2 100644 --- a/src/mapping/nodeUpdate.ts +++ b/src/mapping/nodeUpdate.ts @@ -7,6 +7,14 @@ import { WaterproofSchema } from "../schema"; import { Node } from "prosemirror-model"; import { ReplaceAroundStep, ReplaceStep } from "prosemirror-transform"; +function countNewlines(s: string): number { + let count = 0; + for (let i = 0; i < s.length; i++) { + if (s[i] === '\n') count++; + } + return count; +} + export class NodeUpdate { // Store the tag configuration and serializer constructor (private readonly tagConf: TagConfiguration, private readonly serializer: DocumentSerializer) {} @@ -30,11 +38,11 @@ export class NodeUpdate { } // Handle a node update step - public nodeUpdate(step: ReplaceStep | ReplaceAroundStep, mapping: Mapping) : ParsedStep { + public nodeUpdate(step: ReplaceStep | ReplaceAroundStep, mapping: Mapping, serializedDoc: string, serializer: DocumentSerializer, proseDoc: Node) : ParsedStep { let parsedStep; if (step instanceof ReplaceStep) { // The step is a ReplaceStep - parsedStep = this.doReplaceStep(step, mapping); + parsedStep = this.doReplaceStep(step, mapping, serializedDoc, serializer, proseDoc); } else { // The step is a ReplaceAroundStep (wrapping or unwrapping of nodes) parsedStep = this.doReplaceAroundStep(step, mapping); @@ -42,15 +50,15 @@ export class NodeUpdate { return parsedStep; } - doReplaceStep(step: ReplaceStep, mapping: Mapping): ParsedStep { + doReplaceStep(step: ReplaceStep, mapping: Mapping, serializedDoc: string, serializer: DocumentSerializer, proseDoc: Node): ParsedStep { // Determine operation type const type = typeFromStep(step); console.log("In doReplaceStep, operation type:", type); switch (type) { case OperationType.insert: - return this.replaceInsert(step, mapping.getMapping()); + return this.replaceInsert(step, mapping.getMapping(), serializedDoc); case OperationType.delete: - return this.replaceDelete(step, mapping.getMapping()); + return this.replaceDelete(step, mapping.getMapping(), serializer, proseDoc); case OperationType.replace: throw new NodeUpdateError(" We do not support ReplaceSteps that replace nodes with other nodes (textual replaces are handled in the textUpdate module) "); } @@ -74,7 +82,7 @@ export class NodeUpdate { // ReplaceInsert is used when we insert new nodes into the document // Note: that these steps can be quite complex, as they can contain multiple (nested) nodes // for example undoing a node deletion 'reinserts' the deleted node(s) - replaceInsert(step: ReplaceStep, tree: Tree): ParsedStep { + replaceInsert(step: ReplaceStep, tree: Tree, serializedDoc: string): ParsedStep { // We start by checking that there is something to insert in the step if (!step.slice.content.childCount) { throw new NodeUpdateError(" ReplaceStep insert has no content "); @@ -98,6 +106,9 @@ export class NodeUpdate { const nodes: TreeNode[] = []; let serialized = ""; + // We use the fully serialized document to determine an accurate linecount + // If this causes performance issues, we could likely fix this by being smarter about it. + let lineCounter = countNewlines(serializedDoc.substring(0, documentPos)); step.slice.content.forEach((node, _, idx) => { const parentContent = step.slice.content; @@ -119,10 +130,11 @@ export class NodeUpdate { }; const output = this.serializer.serializeNode(node, parent.type, func); serialized += output; - const builtNode = this.buildTreeFromNode(node, offsetOriginal, offsetProse); + const builtNode = this.buildTreeFromNode(node, offsetOriginal, offsetProse, lineCounter); nodes.push(builtNode); offsetOriginal += output.length; offsetProse += node.nodeSize; + lineCounter += countNewlines(output); }); const docChange: DocChange = { @@ -134,29 +146,33 @@ export class NodeUpdate { const proseOffset = step.slice.content.size; const textOffset = serialized.length; + const lineDelta = countNewlines(serialized); + // now we need to update the tree tree.traverseDepthFirst((thisNode: TreeNode) => { - // Update all nodes that come fully after the insertion position - if (thisNode.pmRange.from >= step.to) { - thisNode.shiftOffsets(textOffset, proseOffset); - } + // Skip the root node — it's handled separately below + if (thisNode === tree.root) return; - // The inserted nodes could be children of nodes already in the tree (at least of the root node, - // but possibly also of hint or input nodes) - if (thisNode.pmRange.from < step.from && thisNode.pmRange.to > step.to) { + if (thisNode.pmRange.from < step.from && thisNode.pmRange.to > step.from) { + // This node strictly contains the insertion point (parent/ancestor) + // Only shift closing offsets thisNode.shiftCloseOffsets(textOffset, proseOffset); + } else if (thisNode.pmRange.from >= step.to) { + // This node starts at or after the insertion position (sibling) + thisNode.shiftOffsets(textOffset, proseOffset); + thisNode.shiftLineStart(lineDelta); } }); + // The root always contains the insertion + tree.root.shiftCloseOffsets(textOffset, proseOffset); // Add the nodes to the parent node. We do this later so that updating in the step // before does not affect the positions of the nodes we are adding nodes.forEach(n => parent.addChild(n)); - - return { result: docChange, newTree: tree }; + return { result: docChange, newTree: tree, lineDelta: lineDelta }; } - buildTreeFromNode(node: Node, startOrig: number, startProse: number): TreeNode { - + buildTreeFromNode(node: Node, startOrig: number, startProse: number, currentLine: number = 0): TreeNode { // Shortcut for newline nodes if (node.type == WaterproofSchema.nodes.newline) { return new TreeNode( @@ -184,6 +200,9 @@ export class NodeUpdate { const [openTagForNode, closeTagForNode] = this.nodeNameToTagPair(node.type.name, node.attrs.title ? node.attrs.title : ""); + const contentLineStart = currentLine + countNewlines(openTagForNode); + const lineStart = (node.type.name === "code" || node.type.name === "math_display") ? contentLineStart : 0; + const treeNode = new TreeNode( node.type.name, // node type {from: startOrig + openTagForNode.length, to: 0}, // inner range @@ -191,15 +210,16 @@ export class NodeUpdate { node.attrs.title ? node.attrs.title : "", // title startProse + 1, 0, // prosemirror start, end {from: startProse, to: 0}, - 0 + lineStart ); let childOffsetOriginal = startOrig + openTagForNode.length; let childOffsetProse = startProse + 1; // +1 for the opening tag + let childLine = contentLineStart; node.forEach((child, _, idx) => { - const childTreeNode = this.buildTreeFromNode(child, childOffsetOriginal, childOffsetProse); + const childTreeNode = this.buildTreeFromNode(child, childOffsetOriginal, childOffsetProse, childLine); treeNode.children.push(childTreeNode); // Above @@ -224,6 +244,7 @@ export class NodeUpdate { const serializedChild = this.serializer.serializeNode(child, node.type.name, func); childOffsetOriginal += serializedChild.length; childOffsetProse += child.nodeSize; + childLine += countNewlines(serializedChild); }); // Now fill in the to positions for innerRange and range @@ -240,11 +261,26 @@ export class NodeUpdate { * @param tree The input tree * @returns A ParsedStep containing the resulting DocChange and the updated tree. */ - replaceDelete(step: ReplaceStep, tree: Tree): ParsedStep { + replaceDelete(step: ReplaceStep, tree: Tree, serializer: DocumentSerializer, proseDoc: Node): ParsedStep { // Find all nodes that are fully in the deleted range const nodesToDelete: TreeNode[] = []; let from = Number.POSITIVE_INFINITY; let to = Number.NEGATIVE_INFINITY; + + + const origDocStart = step.from; + const origDocEnd = step.to; + + // Figure out how many newlines are in the deleted content, needed to update the + // line numbers of the nodes that come after the deleted nodes. + const parentNodeType = proseDoc.resolve(origDocStart).parent.type.name; + const parentNode = parentNodeType === "doc" ? null : parentNodeType; + // Get the slice of the document that will be deleted, serialize it and count the newlines in it + const { content } = proseDoc.slice(origDocStart, origDocEnd); + const str = serializer.serializeFragment(content, parentNode); + const deletedNewlines = countNewlines(str); + + // First pass: identify nodes to delete tree.traverseDepthFirst((node: TreeNode) => { if (node.prosemirrorStart >= step.from && node.prosemirrorEnd <= step.to) { nodesToDelete.push(node); @@ -252,14 +288,17 @@ export class NodeUpdate { if (node.tagRange.from < from) from = node.tagRange.from; if (node.tagRange.to > to) to = node.tagRange.to; - // Remove from the tree immediately (saves an O(n) traversal over nodesToDelete later) - const parent = tree.findParent(node); - if (parent) { - parent.removeChild(node); - } } }); + // Second pass: remove from tree + for (const node of nodesToDelete) { + const parent = tree.findParent(node); + if (parent) { + parent.removeChild(node); + } + } + if (nodesToDelete.length == 0) { throw new NodeUpdateError("Could not find any nodes to delete in the given step."); } @@ -281,11 +320,12 @@ export class NodeUpdate { // only shift nodes that come after the deleted nodes if (thisNode.prosemirrorStart >= step.to) { thisNode.shiftOffsets(-originalRemovedLength, -proseRemovedLength); + thisNode.shiftLineStart(-deletedNewlines); } }); tree.root.shiftCloseOffsets(-originalRemovedLength, -proseRemovedLength); - return { result: docChange, newTree: tree }; + return { result: docChange, newTree: tree, lineDelta: -deletedNewlines }; } // ReplaceAroundDelete is used when we unwrap nodes (remove the hint or input tags) @@ -346,7 +386,7 @@ export class NodeUpdate { wrapperParent.addChild(n); }); - return { result: docChange, newTree: tree }; + return { result: docChange, newTree: tree, lineDelta: 0 }; } replaceAroundReplace(step: ReplaceAroundStep, tree: Tree): ParsedStep { @@ -449,7 +489,7 @@ export class NodeUpdate { tree.root.shiftCloseOffsets(openTag.length + closeTag.length, 2); - return {result: docChange, newTree: tree}; + return {result: docChange, newTree: tree, lineDelta: 0}; } } diff --git a/src/mapping/textUpdate.ts b/src/mapping/textUpdate.ts index 9545c15..55e4fa3 100644 --- a/src/mapping/textUpdate.ts +++ b/src/mapping/textUpdate.ts @@ -85,7 +85,7 @@ export class TextUpdate { } }); - return {result, newTree: tree}; + return {result, newTree: tree, lineDelta: 0}; } } diff --git a/src/mapping/types.ts b/src/mapping/types.ts index 8ab833b..c88ac5e 100644 --- a/src/mapping/types.ts +++ b/src/mapping/types.ts @@ -8,7 +8,9 @@ export type ParsedStep = { /** The document change that will be forwarded to vscode */ result: DocChange | WrappingDocChange; /** The new tree that represents the updated mapping */ - newTree: Tree + newTree: Tree; + /** The net change in line count caused by this step (positive = lines added, negative = lines removed) */ + lineDelta: number; } /** diff --git a/src/menubar/menubar.ts b/src/menubar/menubar.ts index 05952ba..03a9bf2 100644 --- a/src/menubar/menubar.ts +++ b/src/menubar/menubar.ts @@ -163,34 +163,33 @@ function createDefaultMenu(outerView: EditorView, os: OS, tagConf: TagConfigurat const teacherOnly = {teacherModeOnly : true}; // Create the list of menu entries. - const items: MenuEntry[] = []; + const items: MenuEntry[] = [ + // Insert Coq command + createMenuItem("Math↓", `Insert new verified math block underneath (${keyBinding("q")})`, getCmdInsertCode(InsertionPlace.Below, tagConf)), + createMenuItem("Math↑", `Insert new verified math block above (${keyBinding("Q")})`, getCmdInsertCode(InsertionPlace.Above, tagConf)), + // Insert Markdown + createMenuItem("Text↓", `Insert new text block underneath (${keyBinding("m")})`, getCmdInsertMarkdown(InsertionPlace.Below, tagConf)), + createMenuItem("Text↑", `Insert new text block above (${keyBinding("M")})`, getCmdInsertMarkdown(InsertionPlace.Above, tagConf)), + // Insert LaTeX + createMenuItem(`${LaTeX_SVG}
`, `Insert new LaTeX block underneath (${keyBinding("l")})`, getCmdInsertLatex(InsertionPlace.Below, tagConf)), + createMenuItem(`${LaTeX_SVG}
`, `Insert new LaTeX block above (${keyBinding("L")})`, getCmdInsertLatex(InsertionPlace.Above, tagConf)), + // Select the parent node. + createMenuItem("Parent", `Select the parent node (${keyBinding(".")})`, selectParentNode), + // in teacher mode, display input area, hint and lift buttons. + createMenuItem("ⵊ...", "Make selection an input area", teacherOnlyWrapper(wrapInInput(tagConf)), teacherOnly), + createMenuItem("?", "Make selection a hint element", teacherOnlyWrapper(wrapInHint(tagConf)), teacherOnly), + createMenuItem("↑", "Lift selected node (Reverts the effect of making a 'hint' or 'input area')", teacherOnlyWrapper(wpLift(tagConf)), teacherOnly), + createMenuItem("🗑️", "Delete selection", teacherOnlyWrapper(deleteSelection(tagConf)), teacherOnly), + ] const customMenuItems = customEntries?.map(entry => createMenuItem(entry.title, entry.hoverText, () => { entry.callback(); return true; }, entry.buttonVisibility, true)); if (customMenuItems !== undefined) items.push(...customMenuItems); - + // The DEBUG label will be dropped in case we are *not* in debug mode. // eslint-disable-next-line no-unused-labels DEBUG: { items.push( - ...[ - // Insert Coq command - createMenuItem("Math↓", `Insert new verified math block underneath (${keyBinding("q")})`, getCmdInsertCode(InsertionPlace.Below, tagConf)), - createMenuItem("Math↑", `Insert new verified math block above (${keyBinding("Q")})`, getCmdInsertCode(InsertionPlace.Above, tagConf)), - // Insert Markdown - createMenuItem("Text↓", `Insert new text block underneath (${keyBinding("m")})`, getCmdInsertMarkdown(InsertionPlace.Below, tagConf)), - createMenuItem("Text↑", `Insert new text block above (${keyBinding("M")})`, getCmdInsertMarkdown(InsertionPlace.Above, tagConf)), - // Insert LaTeX - createMenuItem(`${LaTeX_SVG}
`, `Insert new LaTeX block underneath (${keyBinding("l")})`, getCmdInsertLatex(InsertionPlace.Below, tagConf)), - createMenuItem(`${LaTeX_SVG}
`, `Insert new LaTeX block above (${keyBinding("L")})`, getCmdInsertLatex(InsertionPlace.Above, tagConf)), - // Select the parent node. - createMenuItem("Parent", `Select the parent node (${keyBinding(".")})`, selectParentNode), - // in teacher mode, display input area, hint and lift buttons. - createMenuItem("ⵊ...", "Make selection an input area", teacherOnlyWrapper(wrapInInput(tagConf)), teacherOnly), - createMenuItem("?", "Make selection a hint element", teacherOnlyWrapper(wrapInHint(tagConf)), teacherOnly), - createMenuItem("↑", "Lift selected node (Reverts the effect of making a 'hint' or 'input area')", teacherOnlyWrapper(wpLift(tagConf)), teacherOnly), - createMenuItem("🗑️", "Delete selection", teacherOnlyWrapper(deleteSelection(tagConf)), teacherOnly), - ], createMenuItem("DUMP DOC", "", (state, dispatch) => { if (dispatch) console.log("\x1b[33m[DEBUG]\x1b[0m dumped doc", JSON.stringify(state.doc.toJSON())); return true; diff --git a/src/serialization/DocumentSerializer.ts b/src/serialization/DocumentSerializer.ts index 193ec06..aebdf10 100644 --- a/src/serialization/DocumentSerializer.ts +++ b/src/serialization/DocumentSerializer.ts @@ -1,7 +1,13 @@ -import { Node } from "prosemirror-model"; +import { Fragment, Node } from "prosemirror-model"; import { WaterproofSchema } from "../schema"; import { TagConfiguration } from "../api"; +export class SerializationError extends Error { + constructor(message: string) { + super("[SerializationError] " + message); + } +} + export abstract class DocumentSerializer { /** * Describes how to turn a code node into a string representation. @@ -57,9 +63,12 @@ export abstract class DocumentSerializer { } /** - * - * @param node - * @returns + * Serializes a node to its string representation. + * @param node The node to serialize. + * @param parent The type name of the parent node, or null if the node is at root level. + * @param neighbors A function that returns the node types above and below the current node, with an option to skip newline nodes. + * @returns The serialized (string) representation of the node. + * @throws A {@linkcode SerializationError} when the node type is not supported. */ public serializeNode(node: Node, parent: string | null, neighbors: (skipNewlines: boolean) => {nodeAbove: string | null, nodeBelow: string | null}): string { switch (node.type) { @@ -71,13 +80,51 @@ export abstract class DocumentSerializer { case WaterproofSchema.nodes.text: return this.serializeText(node); case WaterproofSchema.nodes.newline: return this.serializeNewline(); default: - throw new Error(`[SerializeNode] Node of type "${node.type.name}" not supported.`); + throw new SerializationError(`[SerializeNode] Node of type "${node.type.name}" not supported.`); } } + + /** + * Serializes a fragment of nodes into a string representation. + * + * This method iterates through each child node in the fragment and serializes it individually. + * For each node, it provides context about neighboring nodes to the serialization function, + * with an option to skip newline nodes when determining context. + * @param fragment The node content fragment to serialize + * @param parent The parent node name, or null if there is no parent + * @returns The serialized string representation of the fragment + * @throws A {@linkcode SerializationError} when the document contains a node type that is not supported by the serializer. + */ + public serializeFragment(fragment: Fragment, parent: string | null): string { + const output: string[] = []; + fragment.forEach((child, _, idx) => { + const nodeDirectlyAbove = fragment.maybeChild(idx - 1); + const nodeTwoAbove = fragment.maybeChild(idx - 2); + + const nodeDirectlyBelow = fragment.maybeChild(idx + 1); + const nodeTwoBelow = fragment.maybeChild(idx + 2); + + const func = (skipNewlines: boolean): { nodeAbove: string | null; nodeBelow: string | null } => { + let above = nodeDirectlyAbove?.type.name ?? null; + let below = nodeDirectlyBelow?.type.name ?? null; + + if (above === "newline" && skipNewlines) above = nodeTwoAbove?.type.name ?? null; + if (below === "newline" && skipNewlines) below = nodeTwoBelow?.type.name ?? null; + + return {nodeAbove: above, nodeBelow: below}; + }; + + output.push(this.serializeNode(child, parent, func)); + }); + return output.join(""); + } /** + * Serializes the whole ProseMirror document into its string representation. * - * @param node + * @param node The document node to serialize, this should probably be the root (`doc`) node of the ProseMirror document. + * @returns The string representation of the document + * @throws A {@linkcode SerializationError} when the document contains a node type that is not supported by the serializer. */ public serializeDocument(node: Node) { const output: string[] = [];