diff --git a/__tests__/code-plugin-linenumbers.test.ts b/__tests__/code-plugin-linenumbers.test.ts new file mode 100644 index 0000000..66f2923 --- /dev/null +++ b/__tests__/code-plugin-linenumbers.test.ts @@ -0,0 +1,53 @@ +import { EditorState } from "prosemirror-state"; +import { CODE_PLUGIN_KEY, codePlugin } from "../src/codeview"; +import { ThemeStyle } from "../src/api"; +import { WaterproofSchema } from "../src/schema"; + +// The code plugin stores activeNodeViews in a Set. +// After a ReplaceAroundStep (lift), ProseMirror may destroy and recreate a code +// NodeView. This test checks this is properly handled. + +// Mock the INPUT_AREA_PLUGIN_KEY required by CodeBlockView +jest.mock('../src/inputArea.ts', () => ({ + INPUT_AREA_PLUGIN_KEY: { + getState: jest.fn(() => ({ teacher: true })) + } +})); + +test("After lift, stale NodeViews in activeNodeViews prevent line numbers from reaching cells", () => { + // Create a document with 3 code blocks (simulating post-lift state) + const doc = WaterproofSchema.nodes.doc.create({}, [ + WaterproofSchema.nodes.code.create(null, WaterproofSchema.text("abc")), + WaterproofSchema.nodes.newline.create(), + WaterproofSchema.nodes.code.create(null, WaterproofSchema.text("def")), + WaterproofSchema.nodes.newline.create(), + WaterproofSchema.nodes.code.create(null, WaterproofSchema.text("ghi")), + ]); + + // Create the code plugin (dummy completions/symbols/editor — apply doesn't use them) + const plugin = codePlugin([], [], null as any, ThemeStyle.Light); + const state = EditorState.create({ schema: WaterproofSchema, plugins: [plugin], doc }); + + const pluginState = CODE_PLUGIN_KEY.getState(state); + if (!pluginState) throw new Error("Code plugin state not found"); + + // Simulate 3 real NodeViews + 1 stale view left over from lift + const mockViews = [ + { _getPos: () => 0, updateLineNumbers: jest.fn() }, // code1 + { _getPos: () => 6, updateLineNumbers: jest.fn() }, // code2 (recreated after lift) + { _getPos: () => undefined, updateLineNumbers: jest.fn() }, // code2-old (stale — destroyed by PM, never removed) + { _getPos: () => 12, updateLineNumbers: jest.fn() }, // code3 + ]; + mockViews.forEach(v => pluginState.activeNodeViews.add(v as any)); + + // Dispatch line numbers computed by mapping.computeLineNumbers() + // (3 code blocks → 3 line numbers) + const lineNumbers = [1, 5, 9]; + const tr = state.tr.setMeta(CODE_PLUGIN_KEY, lineNumbers); + state.apply(tr); + + // EXPECTED: the 3 real code cells should each receive their line number. + expect(mockViews[0].updateLineNumbers).toHaveBeenCalledWith(2, false); // line 1 + 1 + expect(mockViews[1].updateLineNumbers).toHaveBeenCalledWith(6, false); // line 5 + 1 + expect(mockViews[3].updateLineNumbers).toHaveBeenCalledWith(10, false); // line 9 + 1 +}); diff --git a/__tests__/commands/all-insertions.test.ts b/__tests__/commands/all-insertions.test.ts index 1f7cffc..7d433dd 100644 --- a/__tests__/commands/all-insertions.test.ts +++ b/__tests__/commands/all-insertions.test.ts @@ -9,6 +9,7 @@ import { configuration } from "../../src/markdown-defaults"; import { EditorView } from "prosemirror-view"; import { WaterproofSchema } from "../../src/schema"; +/* Note that this is not a real tag configuration, the multilean is exclusive to lean files */ const tagConf: TagConfiguration = { markdown: { openTag: "", closeTag: "", @@ -32,6 +33,11 @@ const tagConf: TagConfiguration = { math: { openTag: "$$", closeTag: "$$", openRequiresNewline: false, closeRequiresNewline: false + }, + container: { + openTag: (name: string) => `::::${name}\n`, + closeTag: "\n::::", + openRequiresNewline: false, closeRequiresNewline: false, } } @@ -42,7 +48,7 @@ const initialStateMath = {"doc":{"type":"doc","content":[{"type":"math_display", const startingCell: Array<[string, any]> = [ ["Latex", initialStateMath], ["Markdown", initialStateMD], - ["Code", initialStateCode] + ["Code", initialStateCode], ]; const insertableTypes: Array<[string, (place: InsertionPlace, conf: TagConfiguration) => Command, string]> = [ diff --git a/__tests__/commands/insert-commands.test.ts b/__tests__/commands/insert-commands.test.ts index fef6640..7a50c55 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"; @@ -19,6 +19,52 @@ jest.mock('../../src/inputArea.ts', () => ({ } })); +// A doc with a single code cell; selection inside the code content (position 11 = after 9 chars). +const stateOneCode = {"doc":{"type":"doc","content":[{"type":"code","content":[{"type":"text","text":"Goal True."}]}]},"selection":{"type":"text","anchor":11,"head":11}}; + +test("Insert markdown below code cell adds a newline separator", () => { + const view = new EditorView(null, {state: EditorState.fromJSON({schema: WaterproofSchema}, stateOneCode)}); + + const cmd = getCmdInsertMarkdown(InsertionPlace.Below, tagConf); + const res = cmd(view.state, view.dispatch, view); + + expect(res).toBe(true); + + // The newline node between code and markdown is required so that the serializer + // does not place markdown text on the same line as the closing code fence ("\n```"). + const expected = {"doc":{"type":"doc", + "content":[ + {"type":"code","content":[{"type":"text","text":"Goal True."}]}, + {"type":"newline"}, + {"type":"markdown"} + ]}, + "selection":{"type":"text","anchor":11,"head":11}}; + expect(view.state.toJSON()).toStrictEqual(expected); +}); + +// A doc with a single code cell; selection inside the code content. +const stateOneCodeForAbove = {"doc":{"type":"doc","content":[{"type":"code","content":[{"type":"text","text":"Goal True."}]}]},"selection":{"type":"text","anchor":11,"head":11}}; + +test("Insert markdown above code cell adds a newline separator", () => { + const view = new EditorView(null, {state: EditorState.fromJSON({schema: WaterproofSchema}, stateOneCodeForAbove)}); + + const cmd = getCmdInsertMarkdown(InsertionPlace.Above, tagConf); + const res = cmd(view.state, view.dispatch, view); + + expect(res).toBe(true); + + // The newline node between markdown and code is required so that the serializer + // does not place markdown text on the same line as the opening code fence ("```lean\n"). + const expected = {"doc":{"type":"doc", + "content":[ + {"type":"markdown"}, + {"type":"newline"}, + {"type":"code","content":[{"type":"text","text":"Goal True."}]} + ]}, + "selection":{"type":"text","anchor":14,"head":14}}; + expect(view.state.toJSON()).toStrictEqual(expected); +}); + test("Insert code below twice (selection static)", () => { const view = new EditorView(null, {state: EditorState.fromJSON({schema: WaterproofSchema}, state)}); @@ -53,7 +99,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__/commands/wrap-commands.test.ts b/__tests__/commands/wrap-commands.test.ts new file mode 100644 index 0000000..7095352 --- /dev/null +++ b/__tests__/commands/wrap-commands.test.ts @@ -0,0 +1,281 @@ +import { EditorState, NodeSelection } from "prosemirror-state"; +import { Fragment } from "prosemirror-model"; +import { WaterproofSchema } from "../../src/schema"; +import { wrapInInput, wrapInHint } from "../../src/commands"; +import { DefaultTagSerializer } from "../../src/serialization/DocumentSerializer"; +import { TagConfiguration } from "../../src/api"; + +// Lean-like tag configuration: input, hint, and code all require surrounding newlines. +const leanConfig: TagConfiguration = { + code: { openTag: "```lean\n", closeTag: "\n```", openRequiresNewline: true, closeRequiresNewline: true }, + hint: { openTag: (t: string) => `:::hint "${t}"\n`, closeTag: "\n:::", openRequiresNewline: true, closeRequiresNewline: true }, + input: { openTag: ":::input\n", closeTag: "\n:::", openRequiresNewline: true, closeRequiresNewline: true }, + markdown: { openTag: "", closeTag: "", openRequiresNewline: false, closeRequiresNewline: false }, + math: { openTag: "$$`", closeTag: "`", openRequiresNewline: false, closeRequiresNewline: false }, + container:{ openTag: (n: string) => `::::${n}\n`, closeTag: "\n::::", openRequiresNewline: true, closeRequiresNewline: true }, +}; +const leanSerializer = new DefaultTagSerializer(leanConfig); + +/** Build a doc from an array of nodes and select the node at the given index. */ +function makeStateWithSelection(nodes: import("prosemirror-model").Node[], selectedIndex: number): EditorState { + const doc = WaterproofSchema.nodes.doc.create({}, Fragment.from(nodes)); + const pos = nodes.slice(0, selectedIndex).reduce((acc, n) => acc + n.nodeSize, 0); + const state = EditorState.create({ doc }); + return state.apply(state.tr.setSelection(NodeSelection.create(state.doc, pos))); +} + +// ── helpers ────────────────────────────────────────────────────────────────── +const nl = () => WaterproofSchema.nodes.newline.create(); +const code = (text = "") => text + ? WaterproofSchema.nodes.code.create({}, WaterproofSchema.text(text)) + : WaterproofSchema.nodes.code.create(); +const markdown = (text = "") => text + ? WaterproofSchema.nodes.markdown.create({}, WaterproofSchema.text(text)) + : WaterproofSchema.nodes.markdown.create(); + +// ───────────────────────────────────────────────────────────────────────────── +// Dry-run correctness (no dispatch) +// ───────────────────────────────────────────────────────────────────────────── + +describe("wrapInInput dry-run with Lean config", () => { + test("returns true even without a preceding newline (will be inserted on dispatch)", () => { + const state = makeStateWithSelection([code()], 0); + expect(wrapInInput(leanConfig)(state, undefined)).toBe(true); + }); + + test("returns true even without a following newline (will be inserted on dispatch)", () => { + const state = makeStateWithSelection([nl(), code()], 1); + expect(wrapInInput(leanConfig)(state, undefined)).toBe(true); + }); + + test("returns true when both surrounding newlines are already present", () => { + const state = makeStateWithSelection([nl(), code(), nl()], 1); + expect(wrapInInput(leanConfig)(state, undefined)).toBe(true); + }); + + test("returns true for markdown node without preceding newline", () => { + const state = makeStateWithSelection([markdown()], 0); + expect(wrapInInput(leanConfig)(state, undefined)).toBe(true); + }); +}); + +describe("wrapInHint dry-run with Lean config", () => { + test("returns true even without a preceding newline", () => { + const state = makeStateWithSelection([code()], 0); + expect(wrapInHint(leanConfig)(state, undefined)).toBe(true); + }); + + test("returns true when both surrounding newlines are present", () => { + const state = makeStateWithSelection([nl(), code(), nl()], 1); + expect(wrapInHint(leanConfig)(state, undefined)).toBe(true); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Document structure after wrapping +// ───────────────────────────────────────────────────────────────────────────── + +describe("wrapInInput document structure (Lean config)", () => { + test("surrounding newlines stay OUTSIDE the input, not inside", () => { + // Doc: [nl, code, nl] ← the code is surrounded by the required newlines + const state = makeStateWithSelection([nl(), code(), nl()], 1); + + let newState: EditorState | null = null; + wrapInInput(leanConfig)(state, (tr) => { newState = state.apply(tr); }); + + expect(newState).not.toBeNull(); + const doc = newState!.doc; + + // Expected: [nl, input([code]), nl] — 3 top-level children + // Buggy: [input([nl, code, nl])] — 1 top-level child (whole doc swallowed) + expect(doc.childCount).toBe(3); + expect(doc.child(0).type.name).toBe("newline"); + expect(doc.child(1).type.name).toBe("input"); + expect(doc.child(2).type.name).toBe("newline"); + + const inputNode = doc.child(1); + // The input must contain exactly the code node — no extra newline wrappers. + expect(inputNode.childCount).toBe(1); + expect(inputNode.child(0).type.name).toBe("code"); + }); + + test("wrapping a code cell inside a larger document leaves neighbours intact", () => { + // Doc: [code("a"), nl, code("b"), nl, code("c")] + // Select code("b") and wrap in input. + const state = makeStateWithSelection( + [code("a"), nl(), code("b"), nl(), code("c")], + 2 // index 2 = code("b") + ); + + let newState: EditorState | null = null; + wrapInInput(leanConfig)(state, (tr) => { newState = state.apply(tr); }); + + expect(newState).not.toBeNull(); + const doc = newState!.doc; + + // Expected: [code("a"), nl, input([code("b")]), nl, code("c")] + expect(doc.childCount).toBe(5); + expect(doc.child(0).type.name).toBe("code"); + expect(doc.child(1).type.name).toBe("newline"); + expect(doc.child(2).type.name).toBe("input"); + expect(doc.child(3).type.name).toBe("newline"); + expect(doc.child(4).type.name).toBe("code"); + + expect(doc.child(2).childCount).toBe(1); + expect(doc.child(2).child(0).type.name).toBe("code"); + }); +}); + +describe("wrapInHint document structure (Lean config)", () => { + test("surrounding newlines stay OUTSIDE the hint, not inside", () => { + const state = makeStateWithSelection([nl(), code(), nl()], 1); + + let newState: EditorState | null = null; + wrapInHint(leanConfig)(state, (tr) => { newState = state.apply(tr); }); + + expect(newState).not.toBeNull(); + const doc = newState!.doc; + + expect(doc.childCount).toBe(3); + expect(doc.child(0).type.name).toBe("newline"); + expect(doc.child(1).type.name).toBe("hint"); + expect(doc.child(2).type.name).toBe("newline"); + + const hintNode = doc.child(1); + expect(hintNode.childCount).toBe(1); + expect(hintNode.child(0).type.name).toBe("code"); + }); + + test("hint node receives default title attribute", () => { + const state = makeStateWithSelection([nl(), code(), nl()], 1); + + let newState: EditorState | null = null; + wrapInHint(leanConfig)(state, (tr) => { newState = state.apply(tr); }); + + const hintNode = newState!.doc.child(1); + expect(hintNode.attrs.title).toBe("💡 Hint"); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Serialization round-trip +// ───────────────────────────────────────────────────────────────────────────── + +describe("wrapInInput serialization (Lean config)", () => { + test("serialized output has no double-newline inside :::input block", () => { + // Doc: [code("a"), nl, code("b"), nl, code("c")] — wrap code("b") + const state = makeStateWithSelection( + [code("a"), nl(), code("b"), nl(), code("c")], + 2 + ); + + let newState: EditorState | null = null; + wrapInInput(leanConfig)(state, (tr) => { newState = state.apply(tr); }); + + const result = leanSerializer.serializeDocument(newState!.doc); + + // Correct: single \n at the boundary between :::input and the code fence + expect(result).toBe( + "```lean\na\n```\n:::input\n```lean\nb\n```\n:::\n```lean\nc\n```" + ); + // The buggy output would be: + // "```lean\na\n```\n:::input\n\n```lean\nb\n```\n\n:::\n```lean\nc\n```" + expect(result).not.toContain(":::input\n\n"); + expect(result).not.toContain("\n\n:::"); + }); +}); + +describe("wrapInHint serialization (Lean config)", () => { + test("serialized output has no double-newline inside :::hint block", () => { + const state = makeStateWithSelection( + [code("a"), nl(), code("b"), nl(), code("c")], + 2 + ); + + let newState: EditorState | null = null; + wrapInHint(leanConfig)(state, (tr) => { newState = state.apply(tr); }); + + const result = leanSerializer.serializeDocument(newState!.doc); + + expect(result).toBe( + `\`\`\`lean\na\n\`\`\`\n:::hint "💡 Hint"\n\`\`\`lean\nb\n\`\`\`\n:::\n\`\`\`lean\nc\n\`\`\`` + ); + expect(result).not.toContain(':::hint "💡 Hint"\n\n'); + expect(result).not.toContain("\n\n:::"); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Wrapping markdown nodes (Regression tests) +// ───────────────────────────────────────────────────────────────────────────── + +describe("wrapInInput on markdown after code (no preceding newline)", () => { + test("dry-run returns true even without a preceding newline", () => { + // Typical Lean structure: code followed immediately by markdown, no NewlineBlock. + const state = makeStateWithSelection([code("x"), markdown("hello")], 1); + // Currently returns false — the command incorrectly rejects instead of inserting + // the required newline. + expect(wrapInInput(leanConfig)(state, undefined)).toBe(true); + }); + + test("inserts a newline before the input when none was present", () => { + const state = makeStateWithSelection([code("x"), markdown("hello")], 1); + + let newState: EditorState | null = null; + wrapInInput(leanConfig)(state, (tr) => { newState = state.apply(tr); }); + + expect(newState).not.toBeNull(); + const doc = newState!.doc; + + // Expected: [code, newline, input([markdown])] + // (No trailing newline needed: nothing follows the input) + expect(doc.childCount).toBe(3); + expect(doc.child(0).type.name).toBe("code"); + expect(doc.child(1).type.name).toBe("newline"); + expect(doc.child(2).type.name).toBe("input"); + expect(doc.child(2).childCount).toBe(1); + expect(doc.child(2).child(0).type.name).toBe("markdown"); + }); + + test("serializes correctly — no double-newline, valid Lean syntax", () => { + // Doc: [code("a"), markdown("text"), newline, code("b")] + // markdown has no preceding newline (Lean document pattern). + const state = makeStateWithSelection( + [code("a"), markdown("text"), nl(), code("b")], + 1 // select the markdown + ); + + let newState: EditorState | null = null; + wrapInInput(leanConfig)(state, (tr) => { newState = state.apply(tr); }); + + const result = leanSerializer.serializeDocument(newState!.doc); + + // The missing newline before :::input must be added; the existing newline + // after the input (before code("b")) must be preserved. + expect(result).toBe("```lean\na\n```\n:::input\ntext\n:::\n```lean\nb\n```"); + }); +}); + +describe("wrapInHint on markdown after code (no preceding newline)", () => { + test("dry-run returns true even without a preceding newline", () => { + const state = makeStateWithSelection([code("x"), markdown("hello")], 1); + expect(wrapInHint(leanConfig)(state, undefined)).toBe(true); + }); + + test("inserts a newline before the hint when none was present", () => { + const state = makeStateWithSelection([code("x"), markdown("hello")], 1); + + let newState: EditorState | null = null; + wrapInHint(leanConfig)(state, (tr) => { newState = state.apply(tr); }); + + expect(newState).not.toBeNull(); + const doc = newState!.doc; + + expect(doc.childCount).toBe(3); + expect(doc.child(0).type.name).toBe("code"); + expect(doc.child(1).type.name).toBe("newline"); + expect(doc.child(2).type.name).toBe("hint"); + expect(doc.child(2).childCount).toBe(1); + expect(doc.child(2).child(0).type.name).toBe("markdown"); + }); +}); diff --git a/__tests__/container.test.ts b/__tests__/container.test.ts new file mode 100644 index 0000000..b3bccc3 --- /dev/null +++ b/__tests__/container.test.ts @@ -0,0 +1,538 @@ +import { parse } from "../src/markdown-defaults"; +import { + isMarkdownBlock, isCodeBlock, isHintBlock, isInputAreaBlock, + isMathDisplayBlock, isNewlineBlock, isContainerBlock +} from "../src/document/blocks"; +import { HintBlock, ContainerBlock } from "../src/document"; +import { Mapping, Range, WaterproofDocument } from "../src/api"; +import { CodeBlock, InputAreaBlock, MarkdownBlock, MathDisplayBlock, NewlineBlock } from "../src/document"; +import { configuration } from "../src/markdown-defaults"; +import { DefaultTagSerializer } from "../src/serialization/DocumentSerializer"; +import { constructDocument } from "../src/document"; +import { sanityCheckTree } from "./mapping/util"; +import { TagConfiguration } from "../src/api"; +import { wrapInContainer, wpLift } from "../src/commands"; + +import { EditorState, NodeSelection, Transaction } from "prosemirror-state"; +import { Fragment, Node as PNode } from "prosemirror-model"; +import { WaterproofSchema } from "../src/schema"; +import { checkInputArea } from "../src/commands/command-helpers"; + +const config = configuration("lean4"); +const serializer = new DefaultTagSerializer(config); + +const multileanConfig: TagConfiguration = { + ...config, + container: { + openTag: (name: string) => `::::${name}\n`, + closeTag: "\n::::", + openRequiresNewline: false, closeRequiresNewline: false, + } +}; +const multileanSerializer = new DefaultTagSerializer(multileanConfig); + +function createTestMapping(blocks: WaterproofDocument) { + const mapping = new Mapping(blocks, 1, config, serializer); + return mapping.getMapping(); +} + +// ============================================================ +// Test utility helpers +// ============================================================ + +/** Constructs a document from blocks and serializes it with the given serializer (defaults to `serializer`). */ +function serializeBlocks(blocks: WaterproofDocument, ser = serializer): string { + return ser.serializeDocument(constructDocument(blocks)); +} + +/** Creates an EditorState with a NodeSelection at `pos`. */ +function stateWithNodeSelAt(doc: PNode, pos: number): EditorState { + const state = EditorState.create({ doc }); + return state.apply(state.tr.setSelection(NodeSelection.create(state.doc, pos))); +} + +/** Applies a ProseMirror command to `state`, returning the resulting state or null if not dispatched. */ +function applyCommand( + state: EditorState, + cmd: (s: EditorState, dispatch?: (tr: Transaction) => void) => boolean +): EditorState | null { + let newState: EditorState | null = null; + cmd(state, (tr) => { newState = state.apply(tr); }); + return newState; +} + +/** Returns the type names of all direct children of a doc node. */ +function docChildTypes(doc: PNode): string[] { + const types: string[] = []; + doc.forEach(child => types.push(child.type.name)); + return types; +} + +// ============================================================ +// Parsing tests — the .mv parser (statemachine.ts) does not +// handle container; parsing is handled in waterproof-vscode. +// ============================================================ + +describe("container parsing (not supported by .mv parser)", () => { + test("parser does not recognize container syntax", () => { + const doc = `::::multilean +Some markdown content +::::`; + const blocks = parse(doc, { language: "lean4" }); + // Should be parsed as plain markdown, not as a container + expect(blocks.every(b => !isContainerBlock(b))).toBe(true); + expect(isMarkdownBlock(blocks[0])).toBe(true); + }); +}); + +// ============================================================ +// Serialization tests — with empty tags, container serializes +// transparently (just the inner content, no wrapper). +// ============================================================ + +describe("container serialization", () => { + test("serialize container with markdown", () => { + const innerBlocks = [ + new MarkdownBlock("Some text", { from: 14, to: 23 }, { from: 14, to: 23 }, 0) + ]; + const cg = new ContainerBlock( + "Some text", "test", + { from: 0, to: 28 }, { from: 14, to: 23 }, 0, + innerBlocks + ); + expect(serializeBlocks([cg])).toBe("Some text"); + }); + + test("serialize container with code block", () => { + const innerBlocks = [ + new CodeBlock("def x := 1", { from: 14, to: 37 }, { from: 21, to: 31 }, 0) + ]; + const cg = new ContainerBlock( + "```lean4\ndef x := 1\n```", "test", + { from: 0, to: 42 }, { from: 14, to: 37 }, 0, + innerBlocks + ); + expect(serializeBlocks([cg])).toBe("```lean4\ndef x := 1\n```"); + }); + + test("serialize container with input area", () => { + const inputInnerBlocks = [ + new MarkdownBlock("input text", { from: 26, to: 36 }, { from: 26, to: 36 }, 0) + ]; + const innerBlocks = [ + new InputAreaBlock( + "input text", + { from: 14, to: 49 }, { from: 26, to: 36 }, 0, + inputInnerBlocks + ) + ]; + const cg = new ContainerBlock( + "input text", "test", + { from: 0, to: 54 }, { from: 14, to: 49 }, 0, + innerBlocks + ); + expect(serializeBlocks([cg])).toBe("input text"); + }); + + test("serialize container with hint", () => { + const hintInnerBlocks = [ + new MarkdownBlock("hint text", { from: 40, to: 49 }, { from: 40, to: 49 }, 0) + ]; + const innerBlocks = [ + new HintBlock( + "hint text", + "My Hint", + { from: 14, to: 56 }, { from: 40, to: 49 }, 0, + hintInnerBlocks + ) + ]; + const cg = new ContainerBlock( + 'hint text', "test", + { from: 0, to: 61 }, { from: 14, to: 52 }, 0, + innerBlocks + ); + expect(serializeBlocks([cg])).toBe('hint text'); + }); + + test("serialize container with math_display", () => { + const innerBlocks = [ + new MathDisplayBlock("x^2", { from: 14, to: 21 }, { from: 16, to: 19 }, 0) + ]; + const cg = new ContainerBlock( + "$$x^2$$", "test", + { from: 0, to: 26 }, { from: 14, to: 21 }, 0, + innerBlocks + ); + expect(serializeBlocks([cg])).toBe("$$x^2$$"); + }); + + test("serialize container with non-empty tags (multilean)", () => { + const innerBlocks = [ + new MarkdownBlock("Some content", { from: 14, to: 26 }, { from: 14, to: 26 }, 0) + ]; + const cg = new ContainerBlock( + "Some content", "multilean", + { from: 0, to: 31 }, { from: 14, to: 26 }, 0, + innerBlocks + ); + expect(serializeBlocks([cg], multileanSerializer)).toBe("::::multilean\nSome content\n::::"); + }); + + test("serialize container with multiple children", () => { + const innerBlocks = [ + new MarkdownBlock("intro", { from: 14, to: 19 }, { from: 14, to: 19 }, 0), + new CodeBlock("def x := 1", { from: 19, to: 42 }, { from: 26, to: 36 }, 0), + ]; + const cg = new ContainerBlock( + "intro```lean4\ndef x := 1\n```", "test", + { from: 0, to: 47 }, { from: 14, to: 42 }, 0, + innerBlocks + ); + expect(serializeBlocks([cg])).toBe("intro```lean4\ndef x := 1\n```"); + }); +}); + +// ============================================================ +// Mapping tests +// ============================================================ + +describe("container mapping", () => { + test("mapping with container containing markdown", () => { + const cg = new ContainerBlock( + "Hello", "test", + { from: 0, to: 24 }, + { from: 14, to: 19 }, + 0, + [new MarkdownBlock("Hello", { from: 14, to: 19 }, { from: 14, to: 19 }, 0)] + ); + const tree = createTestMapping([cg]); + + expect(tree.root.children.length).toBe(1); + const cgNode = tree.root.children[0]; + expect(cgNode.type).toBe("container"); + expect(cgNode.children.length).toBe(1); + expect(cgNode.children[0].type).toBe("markdown"); + + sanityCheckTree(tree.root); + }); + + test("mapping with container containing input area with code", () => { + const codeInner = new CodeBlock("code", { from: 12, to: 25 }, { from: 19, to: 23 }, 0); + const inputInner = new InputAreaBlock( + "```lean4\ncode\n```", + { from: 0, to: 38 }, { from: 12, to: 25 }, 0, + [codeInner] + ); + const cg = new ContainerBlock( + "```lean4\ncode\n```", "test", + { from: 0, to: 43 }, { from: 0, to: 38 }, 0, + [inputInner] + ); + + const tree = createTestMapping([cg]); + + expect(tree.root.children.length).toBe(1); + const cgNode = tree.root.children[0]; + expect(cgNode.type).toBe("container"); + expect(cgNode.children.length).toBe(1); + + const inputNode = cgNode.children[0]; + expect(inputNode.type).toBe("input"); + + sanityCheckTree(tree.root); + }); + + test("mapping with container containing hint", () => { + const hintInnerBlocks = [ + new MarkdownBlock("hint body", { from: 36, to: 45 }, { from: 36, to: 45 }, 0) + ]; + const hintBlock = new HintBlock( + "hint body", + "Test", + { from: 14, to: 52 }, + { from: 36, to: 45 }, + 0, + hintInnerBlocks + ); + const cg = new ContainerBlock( + 'hint body', "test", + { from: 0, to: 57 }, + { from: 14, to: 52 }, + 0, + [hintBlock] + ); + const tree = createTestMapping([cg]); + + const cgNode = tree.root.children[0]; + expect(cgNode.type).toBe("container"); + expect(cgNode.children.length).toBe(1); + + const hintNode = cgNode.children[0]; + expect(hintNode.type).toBe("hint"); + expect(hintNode.title).toBe("Test"); + expect(hintNode.children.length).toBe(1); + expect(hintNode.children[0].type).toBe("markdown"); + + sanityCheckTree(tree.root); + }); +}); + +// ============================================================ +// ProseMirror document construction tests +// ============================================================ + +describe("container ProseMirror construction", () => { + test("constructDocument with container", () => { + const innerBlocks = [ + new MarkdownBlock("text", { from: 14, to: 18 }, { from: 14, to: 18 }, 0) + ]; + const cg = new ContainerBlock( + "text", "test", { from: 0, to: 23 }, { from: 14, to: 18 }, 0, innerBlocks + ); + const doc = constructDocument([cg]); + expect(doc.type.name).toBe("doc"); + expect(doc.content.childCount).toBe(1); + expect(doc.content.firstChild!.type.name).toBe("container"); + expect(doc.content.firstChild!.content.childCount).toBe(1); + expect(doc.content.firstChild!.content.firstChild!.type.name).toBe("markdown"); + }); + + test("constructDocument with container containing input", () => { + const inputInner = [ + new MarkdownBlock("answer", { from: 26, to: 32 }, { from: 26, to: 32 }, 0) + ]; + const input = new InputAreaBlock( + "answer", { from: 14, to: 45 }, { from: 26, to: 32 }, 0, inputInner + ); + const cg = new ContainerBlock( + "answer", "test", + { from: 0, to: 50 }, { from: 14, to: 45 }, 0, + [input] + ); + const doc = constructDocument([cg]); + + const cgNode = doc.content.firstChild!; + expect(cgNode.type.name).toBe("container"); + expect(cgNode.content.childCount).toBe(1); + + const inputNode = cgNode.content.firstChild!; + expect(inputNode.type.name).toBe("input"); + expect(inputNode.content.childCount).toBe(1); + expect(inputNode.content.firstChild!.type.name).toBe("markdown"); + }); +}); + +// ============================================================ +// Typeguard tests +// ============================================================ + +describe("container typeguard", () => { + test("isContainerBlock identifies correctly", () => { + const cg = new ContainerBlock("", "test", { from: 0, to: 0 }, { from: 0, to: 0 }, 0, []); + expect(isContainerBlock(cg)).toBe(true); + expect(isInputAreaBlock(cg)).toBe(false); + expect(isHintBlock(cg)).toBe(false); + expect(isCodeBlock(cg)).toBe(false); + expect(isMarkdownBlock(cg)).toBe(false); + expect(isMathDisplayBlock(cg)).toBe(false); + expect(isNewlineBlock(cg)).toBe(false); + }); +}); + +// ============================================================ +// Rocq context tests (container serializes transparently) +// ============================================================ + +describe("container Rocq context", () => { + test("serializer serializes container transparently in Rocq config", () => { + const rocqConfig = configuration("coq"); + const rocqSerializer = new DefaultTagSerializer(rocqConfig); + + const innerBlocks = [ + new MarkdownBlock("text", { from: 14, to: 18 }, { from: 14, to: 18 }, 0) + ]; + const cg = new ContainerBlock( + "text", "test", { from: 0, to: 23 }, { from: 14, to: 18 }, 0, innerBlocks + ); + expect(serializeBlocks([cg], rocqSerializer)).toBe("text"); + }); +}); + +// ============================================================ +// Command tests +// ============================================================ + +/** + * @jest-environment jsdom + */ +describe("wrapInContainer command", () => { + function makeStateWithMarkdown(): EditorState { + const mdNode = WaterproofSchema.nodes.markdown.create({}, WaterproofSchema.text("hello")); + const doc = WaterproofSchema.nodes.doc.create({}, mdNode); + return stateWithNodeSelAt(doc, 0); + } + + test("wrapInContainer wraps selected node in a container", () => { + const state = makeStateWithMarkdown(); + const newState = applyCommand(state, wrapInContainer(config, "multilean")); + + expect(newState).not.toBeNull(); + const doc = newState!.doc; + expect(doc.firstChild!.type.name).toBe("container"); + expect(doc.firstChild!.firstChild!.type.name).toBe("markdown"); + }); + + test("wrapInContainer dry-run (no dispatch) returns true when node is selected", () => { + // Per ProseMirror convention, returning true without dispatch means "I can execute". + const state = makeStateWithMarkdown(); + const result = wrapInContainer(config, "multilean")(state, undefined); + expect(result).toBe(true); + }); +}); + +describe("wpLift from container", () => { + test("wpLift lifts child out of container", () => { + const mdNode = WaterproofSchema.nodes.markdown.create({}, WaterproofSchema.text("hello")); + const cgNode = WaterproofSchema.nodes.container.create({name: "test"}, mdNode); + const doc = WaterproofSchema.nodes.doc.create({}, cgNode); + const stateWithSel = stateWithNodeSelAt(doc, 0); + + const newState = applyCommand(stateWithSel, wpLift(config)); + + expect(newState).not.toBeNull(); + // After lifting, the markdown should be at doc level (no container wrapper) + expect(newState!.doc.firstChild!.type.name).toBe("markdown"); + }); +}); + +describe("checkInputArea with container nesting", () => { + test("returns true when selection is inside input nested in container", () => { + const mdNode = WaterproofSchema.nodes.markdown.create({}, WaterproofSchema.text("ans")); + const inputNode = WaterproofSchema.nodes.input.create({}, mdNode); + const cgNode = WaterproofSchema.nodes.container.create({name: "test"}, inputNode); + const doc = WaterproofSchema.nodes.doc.create({}, cgNode); + // Manually test checkInputArea with the resolved position inside the input + // depth: doc(0) > container(1) > input(2) > markdown(3) > text + // from.node(1) = container, from.node(2) = input → should return true + const innerSel = { $from: doc.resolve(3) } as any; + expect(checkInputArea(innerSel)).toBe(true); + }); + + test("returns false when selection is in markdown directly inside container (no input)", () => { + const mdNode = WaterproofSchema.nodes.markdown.create({}, WaterproofSchema.text("ans")); + const cgNode = WaterproofSchema.nodes.container.create({name: "test"}, mdNode); + const doc = WaterproofSchema.nodes.doc.create({}, cgNode); + // Position 2 is inside the markdown text directly inside container + const innerSel = { $from: doc.resolve(2) } as any; + expect(checkInputArea(innerSel)).toBe(false); + }); + + // T3 — Regression: the original code only checked depth=1 for input nodes. + // This test exercises the depth>=2 branch: cursor exactly at the input boundary + // inside a container (depth=2, before any inner block), which was missed pre-fix. + test("returns true at depth 2 (cursor at input boundary inside container)", () => { + const mdNode = WaterproofSchema.nodes.markdown.create({}, WaterproofSchema.text("ans")); + const inputNode = WaterproofSchema.nodes.input.create({}, mdNode); + const cgNode = WaterproofSchema.nodes.container.create({name: "test"}, inputNode); + const doc = WaterproofSchema.nodes.doc.create({}, cgNode); + // pos 2: inside input (after input open token), depth=2 + // node(1)=container, node(2)=input → checkInputArea should return true + const resolvedPos = doc.resolve(2); + expect(resolvedPos.depth).toBe(2); + const innerSel = { $from: resolvedPos } as any; + expect(checkInputArea(innerSel)).toBe(true); + }); +}); + +// ============================================================ +// Regression tests for container bugs +// ============================================================ + +// T1 — Regression: wrapInContainer must not absorb the preceding newline. +// The old implementation used tr.wrap(blockRange, ...) which, for a top-level +// NodeSelection, caused the preceding newline to be swept inside the container. +// The fix uses ReplaceAroundStep(sel.from, sel.to, sel.from, sel.to, ...). +describe("wrapInContainer newline regression", () => { + test("newline before wrapped node stays outside container", () => { + // Doc: [newline, code] + // newline.nodeSize=1 → code is at pos 1 + const nlNode = WaterproofSchema.nodes.newline.create(); + const codeNode = WaterproofSchema.nodes.code.create(); + const doc = WaterproofSchema.nodes.doc.create({}, Fragment.from([nlNode, codeNode])); + const stateWithSel = stateWithNodeSelAt(doc, 1); + + const newState = applyCommand(stateWithSel, wrapInContainer(multileanConfig, "multilean")); + expect(newState).not.toBeNull(); + + // Buggy behaviour: newline gets absorbed → doc.childCount=1, container contains [newline, code] + // Fixed behaviour: doc.childCount=2, newline stays as first child + const newDoc = newState!.doc; + expect(docChildTypes(newDoc)).toEqual(["newline", "container"]); + expect(newDoc.child(1).firstChild!.type.name).toBe("code"); + }); +}); + +// T2 — Regression: wrapping a container node inside another container must be rejected. +describe("wrapInContainer container-in-container prevention", () => { + test("returns false when selected node is itself a container", () => { + const mdNode = WaterproofSchema.nodes.markdown.create({}, WaterproofSchema.text("hello")); + const cgNode = WaterproofSchema.nodes.container.create({name: "inner"}, mdNode); + const doc = WaterproofSchema.nodes.doc.create({}, cgNode); + const stateWithSel = stateWithNodeSelAt(doc, 0); + const result = wrapInContainer(multileanConfig, "multilean")(stateWithSel, undefined); + expect(result).toBe(false); + }); +}); + +// T4 — Regression: a text edit inside the container after wrapping must not corrupt the doc. +// This exercises the position-mapping path that was broken by the old tr.wrap approach. +describe("wrapInContainer followed by content edit", () => { + test("doc structure remains valid after wrap and text insert inside code", () => { + // Doc: [newline, code, newline] + const nlNode = WaterproofSchema.nodes.newline.create(); + const codeNode = WaterproofSchema.nodes.code.create(); + const nl2Node = WaterproofSchema.nodes.newline.create(); + const doc = WaterproofSchema.nodes.doc.create({}, Fragment.from([nlNode, codeNode, nl2Node])); + const stateWithSel = stateWithNodeSelAt(doc, 1); + + const wrapped = applyCommand(stateWithSel, wrapInContainer(multileanConfig, "multilean")); + expect(wrapped).not.toBeNull(); + + // Verify structure after wrap: [newline, container[code], newline] + expect(docChildTypes(wrapped!.doc)).toEqual(["newline", "container", "newline"]); + expect(wrapped!.doc.child(1).firstChild!.type.name).toBe("code"); + + // Now insert text inside the code node (position 3: container open at 1, + // code open at 2, code content starts at 3). + const edited = wrapped!.apply(wrapped!.tr.insertText("x", 3)); + + // Structure must still be [newline, container[code_with_text], newline] + expect(docChildTypes(edited.doc)).toEqual(["newline", "container", "newline"]); + expect(edited.doc.child(1).firstChild!.type.name).toBe("code"); + }); +}); + + +// T5 — Regression: wpLift must lift ALL children when container has multiple inner blocks. +describe("wpLift with multiple children", () => { + test("lifts all children out of container when container has multiple inner blocks", () => { + const mdNode = WaterproofSchema.nodes.markdown.create({}, WaterproofSchema.text("hello")); + const nlNode = WaterproofSchema.nodes.newline.create(); + const codeNode = WaterproofSchema.nodes.code.create(); + const cgNode = WaterproofSchema.nodes.container.create( + {name: "test"}, + Fragment.from([mdNode, nlNode, codeNode]) + ); + const doc = WaterproofSchema.nodes.doc.create({}, cgNode); + const stateWithSel = stateWithNodeSelAt(doc, 0); + + const newState = applyCommand(stateWithSel, wpLift(multileanConfig)); + expect(newState).not.toBeNull(); + + // Container is gone; children should be at doc level + const types = docChildTypes(newState!.doc); + expect(types).toContain("markdown"); + expect(types).toContain("newline"); + expect(types).toContain("code"); + }); +}); diff --git a/__tests__/mapping/mapping-update.test.ts b/__tests__/mapping/mapping-update.test.ts index 2203b4f..ad67661 100644 --- a/__tests__/mapping/mapping-update.test.ts +++ b/__tests__/mapping/mapping-update.test.ts @@ -1,5 +1,5 @@ import { Fragment, Slice } from "prosemirror-model"; -import { ReplaceStep } from "prosemirror-transform"; +import { ReplaceAroundStep, ReplaceStep } from "prosemirror-transform"; import { DocChange } from "../../src/api"; import { Block } from "../../src/document"; import { Mapping, TreeNode } from "../../src/mapping"; @@ -77,4 +77,245 @@ 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]); +}); + +// Character deletions inside a code/markdown/math_display block must still be +// classified as text edits and routed to textUpdate, not to replaceDelete. +test("Regression: character deletion inside a code block is classified as a text edit", () => { + // Document: one code block containing "abc" + // ProseMirror layout: 0[code 1"a"2"b"3"c"4]5 + // Deleting "bc" = step.from=3, step.to=5 + const docString = "```coq\nabc\n```"; + + const blocks = parse(docString, {language: "coq"}); + const mapping = new Mapping(blocks, 0, config, serializer); + const proseDoc = constructDocument(blocks); + + const tree = mapping.getMapping(); + const codeNode = tree.root.children.find(n => n.type === "code"); + if (!codeNode) throw new Error("Test setup: code node not found"); + + // Delete "bc": step covers [3, 5), which is strictly inside the code node. + // Previously, replaceDelete would find no whole nodes in this range and throw NodeUpdateError. + // textUpdate correctly removes the two characters from the file. + const step = new ReplaceStep( + 3, // 3 — one char into content, so this is a partial deletion + 5, // 5 — end of content + new Slice(Fragment.empty, 0, 0) + ); + + let result: DocChange | undefined; + expect(() => { + result = mapping.update(step, proseDoc) as DocChange; + }).not.toThrow(); + + // The file edit should remove "bc" (2 chars) from the code content. + expect(result).toStrictEqual({ + finalText: "", + startInFile: 9, + endInFile: 11, + }); + + sanityCheckTree(mapping.getMapping().root); +}); + +// Regression: wpLift on an input node with surrounding newlines produces a 3-step transaction: +// Step 1 — ReplaceAroundStep that lifts the input's content to the parent level +// Step 2 — ReplaceStep that deletes the leading duplicate newline (now at step1.from) +// Step 3 — ReplaceStep that deletes the trailing duplicate newline +test("Regression: wpLift newline-deduplication steps are not misclassified as text edits", () => { + // Document: code("abc") | newline | input([ newline | code("def") | newline ]) | newline | code("ghi") + // In coq format: + // ```coq\nabc\n```\n\n```coq\ndef\n```\n\n```coq\nghi\n``` + // + // ProseMirror layout (pre-transaction): + // 0[code1 1"abc"4]5 5(nl1) 6[input 7(nl_a) 8[code2 9"def"12]13 13(nl_b) 14]15 15(nl2) 16[code3 17"ghi"20]21 + const docString = "```coq\nabc\n```\n\n```coq\ndef\n```\n\n```coq\nghi\n```"; + + const blocks = parse(docString, {language: "coq"}); + const mapping = new Mapping(blocks, 0, config, serializer); + const proseDoc = constructDocument(blocks); + + const tree = mapping.getMapping(); + const inputNode = tree.root.children.find(n => n.type === "input"); + if (!inputNode) throw new Error("Test setup: input node not found"); + + expect(inputNode.pmRange).toEqual({ from: 6, to: 15 }); + expect(inputNode.prosemirrorStart).toBe(7); + expect(inputNode.prosemirrorEnd).toBe(14); + + const step1 = new ReplaceAroundStep( + inputNode.pmRange.from, // 6 + inputNode.pmRange.to, // 15 + inputNode.prosemirrorStart, // 7 (gapFrom: first inner content position) + inputNode.prosemirrorEnd, // 14 (gapTo: last inner content position) + new Slice(Fragment.empty, 0, 0), + 0 + ); + + // After step 1, nl_a lands at inputNode.pmRange.from (= 6). + // Step 2: delete nl_a (leading duplicate: nl1 before input + nl_a as first child). + const step2from = inputNode.pmRange.from; // 6 + const step2 = new ReplaceStep(step2from, step2from + 1, new Slice(Fragment.empty, 0, 0)); + + // After steps 1 and 2, nl2 (the outer trailing newline) is at position + // inputNode.pmRange.to - 3 = 15 - 3 = 12. + const step3from = inputNode.pmRange.to - 3; // 12 + const step3 = new ReplaceStep(step3from, step3from + 1, new Slice(Fragment.empty, 0, 0)); + + expect(() => { + mapping.update(step1, proseDoc); + mapping.update(step2, proseDoc); + mapping.update(step3, proseDoc); + }).not.toThrow(); + + sanityCheckTree(mapping.getMapping().root); + + // After wpLift the document is: + // ```coq\nabc\n```\n```coq\ndef\n```\n```coq\nghi\n``` + // Line numbers (0-indexed): code1 content on line 1, code2 on line 4, code3 on line 7. + // Step 2 removes the leading duplicate newline (shifts code2 and code3 down by 1 line); + // step 3 removes the trailing duplicate newline (shifts code3 down by 1 more line). + expect(mapping.getMapping().computeLineNumbers()).toStrictEqual([1, 4, 7]); +}); + +test("Regression: deleting the first code block at position 0 routes to nodeUpdate", () => { + const docString = "```coq\nabc\n```\n```coq\ndef\n```"; + + const blocks = parse(docString, {language: "coq"}); + const mapping = new Mapping(blocks, 0, config, serializer); + const proseDoc = constructDocument(blocks); + + const tree = mapping.getMapping(); + const codeNodes = tree.root.children.filter(n => n.type === "code"); + expect(codeNodes.length).toBe(2); + + const firstCode = codeNodes[0]; + // Delete the entire first code block (from pmRange.from to pmRange.to) + // plus the trailing newline so the step range covers [0, newline.pmRange.to] + const newlineAfterFirst = tree.root.children.find( + n => n.type === "newline" && n.pmRange.from === firstCode.pmRange.to + ); + if (!newlineAfterFirst) throw new Error("Test setup: newline after first code block not found"); + const deleteTo = newlineAfterFirst.pmRange.to; + + const step = new ReplaceStep(0, deleteTo, new Slice(Fragment.empty, 0, 0)); + + let result: DocChange | undefined; + expect(() => { + result = mapping.update(step, proseDoc) as DocChange; + }).not.toThrow(); + + // The deletion should remove the first code block and its trailing newline + expect(result).toBeDefined(); + expect(result!.finalText).toBe(""); + expect(result!.startInFile).toBeGreaterThanOrEqual(0); + + sanityCheckTree(mapping.getMapping().root); }); 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..a897c0a 100644 --- a/__tests__/mapping/nodeupdate.test.ts +++ b/__tests__/mapping/nodeupdate.test.ts @@ -1,32 +1,49 @@ -import { Fragment, Slice } from "prosemirror-model"; +import { Fragment, ResolvedPos, Slice } from "prosemirror-model"; import { DocChange, Mapping, WaterproofDocument, WrappingDocChange } from "../../src/api"; import { configuration } from "../../src/markdown-defaults"; import { ReplaceAroundStep, ReplaceStep } from "prosemirror-transform"; import { WaterproofSchema } from "../../src/schema"; import { NodeUpdate } from "../../src/mapping/nodeUpdate"; -import { CodeBlock, HintBlock, InputAreaBlock, MarkdownBlock, NewlineBlock } from "../../src/document"; +import { CodeBlock, ContainerBlock, HintBlock, InputAreaBlock, MarkdownBlock, NewlineBlock } from "../../src/document"; import { DefaultTagSerializer } from "../../src/serialization/DocumentSerializer"; import { sanityCheckTree } from "./util"; +import { Node } from "prosemirror-model"; const config = configuration("coq"); const serializer = new DefaultTagSerializer(config); +const nodeMock : Node = new Node(); + function createMapping(blocks: WaterproofDocument) { const mapping = new Mapping(blocks, 0, config, serializer); return mapping; } -const PLACEHOLDER_LINENR = 0; +// Lean-like tag configuration with meaningful container tags (::::name\n / \n::::) +// so that wrapping/lifting a multilean cell actually changes line numbers. +const leanConfig = { + code: { openTag: "```lean\n", closeTag: "\n```", openRequiresNewline: true, closeRequiresNewline: true }, + hint: { openTag: (t: string) => `:::hint "${t}"\n`, closeTag: "\n:::", openRequiresNewline: true, closeRequiresNewline: true }, + input: { openTag: ":::input\n", closeTag: "\n:::", openRequiresNewline: true, closeRequiresNewline: true }, + markdown: { openTag: "", closeTag: "", openRequiresNewline: false, closeRequiresNewline: false }, + math: { openTag: "$$`", closeTag: "`", openRequiresNewline: false, closeRequiresNewline: false }, + container:{ openTag: (n: string) => `::::${n}\n`, closeTag: "\n::::", openRequiresNewline: true, closeRequiresNewline: true }, +}; +const leanSerializer = new DefaultTagSerializer(leanConfig); + +function createLeanMapping(blocks: WaterproofDocument) { + return new Mapping(blocks, 0, leanConfig, leanSerializer); +} -// 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); + jest.spyOn(serializer, "serializeDocument").mockReturnValue("# Hello") + const {newTree, result} = nodeUpdate.nodeUpdate(step, mapping, serializer, nodeMock); expect(result).toStrictEqual({ finalText: "\n```coq\n\n```", @@ -70,6 +87,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,30 +98,30 @@ 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); - console.log(JSON.stringify(newTree.root, null, " ")) + jest.spyOn(serializer, "serializeDocument").mockReturnValue("# Hello") + const {newTree, result} = nodeUpdate.nodeUpdate(step, mapping, serializer, nodeMock); + sanityCheckTree(newTree.root); expect(result).toStrictEqual({ finalText: "\n```coq\n\n```", @@ -109,7 +129,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 +142,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, serializer, nodeMock); 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 +175,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, serializer, nodeMock); 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 +209,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, serializer, nodeMock); 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, serializer, nodeMock); sanityCheckTree(newTree.root); expect(result).toStrictEqual({ @@ -231,17 +261,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, serializer, nodeMock); sanityCheckTree(newTree.root); expect(result).toStrictEqual({ @@ -256,18 +288,27 @@ test("Wrap markdown in input area", () => { endInFile: 7 } }) + + expect(newTree.computeLineNumbers()).toStrictEqual([]); }) +function configureNodeMock (deletedString : string) { + jest.spyOn(nodeMock, 'resolve').mockReturnValue(({ parent: { type: { name: 'doc' } } }) as ResolvedPos) + jest.spyOn(nodeMock, 'slice').mockReturnValue({} as Slice) + jest.spyOn(serializer, 'serializeFragment').mockReturnValue(deletedString) +} + 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) ]; + configureNodeMock("```coq\nLemma.\n```") + const mapping = createMapping(blocks); const tree = mapping.getMapping(); const codeNode = tree.root.children.find(node => node.type === "code"); @@ -277,7 +318,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, serializer, nodeMock); expect(result).toStrictEqual({ finalText: "", @@ -295,27 +336,34 @@ 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\n```coq\nX\n```B + // Code block opens at position 2 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: 2}, {from: 0, to: 2}, 0), + new CodeBlock("X", {from: 2, to: 14}, {from: 9, to: 10}, 1), + new MarkdownBlock("B", {from: 14, to: 15}, {from: 14, to: 15}, 0) ]; + configureNodeMock("A\n```coq\nX\n```B") + + const mapping = createMapping(blocks); const tree = mapping.getMapping(); const codeNode = tree.root.children.find(node => node.type === "code"); - const trailingMarkdown = tree.root.children.find(node => node.type === "markdown" && node.contentRange.from === 13); + const trailingMarkdown = tree.root.children.find(node => node.type === "markdown" && node.contentRange.from === 14); if (!codeNode || !trailingMarkdown) throw new Error("Test setup failed: missing code or trailing markdown node"); 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, serializer, nodeMock); expect(result).toStrictEqual({ finalText: "", @@ -327,14 +375,17 @@ test("Delete adjacent code and markdown blocks", () => { expect(newTree.root.children.length).toBe(1); 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); + expect(newTree.root.children[0].contentRange).toStrictEqual({from: 0, to: 2}); + expect(newTree.root.contentRange.to).toBe(2); + + // 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 +393,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, serializer, nodeMock); sanityCheckTree(newTree.root); expect(result).toStrictEqual({ @@ -350,8 +401,46 @@ test("Delete markdown cell", () => { startInFile: 7, endInFile: 14 }) + + expect(newTree.computeLineNumbers()).toStrictEqual([]); }) +test("Delete first of two codeblocks", () => { + // Simulates the following: + /* ```coq + Code + ``` + ```coq + More + ``` + */ + // Then deleting the first cell. + + configureNodeMock("```coq\nCode\n```\n") + const mapping = createMapping([ + new CodeBlock("Code", {from: 0, to: 15}, {from: 7, to: 11}, 1), + new NewlineBlock({from: 15, to: 16 }, {from: 15, to: 16}, 0), + new CodeBlock("More", {from: 16, to: 31}, {from: 23, to: 27}, 4) + ]); + + const slice: Slice = new Slice(Fragment.from([]), 0, 0); + + // PM layout: code={0,6} newline={6,7} code2={7,13} + // Delete the first code block + trailing newline in PM space + const step = new ReplaceStep(0, 7, slice); + + const nodeUpdate = new NodeUpdate(config, serializer); + const {newTree, result} = nodeUpdate.nodeUpdate(step, mapping, serializer, nodeMock); + sanityCheckTree(newTree.root); + + expect(result).toStrictEqual({ + finalText: "", + startInFile: 0, + endInFile: 16 + }) + + expect(newTree.computeLineNumbers()).toStrictEqual([1]); +}) test("Complex deletion", () => { // # Hello @@ -361,16 +450,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 +469,7 @@ test("Complex deletion", () => { const nodeUpdate = new NodeUpdate(config, serializer); - const {newTree, result} = nodeUpdate.nodeUpdate(step, mapping); + const {newTree, result} = nodeUpdate.nodeUpdate(step, mapping, serializer, nodeMock); console.log(JSON.stringify(newTree.root, null, " ")); sanityCheckTree(newTree.root); @@ -388,6 +478,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 +493,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 +516,9 @@ test("Complex deletion undo", () => { const nodeUpdate = new NodeUpdate(config, serializer); - const {newTree, result} = nodeUpdate.nodeUpdate(step, mapping); + + jest.spyOn(serializer, "serializeDocument").mockReturnValue("# Hello") + const {newTree, result} = nodeUpdate.nodeUpdate(step, mapping, serializer, nodeMock); console.log(JSON.stringify(newTree.root, null, " ")); sanityCheckTree(newTree.root); @@ -432,4 +527,195 @@ 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); + jest.spyOn(serializer, "serializeDocument").mockReturnValue("# Hello\n# World") + const {newTree} = nodeUpdate.nodeUpdate(step, mapping, serializer, nodeMock); + + // 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); + + jest.spyOn(serializer, "serializeDocument").mockReturnValue("Hello\n```coq\nCode\n```") + const {newTree} = nodeUpdate.nodeUpdate(step, mapping, serializer, nodeMock); + + // 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]); +}); + +test("Undo deletion of first codeblock (without newline)", () => { + // Simulates the document: + // ```coq + // Code + // ``` + // # Hello + // + // The user deletes the first code cell, then presses undo. + const mapping = createMapping([ + new CodeBlock("Code", {from: 0, to: 15}, {from: 7, to: 11}, 1), + new NewlineBlock({from: 15, to: 16}, {from: 15, to: 16}, 0), + new MarkdownBlock("# Hello", {from: 16, to: 23}, {from: 16, to: 23}, 0), + ]); + + // Step 1: Delete the first code block. + // ProseMirror's deleteSelection on a NodeSelection of the code block + // generates ReplaceStep(0, code.pmRange.to) = ReplaceStep(0, 6). + configureNodeMock("```coq\nCode\n```"); + const nodeUpdate = new NodeUpdate(config, serializer); + const deleteStep = new ReplaceStep(0, 6, Slice.empty); + + jest.spyOn(serializer, "serializeDocument").mockReturnValue("Hello\n```coq\nCode\n# Hello```") + nodeUpdate.nodeUpdate( + deleteStep, mapping, serializer, nodeMock + ); + + // Step 2: Undo — reinsert the code block at position 0. + // The inverse of ReplaceStep(0, 6, empty) is ReplaceStep(0, 0, original_slice). + const undoSlice = new Slice(Fragment.from([ + WaterproofSchema.nodes.code.create(null, + Fragment.from([WaterproofSchema.text("Code")]) + ), + ]), 0, 0); + const undoStep = new ReplaceStep(0, 0, undoSlice); + + jest.spyOn(serializer, "serializeDocument").mockReturnValue("# Hello```") + const { newTree, result } = nodeUpdate.nodeUpdate( + undoStep, mapping, serializer, nodeMock + ); + + sanityCheckTree(newTree.root); + + expect(result).toStrictEqual({ + finalText: "```coq\nCode\n```", + startInFile: 0, + endInFile: 0 + }); +}); + +// ── multilean (container) wrap / lift ───────────────────────────────────────── +// +// The Lean container tag is "::::multilean\n" (14 chars, 1 newline) open and +// "\n::::" (5 chars, 1 newline) close, so these tests verify that the code +// block's lineStart is updated correctly when the container tags are added or +// removed. + +test("Wrap code cell in multilean container shifts lineStart", () => { + // Document (Lean format): ```lean\nLemma.\n``` (18 chars) + // Line 0: ```lean Line 1: Lemma. ← code content starts at line 1 + const codeBlock = new CodeBlock( + "Lemma.", + { from: 0, to: 18 }, // "```lean\nLemma.\n```" + { from: 8, to: 14 }, // content (after "```lean\n") + 1 // lineStart: 1 newline in open tag + ); + const mapping = createLeanMapping([codeBlock]); + + expect(mapping.getMapping().computeLineNumbers()).toStrictEqual([1]); + + // The code node occupies pmRange {0, 8} (nodeSize = 1+6+1 = 8). + // wrap(blockRange, [{type: container, attrs:{name:"multilean"}}]) produces: + // ReplaceAroundStep(from=0, to=8, gapFrom=0, gapTo=8, slice=container, insert=1) + const wrapSlice = new Slice( + Fragment.from([WaterproofSchema.nodes.container.create({ name: "multilean" })]), + 0, 0 + ); + const wrapStep = new ReplaceAroundStep(0, 8, 0, 8, wrapSlice, 1); + + const nodeUpdate = new NodeUpdate(leanConfig, leanSerializer); + const { newTree, result } = nodeUpdate.nodeUpdate(wrapStep, mapping, leanSerializer, nodeMock); + + sanityCheckTree(newTree.root); + + // The container tags "::::multilean\n" and "\n::::" are inserted at the + // boundaries of the code block's original file range. + expect(result).toStrictEqual({ + firstEdit: { finalText: "::::multilean\n", startInFile: 0, endInFile: 0 }, + secondEdit: { finalText: "\n::::", startInFile: 18, endInFile: 18 }, + }); + + // The container open tag adds 1 newline, so the code content now starts at + // line 2: line 0 = "::::multilean", line 1 = "```lean", line 2 = "Lemma." + expect(newTree.computeLineNumbers()).toStrictEqual([2]); +}); + +test("Lift code cell from multilean container restores lineStart", () => { + // Document (Lean format): + // ::::multilean\n```lean\nLemma.\n```\n:::: + // 0 14 22 28 32 37 + // Line 0: ::::multilean Line 1: ```lean Line 2: Lemma. ← lineStart = 2 + const innerCode = new CodeBlock( + "Lemma.", + { from: 14, to: 32 }, // tagRange inside container + { from: 22, to: 28 }, // content range + 2 // lineStart inside container + ); + const containerBlock = new ContainerBlock( + "```lean\nLemma.\n```", + "multilean", + { from: 0, to: 37 }, // full range incl. container tags + { from: 14, to: 32 }, // inner range (just the code block) + 0, // container's own lineStart + [innerCode] + ); + const mapping = createLeanMapping([containerBlock]); + + expect(mapping.getMapping().computeLineNumbers()).toStrictEqual([2]); + + // ProseMirror layout for container(code("Lemma.")): + // container.pmRange = {0, 10}, prosemirrorStart=1, prosemirrorEnd=9 + // code.pmRange = {1, 9}, prosemirrorStart=2, prosemirrorEnd=8 + // wpLift generates: + // ReplaceAroundStep(from=container.pmRange.from, to=container.pmRange.to, + // gapFrom=container.prosemirrorStart, gapTo=container.prosemirrorEnd, + // slice=empty, insert=0) + const liftStep = new ReplaceAroundStep(0, 10, 1, 9, Slice.empty, 0); + + const nodeUpdate = new NodeUpdate(leanConfig, leanSerializer); + const { newTree, result } = nodeUpdate.nodeUpdate(liftStep, mapping, leanSerializer, nodeMock); + + sanityCheckTree(newTree.root); + + // The container open tag "::::multilean\n" (positions 0–13) and close tag + // "\n::::" (positions 32–36) are deleted. + expect(result).toStrictEqual({ + firstEdit: { finalText: "", startInFile: 0, endInFile: 14 }, + secondEdit: { finalText: "", startInFile: 32, endInFile: 37 }, + }); + + // After lifting, the code block is directly at the top level: + // ```lean\nLemma.\n``` → line 1 = "Lemma." → lineStart = 1 + expect(newTree.computeLineNumbers()).toStrictEqual([1]); +}); \ 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/__tests__/tsconfig.json b/__tests__/tsconfig.json new file mode 100644 index 0000000..b1e4f46 --- /dev/null +++ b/__tests__/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.test.json", + "compilerOptions": { + "types": ["jest", "node"] + }, + "include": ["./**/*.ts", "../src/**/*.ts", "../src/**/*.json"] +} diff --git a/jest.config.js b/jest.config.js index 05d13b5..f9f8d58 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,14 +1,16 @@ const { createDefaultEsmPreset } = require("ts-jest"); -const tsJestTransformCfg = createDefaultEsmPreset().transform; +const tsJestTransformCfg = createDefaultEsmPreset({ + tsconfig: "tsconfig.test.json", +}).transform; /** @type {import("jest").Config} **/ module.exports = { testEnvironment: "node", transform: { ...tsJestTransformCfg, - "^.*.js$": ["ts-jest"], - "^.*.css$": ["ts-jest"] + "^.*.js$": ["ts-jest", { tsconfig: "tsconfig.test.json" }], + "^.*.css$": ["ts-jest", { tsconfig: "tsconfig.test.json" }], }, transformIgnorePatterns: [ '/node_modules/(?!(@benrbray|katex)/)' diff --git a/src/api/types.ts b/src/api/types.ts index 47baa2f..3f0f1db 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -1,4 +1,5 @@ import { HighlightStyle, LanguageSupport } from "@codemirror/language"; +import { EditorState } from "prosemirror-state"; import { Block } from "../document"; import { DocumentSerializer } from "../serialization/DocumentSerializer"; import { WaterproofCompletion, WaterproofSymbol } from "./Completions"; @@ -84,6 +85,7 @@ export type TagConfiguration = { hint: { openTag: ((title: string) => string), closeTag: string } & RequiresNewline, input: OpenCloseTag & RequiresNewline, math: OpenCloseTag & RequiresNewline, + container: { openTag: (name: string) => string, closeTag: string } & RequiresNewline, } export class NodeUpdateError extends Error { @@ -105,6 +107,8 @@ export type MenuBarEntry = { hoverText: string; /** The function to execute when the button is clicked */ callback: () => void; + /** Optional predicate called with the current editor state to determine if the button should be active (enabled). When omitted the button is always active. */ + isActive?: (state: EditorState) => boolean; /** Control the visibility of the entry */ buttonVisibility?: { /** When set to true the entry will only be visible in teacher mode */ diff --git a/src/codeview/code-plugin.ts b/src/codeview/code-plugin.ts index 5c18aa8..4812eea 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,13 +73,23 @@ 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); + } + } } } } + // Prune stale views whose NodeView was destroyed by ProseMirror + // (e.g. after a ReplaceAroundStep / lift that recreates a NodeView). + for (const view of value.activeNodeViews) { + if (view._getPos() === undefined) { + value.activeNodeViews.delete(view); + } + } + // Update the state const meta = tr.getMeta(CODE_PLUGIN_KEY); if (meta) { @@ -88,10 +99,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..de016ae 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,10 +17,13 @@ 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, tagConf: TagConfiguration): Transaction | undefined { const sel = state.selection; let trans: Transaction = tr; + const insertNewlineBeforeIfNotExists = needsNewlineBefore(nodeType, tagConf); + const insertNewlineAfterIfNotExists = needsNewlineAfter(nodeType, tagConf); + const parentAndIndex = getParentAndIndex(sel); if (parentAndIndex === null) return; const {parent, index} = parentAndIndex; @@ -33,8 +37,7 @@ export function insertAbove(state: EditorState, tr: Transaction, nodeType: NodeT // To and from point directly to beginning and end of node. pos = sel.from; } else if (sel instanceof TextSelection) { - // TODO: This -1 is here to make sure that we do not insert 3 random code cells. - // I can't fully wrap my head around why it is needed at the moment though. + // This -1 is here to make sure we select the parent node pos = sel.from - sel.$from.parentOffset - 1; } else { return; @@ -48,13 +51,22 @@ export function insertAbove(state: EditorState, tr: Transaction, nodeType: NodeT const beforeNewline = parent.maybeChild(index - 2); const hasNewlineBefore = beforeNewline === null ? false : beforeNewline.type === WaterproofSchema.nodes.newline; + // A newline is also required after the new node if the current node (now below) requires one before its open tag. + const currentNode = parent.maybeChild(index); + const currentNeedsNewlineBefore = tagConf && currentNode ? needsNewlineBefore(currentNode.type, tagConf) : false; + 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()); - if (insertNewlineAfterIfNotExists && !beforeIsNewline) { + if ((insertNewlineAfterIfNotExists || currentNeedsNewlineBefore) && !beforeIsNewline) { toInsert.push(newline()); } @@ -70,10 +82,13 @@ 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, tagConf: TagConfiguration): Transaction | undefined { const sel = state.selection; let trans: Transaction = tr; - + + const insertNewlineBeforeIfNotExists = needsNewlineBefore(nodeType, tagConf); + const insertNewlineAfterIfNotExists = needsNewlineAfter(nodeType, tagConf); + const parentAndIndex = getParentAndIndex(sel); if (parentAndIndex === null) return; const {parent, index} = parentAndIndex; @@ -82,7 +97,7 @@ export function insertBelow(state: EditorState, tr: Transaction, nodeType: NodeT const afterIsNewline = nodeBelowSelection === null ? false : (nodeBelowSelection.type === WaterproofSchema.nodes.newline); let pos; - + if (sel instanceof NodeSelection) { // To and from point directly to beginning and end of node. pos = sel.to; @@ -96,17 +111,25 @@ export function insertBelow(state: EditorState, tr: Transaction, nodeType: NodeT // Assumption: If a newline appears after a node the current node wants that. pos += 1; // We are going to insert after } - + const afterNewline = parent.maybeChild(index + 2); 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); + // A newline is also required before the new node if the current node's close tag requires one. + const currentNode = parent.maybeChild(index); + const currentNeedsNewlineAfter = tagConf && currentNode ? needsNewlineAfter(currentNode.type, tagConf) : false; + const toInsert: PNode[] = []; - if (insertNewlineBeforeIfNotExists && !afterIsNewline) { + if ((insertNewlineBeforeIfNotExists || currentNeedsNewlineAfter) && !afterIsNewline) { toInsert.push(newline()); } toInsert.push(nodeType.create()); - if (insertNewlineAfterIfNotExists && !hasNewlineAfter && afterIsNewline) { + if ((insertNewlineAfterIfNotExists && !hasNewlineAfter && afterIsNewline) || + (belowNeedsNewlineBefore && !newlineAlreadyBelow)) { toInsert.push(newline()); } @@ -156,8 +179,9 @@ export function allowedToInsert(state: EditorState): boolean { export function checkInputArea(sel: Selection): boolean { const from = sel.$from; const depth = from.depth; - // An input area can only ever have depth = 1, since it is a - // top level node (see WaterproofSchema in `schema.ts`) + // An input area can be at depth = 1 (top level) or depth = 2 (inside a container) if (depth < 1) return false; - return from.node(1).type === WaterproofSchema.nodes.input; + if (from.node(1).type === WaterproofSchema.nodes.input) return true; + if (depth >= 2 && from.node(1).type === WaterproofSchema.nodes.container && from.node(2).type === WaterproofSchema.nodes.input) return true; + return false; } \ No newline at end of file diff --git a/src/commands/commands.ts b/src/commands/commands.ts index f1baf45..1270b08 100644 --- a/src/commands/commands.ts +++ b/src/commands/commands.ts @@ -1,9 +1,9 @@ -import { NodeType } from "prosemirror-model"; +import { Attrs, NodeType } from "prosemirror-model"; import { Command, EditorState, NodeSelection, TextSelection, Transaction } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; import { liftTarget } from "prosemirror-transform"; import { WaterproofSchema } from "../schema"; -import { getParentAndIndex, needsNewlineAfter, needsNewlineBefore } from "./utils"; +import { closingTagStartsWithNewline, getParentAndIndex, needsNewlineAfter, needsNewlineBefore, openingTagEndsWithNewline } from "./utils"; import { TagConfiguration } from "../api"; export function wpLift(_tagConf: TagConfiguration): Command { @@ -17,8 +17,8 @@ export function wpLift(_tagConf: TagConfiguration): Command { const after = $to.nodeAfter; const {type} = node; - if (type !== WaterproofSchema.nodes.hint && type !== WaterproofSchema.nodes.input) { - // We can only lift hint or input area nodes. + if (type !== WaterproofSchema.nodes.hint && type !== WaterproofSchema.nodes.input && type !== WaterproofSchema.nodes.container) { + // We can only lift hint, input area, or container nodes. return false; } @@ -29,29 +29,12 @@ export function wpLift(_tagConf: TagConfiguration): Command { const firstIsNewline = firstChild.type === WaterproofSchema.nodes.newline; const lastIsNewline = lastChild.type === WaterproofSchema.nodes.newline; - - const beforeIsNewline = before === null ? false : before.type === WaterproofSchema.nodes.newline; const afterIsNewline = after === null ? false : after.type === WaterproofSchema.nodes.newline; - // Can we assume that the newlines in the dcuments are always there for some node? - // const needsBefore = needsNewlineBefore(node.type, tagConf); - // const needsAfter = needsNewlineAfter(node.type, tagConf); - - // console.log("first", firstIsNewline, "last", lastIsNewline, "before", beforeIsNewline, "after", afterIsNewline, "needsBefore", needsBefore, "needsAfter", needsAfter); const shouldRemoveNewlineBefore = beforeIsNewline && firstIsNewline; const shouldRemoveNewlineAfter = afterIsNewline && lastIsNewline && childCount > 1; - // if (beforeIsNewline && firstIsNewline) { - // console.log("Both first child and before node are newlines"); - // console.log("We are going to remove the node before"); - // } - - // if (afterIsNewline && lastIsNewline && childCount > 1) { - // console.log("Both the last node and the after node are newlines (and the first and last child are not the same)"); - // console.log("We are going to remove the node after"); - // } - // Create a block range that covers the content of the input/hint block const range = state.doc.resolve(from + 1).blockRange(state.doc.resolve(to - 1)); if (range === null) return false; @@ -139,7 +122,19 @@ export function wrapInInput(tagConf: TagConfiguration): Command { return wpWrapIn(WaterproofSchema.nodes.input, tagConf); } -function wpWrapIn(nodeType: NodeType, tagConf: TagConfiguration): Command { +export function wrapInContainer(tagConf: TagConfiguration, name: string): Command { + return (state, dispatch) => { + const sel = state.selection; + if (!(sel instanceof NodeSelection)) return false; + // Nesting containers is not allowed for now, because we want to disallow it for multilean + // TODO: Possibly make this configurable if future usecases for containers do want nesting + if (sel.node.type === WaterproofSchema.nodes.container) return false; + + return wpWrapIn(WaterproofSchema.nodes.container, tagConf, {name})(state, dispatch) + } +} + +function wpWrapIn(nodeType: NodeType, tagConf: TagConfiguration, attrs? : Attrs): Command { return (state, dispatch) => { const sel = state.selection; if (!(sel instanceof NodeSelection)) return false; @@ -154,15 +149,12 @@ function wpWrapIn(nodeType: NodeType, tagConf: TagConfiguration): Command { const needsBefore = needsNewlineBefore(nodeBeingWrapped.type, tagConf); const needsAfter = needsNewlineAfter(nodeBeingWrapped.type, tagConf); - if ((needsBefore && !beforeIsNewline) || (needsAfter && !afterIsNewline)) { - return false; - } - let $start = sel.$from; let $end = sel.$to; - const consumeBefore = needsBefore && beforeIsNewline; - const consumeAfter = needsAfter && afterIsNewline; - // console.log("Consume before and after:", consumeBefore, consumeAfter); + + const consumeBefore = needsBefore && beforeIsNewline && !openingTagEndsWithNewline(nodeType, tagConf); + const consumeAfter = needsAfter && afterIsNewline && !closingTagStartsWithNewline(nodeType, tagConf); + if (before !== null && consumeBefore) { // extend the selection to incldue the before newline node $start = state.doc.resolve(sel.from - before.nodeSize); @@ -176,23 +168,19 @@ function wpWrapIn(nodeType: NodeType, tagConf: TagConfiguration): Command { const blockRange = $start.blockRange($end); if (blockRange === null) return false; const tr = state.tr; - tr.wrap(blockRange, [{type: nodeType}]); + tr.wrap(blockRange, [{type: nodeType, attrs}]); // We potentially have to insert newlines before or after the newly created input area. - if (consumeBefore) { - const nodeBeforeNewline = $start.nodeBefore; - if (nodeBeforeNewline !== null && needsNewlineAfter(nodeBeforeNewline.type, tagConf)) { - // Inserting newline before the input area - tr.insert(tr.mapping.map(blockRange.start) - 1, WaterproofSchema.nodes.newline.create()); - } + const nodeBefore = $start.nodeBefore; + if (nodeBefore !== null && nodeBefore.type !== WaterproofSchema.nodes.newline && (needsNewlineAfter(nodeBefore.type, tagConf) || needsNewlineBefore(nodeType, tagConf))) { + // Inserting newline before the input area + tr.insert(tr.mapping.map(blockRange.start) - 1, WaterproofSchema.nodes.newline.create()); } - if (consumeAfter) { - const nodeAfterNewline = $end.nodeAfter; - if (nodeAfterNewline !== null && needsNewlineBefore(nodeAfterNewline.type, tagConf)) { - // Inserting newline after the input area - tr.insert(tr.mapping.map(blockRange.end), WaterproofSchema.nodes.newline.create()); - } + const nodeAfter = $end.nodeAfter; + if (nodeAfter !== null && nodeAfter.type !== WaterproofSchema.nodes.newline && (needsNewlineBefore(nodeAfter.type, tagConf) || needsNewlineAfter(nodeType, tagConf))) { + // Inserting newline after the input area + tr.insert(tr.mapping.map(blockRange.end), WaterproofSchema.nodes.newline.create()); } // Finally, dispatch the transaction and set the selection to be the node selection of the newly created input area. diff --git a/src/commands/index.ts b/src/commands/index.ts index 3e2f853..935761b 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,3 +1,3 @@ // Export all insertion commands for use in the menubar or with keybindings. -export { wpLift, wrapInHint, wrapInInput, deleteSelection } from "./commands"; +export { wpLift, wrapInHint, wrapInInput, wrapInContainer, deleteSelection } from "./commands"; export { InsertionPlace } from "./types"; \ No newline at end of file diff --git a/src/commands/insert-command.ts b/src/commands/insert-command.ts index a399ba3..54be802 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); 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); 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); if (trans === undefined) { return false; } diff --git a/src/commands/utils.ts b/src/commands/utils.ts index 4a5c633..31d87f9 100644 --- a/src/commands/utils.ts +++ b/src/commands/utils.ts @@ -26,36 +26,31 @@ export function getParentAndIndex(sel: Selection): {parent: Node; index: number} return null; } +function tagConfForNodeType(nodeType: NodeType, tagConf: TagConfiguration) { + if (nodeType === WaterproofSchema.nodes.code) return tagConf.code; + if (nodeType === WaterproofSchema.nodes.hint) return tagConf.hint; + if (nodeType === WaterproofSchema.nodes.input) return tagConf.input; + if (nodeType === WaterproofSchema.nodes.markdown) return tagConf.markdown; + if (nodeType === WaterproofSchema.nodes.math_display) return tagConf.math; + if (nodeType === WaterproofSchema.nodes.container) return tagConf.container; + return null; +} + export function needsNewlineBefore(nodeType: NodeType, tagConf: TagConfiguration): boolean { - switch (nodeType) { - case WaterproofSchema.nodes.code: - return tagConf.code.openRequiresNewline; - case WaterproofSchema.nodes.hint: - return tagConf.hint.openRequiresNewline; - case WaterproofSchema.nodes.input: - return tagConf.input.openRequiresNewline; - case WaterproofSchema.nodes.markdown: - return tagConf.markdown.openRequiresNewline; - case WaterproofSchema.nodes.math_display: - return tagConf.math.openRequiresNewline; - default: - return false; - } + return tagConfForNodeType(nodeType, tagConf)?.openRequiresNewline ?? false; } export function needsNewlineAfter(nodeType: NodeType, tagConf: TagConfiguration): boolean { - switch (nodeType) { - case WaterproofSchema.nodes.code: - return tagConf.code.closeRequiresNewline; - case WaterproofSchema.nodes.hint: - return tagConf.hint.closeRequiresNewline; - case WaterproofSchema.nodes.input: - return tagConf.input.closeRequiresNewline; - case WaterproofSchema.nodes.markdown: - return tagConf.markdown.closeRequiresNewline; - case WaterproofSchema.nodes.math_display: - return tagConf.math.closeRequiresNewline; - default: - return false; - } -} \ No newline at end of file + return tagConfForNodeType(nodeType, tagConf)?.closeRequiresNewline ?? false; +} + +export function openingTagEndsWithNewline(nodeType: NodeType, tagConf: TagConfiguration): boolean { + const entry = tagConfForNodeType(nodeType, tagConf); + if (!entry) return false; + const openTag = typeof entry.openTag === "function" ? entry.openTag("") : entry.openTag; + return openTag.endsWith("\n"); +} + +export function closingTagStartsWithNewline(nodeType: NodeType, tagConf: TagConfiguration): boolean { + return tagConfForNodeType(nodeType, tagConf)?.closeTag.startsWith("\n") ?? false; +} diff --git a/src/document/blocks/block.ts b/src/document/blocks/block.ts index ced685a..fa312e6 100644 --- a/src/document/blocks/block.ts +++ b/src/document/blocks/block.ts @@ -8,6 +8,7 @@ export enum BLOCK_NAME { MARKDOWN = "markdown", CODE = "code", NEWLINE = "newline", + CONTAINER = "container", } export interface BlockRange { @@ -26,7 +27,7 @@ export interface Block { /** The linenumber (0 based) at the start of this block */ lineStart: number; - /** Blocks that are children of this block, only valid for InputArea and Hint Blocks. */ + /** Blocks that are children of this block, only valid for InputArea, Hint, and Container Blocks. */ innerBlocks?: Block[]; /** Convert this block to the corresponding ProseMirror node. */ diff --git a/src/document/blocks/blocktypes.ts b/src/document/blocks/blocktypes.ts index f33469b..dca93f4 100644 --- a/src/document/blocks/blocktypes.ts +++ b/src/document/blocks/blocktypes.ts @@ -1,7 +1,7 @@ import { Node } from "prosemirror-model"; import { WaterproofSchema } from "../../schema"; import { BLOCK_NAME, Block, BlockRange } from "./block"; -import { code, hint, inputArea, markdown, mathDisplay, newline } from "./schema"; +import { code, container, hint, inputArea, markdown, mathDisplay, newline } from "./schema"; const indentation = (level: number): string => " ".repeat(level); const debugInfo = (block: Block): string => `{range=${block.range.from}-${block.range.to}}`; @@ -168,4 +168,41 @@ export class NewlineBlock implements Block { debugPrint(level: number): void { console.log(`${indentation(level)}Newline`); } +} + +/** + * ContainerBlocks are generic container blocks that group multiple blocks together. + * They carry a name to identify the container type. + * In Lean context, multilean blocks are represented as containers with name "multilean". + * They can contain both top-level blocks (input, hint) and leaf blocks (math, code, markdown). + */ +export class ContainerBlock implements Block { + public type = BLOCK_NAME.CONTAINER; + public innerBlocks: Block[]; + + constructor( + public stringContent: string, + public name: string, + public range: BlockRange, + public innerRange: BlockRange, + public lineStart: number, + childBlocks: Block[] | ((innerContent: string, innerRange: BlockRange, lineStartOffset: number) => Block[]) + ) { + if (typeof childBlocks === "function") { + this.innerBlocks = childBlocks(stringContent, innerRange, lineStart); + } else { + this.innerBlocks = childBlocks; + } + } + + toProseMirror() { + const childNodes = this.innerBlocks.map(block => block.toProseMirror()); + return container(this.name, childNodes); + } + + debugPrint(level: number): void { + console.log(`${indentation(level)}ContainerBlock(${this.name}) {${debugInfo(this)}} [`); + this.innerBlocks.forEach(block => block.debugPrint(level + 1)); + console.log(`${indentation(level)}]`); + } } \ No newline at end of file diff --git a/src/document/blocks/schema.ts b/src/document/blocks/schema.ts index 976e3ba..7ebcce6 100644 --- a/src/document/blocks/schema.ts +++ b/src/document/blocks/schema.ts @@ -36,6 +36,11 @@ export const hint = (title: string, childNodes: ProseNode[]): ProseNode => { return WaterproofSchema.nodes.hint.create({title}, childNodes); } +/** Construct container prosemirror node. */ +export const container = (name: string, childNodes: ProseNode[]): ProseNode => { + return WaterproofSchema.nodes.container.create({name}, childNodes); +} + // ##### Special newline block ###### export const newline = () => { return WaterproofSchema.nodes.newline.create(); diff --git a/src/document/blocks/typeguards.ts b/src/document/blocks/typeguards.ts index 90cae3a..0ed96e0 100644 --- a/src/document/blocks/typeguards.ts +++ b/src/document/blocks/typeguards.ts @@ -1,9 +1,10 @@ import { BLOCK_NAME, Block } from "./block"; -import { CodeBlock, HintBlock, InputAreaBlock, MarkdownBlock, MathDisplayBlock, NewlineBlock } from "./blocktypes"; +import { CodeBlock, ContainerBlock, HintBlock, InputAreaBlock, MarkdownBlock, MathDisplayBlock, NewlineBlock } from "./blocktypes"; export const isInputAreaBlock = (block: Block): block is InputAreaBlock => block.type === BLOCK_NAME.INPUT_AREA; export const isHintBlock = (block: Block): block is HintBlock => block.type === BLOCK_NAME.HINT; export const isMathDisplayBlock = (block: Block): block is MathDisplayBlock => block.type === BLOCK_NAME.MATH_DISPLAY; export const isCodeBlock = (block: Block): block is CodeBlock => block.type === BLOCK_NAME.CODE; export const isMarkdownBlock = (block: Block): block is MarkdownBlock => block.type === BLOCK_NAME.MARKDOWN; -export const isNewlineBlock = (block: Block): block is NewlineBlock => block.type === BLOCK_NAME.NEWLINE; \ No newline at end of file +export const isNewlineBlock = (block: Block): block is NewlineBlock => block.type === BLOCK_NAME.NEWLINE; +export const isContainerBlock = (block: Block): block is ContainerBlock => block.type === BLOCK_NAME.CONTAINER; \ No newline at end of file diff --git a/src/editor.ts b/src/editor.ts index 11fdedb..d129642 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -2,7 +2,7 @@ import { mathPlugin, mathSerializer } from "@benrbray/prosemirror-math"; import { selectParentNode } from "prosemirror-commands"; import { keymap } from "prosemirror-keymap"; import { Node as ProseNode } from "prosemirror-model"; -import { EditorState, NodeSelection, Plugin, Selection, TextSelection, Transaction } from "prosemirror-state"; +import { Command, EditorState, NodeSelection, Plugin, Selection, TextSelection, Transaction } from "prosemirror-state"; import { ReplaceAroundStep, ReplaceStep, Step } from "prosemirror-transform"; import { EditorView } from "prosemirror-view"; import { undo, redo, history } from "prosemirror-history"; @@ -567,6 +567,7 @@ export class WaterproofEditor { const trans = state.tr; trans.setMeta(INPUT_AREA_PLUGIN_KEY, {teacher: isTeacher}); this._view.dispatch(trans); + this._editorElem.classList.toggle("teacher-mode", isTeacher); } public reportProgress(current: number, total: number, text?: string): void { @@ -758,6 +759,14 @@ export class WaterproofEditor { }); } + /** + * Execute a ProseMirror command on the editor. + * @param cmd The ProseMirror command to execute. + */ + public executeProsemirrorCommand(cmd: Command): void { + if (this._view) cmd(this._view.state, this._view.dispatch, this._view); + } + // Editor API // @internal public executeCommand(command: string) { 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/index.ts b/src/index.ts index 2ca1d74..d5d7958 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,3 +9,4 @@ export * as "markdown" from "./markdown-defaults"; export { DocumentSerializer, DefaultTagSerializer } from "./serialization/DocumentSerializer"; export { Node } from "prosemirror-model"; export * from "./edit-utils"; +export { wrapInContainer } from "./commands"; diff --git a/src/mapping/Tree.ts b/src/mapping/Tree.ts index ada7b6d..e319281 100644 --- a/src/mapping/Tree.ts +++ b/src/mapping/Tree.ts @@ -147,6 +147,11 @@ export class Tree { * Find the most specific node that contains the given ProseMirror position, this function is biased to find the * first node (in terms of position) containing the position. I.e. in a tree with a code cell that ends at 28 and a newline that * starts at 28, we will return the code cell when searching for position 28. + * + * When using this method, be careful of the cases where you can get the first child of some node, + * since these might need some special casing. We suspect that changing this function to have a + * right-bias rather than a left-bias shifts the above issue to concern the final child. + * * @param pos ProseMirror position to search for * @param node The node to start the search from, defaults to the root node of the tree * @returns The most specific node containing the position, or null if no such node exists @@ -195,4 +200,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..0e402bc 100644 --- a/src/mapping/mapping.ts +++ b/src/mapping/mapping.ts @@ -17,7 +17,16 @@ export class Mapping { private tree: Tree; /** The version of the underlying textDocument */ private _version: number; + /** + * Tracks the ProseMirror document after each processed step. + * The caller of `update` always passes the pre-transaction document, so for + * step N in a multi-step transaction the passed `doc` is already stale. + * We keep `_currentDoc` in sync by applying each step to it, so that + * subsequent calls within the same transaction see the correct document. + */ + private _currentDoc: Node | null = null; + private readonly serializer: DocumentSerializer; private readonly nodeUpdate: NodeUpdate; private readonly textUpdate: TextUpdate; @@ -26,6 +35,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; @@ -107,23 +117,37 @@ export class Mapping { // This is probably the most used path isText = true; } else { - // TODO: Figure out if this takes a lot of computation and whether we can do this more efficiently. - // A textual deletion has no content, but so do node deletions. We differentiate between them by - // checking what the parent node of the from position is. - const parentNodeType = doc.resolve(step.from).parent.type; + const nodeAtPos = this.tree.findNodeByProsePos(step.from); + + // This last condition has been introduced for the case where we're deleting the first node among children. isText = (step.slice.content.childCount === 0 && - (parentNodeType === WaterproofSchema.nodes.markdown || - parentNodeType === WaterproofSchema.nodes.code || - parentNodeType === WaterproofSchema.nodes.math_display)); + (nodeAtPos?.type === "markdown" || + nodeAtPos?.type === "code" || + nodeAtPos?.type === "math_display") && + step.from >= nodeAtPos.prosemirrorStart); } let result: ParsedStep; + // For multi-step transactions the caller passes the same pre-transaction `doc` for + // every step, so by step N it is stale. Use our internally-evolved document instead. + const currentDoc = this._currentDoc ?? doc; + // 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, currentDoc); + } + + this.tree = result.newTree; - this.tree = result.newTree + // Evolve _currentDoc by applying the step, so the next call in the same + // transaction receives the correct document rather than the stale original. + const applied = step.apply(currentDoc); + this._currentDoc = applied.doc ?? currentDoc; if ('finalText' in result.result) { if (this.checkDocChange(result.result)) this._version++; @@ -154,7 +178,9 @@ export class Mapping { function buildSubtree(blocks: Block[]): TreeNode[] { return blocks.map(block => { - const title = typeguards.isHintBlock(block) ? block.title : ""; + const title = typeguards.isHintBlock(block) ? block.title + : typeguards.isContainerBlock(block) ? block.name + : ""; const node = new TreeNode( block.type, diff --git a/src/mapping/nodeUpdate.ts b/src/mapping/nodeUpdate.ts index 8d740d6..a952005 100644 --- a/src/mapping/nodeUpdate.ts +++ b/src/mapping/nodeUpdate.ts @@ -3,10 +3,19 @@ import { OperationType, ParsedStep } from "./types"; import { Mapping } from "./mapping"; import { typeFromStep } from "./helper-functions"; import { DocChange, DocumentSerializer, NodeUpdateError, TagConfiguration, WrappingDocChange } from "../api"; +import { makeNeighbors } from "../serialization/DocumentSerializer"; 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) {} @@ -24,17 +33,19 @@ export class NodeUpdate { return [this.tagConf.input.openTag, this.tagConf.input.closeTag]; case "math_display": return [this.tagConf.math.openTag, this.tagConf.math.closeTag]; + case "container": + return [this.tagConf.container.openTag(title), this.tagConf.container.closeTag]; default: throw new NodeUpdateError(`Unsupported node type: ${nodeName}`); } } // Handle a node update step - public nodeUpdate(step: ReplaceStep | ReplaceAroundStep, mapping: Mapping) : ParsedStep { + public nodeUpdate(step: ReplaceStep | ReplaceAroundStep, mapping: Mapping, 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, serializer, proseDoc); } else { // The step is a ReplaceAroundStep (wrapping or unwrapping of nodes) parsedStep = this.doReplaceAroundStep(step, mapping); @@ -42,15 +53,15 @@ export class NodeUpdate { return parsedStep; } - doReplaceStep(step: ReplaceStep, mapping: Mapping): ParsedStep { + doReplaceStep(step: ReplaceStep, mapping: Mapping, 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(), serializer, proseDoc); 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 +85,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, serializer: DocumentSerializer, proseDoc : Node): 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,31 +109,23 @@ 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. + const serializedDoc = serializer.serializeDocument(proseDoc) + let lineCounter = countNewlines(serializedDoc.substring(0, documentPos)); step.slice.content.forEach((node, _, idx) => { const parentContent = step.slice.content; - - // Above - const nodeDirectlyAbove = parentContent.maybeChild(idx - 1); - const nodeTwoAbove = parentContent.maybeChild(idx - 2); - // Below - const nodeDirectlyBelow = parentContent.maybeChild(idx + 1); - const nodeTwoBelow = parentContent.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}; - }; + const func = makeNeighbors( + parentContent.maybeChild(idx - 1), parentContent.maybeChild(idx - 2), + parentContent.maybeChild(idx + 1), parentContent.maybeChild(idx + 2) + ); 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 +137,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 }; } - 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( @@ -182,48 +189,41 @@ export class NodeUpdate { ) } - const [openTagForNode, closeTagForNode] = this.nodeNameToTagPair(node.type.name, node.attrs.title ? node.attrs.title : ""); + const nodeTitle = node.attrs.title ? node.attrs.title : (node.attrs.name ? node.attrs.name : ""); + const [openTagForNode, closeTagForNode] = this.nodeNameToTagPair(node.type.name, nodeTitle); + + 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 {from: startOrig, to: 0}, // full range - node.attrs.title ? node.attrs.title : "", // title + nodeTitle, // 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 - const nodeDirectlyAbove = node.maybeChild(idx - 1); - const nodeTwoAbove = node.maybeChild(idx - 2); - - // Below - const nodeDirectlyBelow = node.maybeChild(idx + 1); - const nodeTwoBelow = node.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; + const func = makeNeighbors( + node.maybeChild(idx - 1), node.maybeChild(idx - 2), + node.maybeChild(idx + 1), node.maybeChild(idx + 2) + ); - return {nodeAbove: above, nodeBelow: below}; - }; - // Update the offsets for the next child 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,26 +240,44 @@ 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. + // proseDoc reflects the state after all prior steps in this transaction because + // Mapping._currentDoc is kept in sync and passed here as proseDoc. + 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) { + if (node.prosemirrorStart >= step.from && node.prosemirrorEnd < step.to) { nodesToDelete.push(node); 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,6 +299,7 @@ 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); @@ -342,6 +361,10 @@ export class NodeUpdate { nodesInRange.forEach(n => { // We update their positions n.shiftOffsets(-wrappedOpenTag.length, -1); + // The open tag may contain newlines (e.g. "::::multilean\n") that were + // counted towards the children's lineStart when wrapping happened. + // Lifting removes those newlines, so subtract them here. + n.shiftLineStart(-countNewlines(wrappedOpenTag)); // and add them to the parent of the wrapper node wrapperParent.addChild(n); }); @@ -349,7 +372,7 @@ export class NodeUpdate { return { result: docChange, newTree: tree }; } - replaceAroundReplace(step: ReplaceAroundStep, tree: Tree): ParsedStep { + replaceAroundReplace(step: ReplaceAroundStep, tree: Tree): ParsedStep { // We start by checking what kind of node we are wrapping with const wrappingNode = step.slice.content.firstChild; if (!wrappingNode) { @@ -363,24 +386,41 @@ export class NodeUpdate { throw new NodeUpdateError(" We only support ReplaceAroundSteps with a single wrapping node "); } - // Check that the wrapping node is of a supported type (hint or input) + // Check that the wrapping node is of a supported type (hint, input, or container) const insertedNodeType = wrappingNode.type.name; - if (insertedNodeType !== "hint" && insertedNodeType !== "input") { - throw new NodeUpdateError(" We only support wrapping in hints or inputs "); + if (insertedNodeType !== "hint" && insertedNodeType !== "input" && insertedNodeType !== "container") { + throw new NodeUpdateError(" We only support wrapping in hints, inputs, or containers "); } - // If we are wrapping in a hint node we need to have a title attribute - const title: string = insertedNodeType === "hint" ? wrappingNode.attrs.title : ""; + // If we are wrapping in a hint node we need to have a title attribute; container uses name attribute + const title: string = insertedNodeType === "hint" ? wrappingNode.attrs.title + : insertedNodeType === "container" ? wrappingNode.attrs.name + : ""; // Get the tags for the wrapping node const [openTag, closeTag] = this.nodeNameToTagPair(insertedNodeType, title); // The step includes a range of nodes that are wrapped. We use the mapping // to find the node at gapFrom (the first one being wrapped) and the node // at gapTo (the last one being wrapped). - const nodesBeingWrappedStart = tree.findNodeByProsePos(step.gapFrom); + let nodesBeingWrappedStart = tree.findNodeByProsePos(step.gapFrom); const nodesBeingWrappedEnd = tree.findNodeByProsePos(step.gapTo); // If one of the two doesn't exist we error if (!nodesBeingWrappedStart || !nodesBeingWrappedEnd) throw new NodeUpdateError(" Could not find node in mapping "); + + const openTagLines = countNewlines(openTag); + const closeTagLines = countNewlines(closeTag); + + // findNodeByProsePos is biased: at a boundary position it returns the node ENDING there. + // If gapFrom equals nodesBeingWrappedStart.pmRange.to, we got the preceding node instead + // of the node that starts at gapFrom. Advance to the next sibling to correct this. + if (nodesBeingWrappedStart.pmRange.to === step.gapFrom) { + const parent = tree.findParent(nodesBeingWrappedStart); + const siblings = parent ? parent.children : tree.root.children; + const idx = siblings.indexOf(nodesBeingWrappedStart); + if (idx + 1 < siblings.length) { + nodesBeingWrappedStart = siblings[idx + 1]; + } + } // Generate the document change (this is a wrapping document change) const docChange: WrappingDocChange = { @@ -436,6 +476,7 @@ export class NodeUpdate { tree.traverseDepthFirst((thisNode: TreeNode) => { if (thisNode.pmRange.from >= positions.proseEnd) { thisNode.shiftOffsets(openTag.length + closeTag.length, 2); + thisNode.shiftLineStart(openTagLines + closeTagLines); } }); @@ -445,11 +486,12 @@ export class NodeUpdate { nodesInRange.forEach(n => { newNode.addChild(n); n.shiftOffsets(openTag.length, 1); + n.shiftLineStart(openTagLines); }); tree.root.shiftCloseOffsets(openTag.length + closeTag.length, 2); - return {result: docChange, newTree: tree}; + return { result: docChange, newTree: tree }; } } diff --git a/src/mapping/textUpdate.ts b/src/mapping/textUpdate.ts index 9545c15..9ca4aec 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 }; } } diff --git a/src/mapping/types.ts b/src/mapping/types.ts index 8ab833b..14b51dd 100644 --- a/src/mapping/types.ts +++ b/src/mapping/types.ts @@ -8,7 +8,7 @@ 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; } /** diff --git a/src/markdown-defaults/index.ts b/src/markdown-defaults/index.ts index c35de30..43237e1 100644 --- a/src/markdown-defaults/index.ts +++ b/src/markdown-defaults/index.ts @@ -28,6 +28,11 @@ export function configuration(languageId: string): TagConfiguration { math: { openTag: "$$", closeTag: "$$", openRequiresNewline: false, closeRequiresNewline: false + }, + container: { + openTag: (_name: string) => "", + closeTag: "", + openRequiresNewline: false, closeRequiresNewline: false } } }; diff --git a/src/menubar/menubar.ts b/src/menubar/menubar.ts index 05952ba..2270a4a 100644 --- a/src/menubar/menubar.ts +++ b/src/menubar/menubar.ts @@ -1,5 +1,5 @@ import { selectParentNode } from "prosemirror-commands"; -import { Command, PluginView, Plugin, PluginKey } from "prosemirror-state"; +import { Command, PluginView, Plugin, PluginKey, EditorState } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; import { INPUT_AREA_PLUGIN_KEY } from "../inputArea"; import { InsertionPlace, wrapInHint, wrapInInput, deleteSelection, wpLift } from "../commands"; @@ -14,6 +14,7 @@ type MenuEntry = { showByDefault: boolean; cmd: Command; customEntry: boolean; + isActive?: (state: EditorState) => boolean; }; /** @@ -110,6 +111,10 @@ class MenuView implements PluginView { item.dom.style.opacity = active ? "1" : "0.4"; // And make it unclickable. item.dom.setAttribute("disabled", (!active).toString()); + } else if (item.isActive) { + const active = item.isActive(this.view.state); + item.dom.style.opacity = active ? "1" : "0.4"; + item.dom.setAttribute("disabled", (!active).toString()); } } } @@ -163,34 +168,38 @@ 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 Code Block + 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)); + const customMenuItems = customEntries?.map(entry => { + const item = createMenuItem(entry.title, entry.hoverText, () => { entry.callback(); return true; }, entry.buttonVisibility, true); + item.isActive = entry.isActive; + return item; + }); 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/schema/schema.ts b/src/schema/schema.ts index eb1fe22..58e4ca7 100644 --- a/src/schema/schema.ts +++ b/src/schema/schema.ts @@ -6,7 +6,8 @@ export const SchemaCell = { Markdown: "markdown", MathDisplay: "math_display", Code: "code", - Newline: "newline" + Newline: "newline", + Container: "container" } as const; export type SchemaKeys = keyof typeof SchemaCell; @@ -37,7 +38,7 @@ export const WaterproofSchema = new Schema({ markdown: { block: true, content: "text*", - group: "cell containercontent", + group: "cell hintinputcontent containercontent", parseDOM: [{tag: "markdown", preserveWhitespace: "full"}], atom: true, toDOM: () => { @@ -49,8 +50,8 @@ export const WaterproofSchema = new Schema({ /////// HINT ////// //#region Hint hint: { - content: "containercontent+", - group: "cell", + content: "hintinputcontent+", + group: "cell containercontent", attrs: { title: {default: "💡 Hint"}, shown: {default: false} @@ -64,8 +65,8 @@ export const WaterproofSchema = new Schema({ /////// Input Area ////// //#region input input: { - content: "containercontent+", - group: "cell", + content: "hintinputcontent+", + group: "cell containercontent", attrs: { status: {default: null} }, @@ -79,7 +80,7 @@ export const WaterproofSchema = new Schema({ //#region Code code: { content: "text*",// content is of type text - group: "cell containercontent", + group: "cell hintinputcontent containercontent", code: true, atom: true, // doesn't have directly editable content (content is edited through codemirror) toDOM(node) { return ["WaterproofCode", node.attrs, 0] } // cells @@ -90,7 +91,7 @@ export const WaterproofSchema = new Schema({ /////// MATH DISPLAY ////// //#region math-display math_display: { - group: "math cell containercontent", + group: "math cell hintinputcontent containercontent", content: "text*", atom: true, code: true, @@ -99,9 +100,23 @@ export const WaterproofSchema = new Schema({ //#endregion newline: { - group: "cell containercontent", + group: "cell hintinputcontent containercontent", toDOM(node) { return ["WaterproofNewline", node.attrs]}, selectable: false, - } + }, + + /////// CONTAINER ////// + //#region container + container: { + content: "containercontent+", + group: "cell", + attrs: { + name: {default: ""} + }, + toDOM: (node) => { + return ["div", {class: "container", "data-name": node.attrs.name}, 0]; + } + }, + //#endregion } }); \ No newline at end of file diff --git a/src/serialization/DocumentSerializer.ts b/src/serialization/DocumentSerializer.ts index 193ec06..d260d2b 100644 --- a/src/serialization/DocumentSerializer.ts +++ b/src/serialization/DocumentSerializer.ts @@ -1,7 +1,28 @@ -import { Node } from "prosemirror-model"; +import { Fragment, Node } from "prosemirror-model"; import { WaterproofSchema } from "../schema"; import { TagConfiguration } from "../api"; +export function makeNeighbors( + above: Node | null | undefined, + twoAbove: Node | null | undefined, + below: Node | null | undefined, + twoBelow: Node | null | undefined +): (skipNewlines: boolean) => { nodeAbove: string | null; nodeBelow: string | null } { + return (skipNewlines) => { + let nodeAbove = above?.type.name ?? null; + let nodeBelow = below?.type.name ?? null; + if (nodeAbove === "newline" && skipNewlines) nodeAbove = twoAbove?.type.name ?? null; + if (nodeBelow === "newline" && skipNewlines) nodeBelow = twoBelow?.type.name ?? null; + return { nodeAbove, nodeBelow }; + }; +} + +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. @@ -47,6 +68,16 @@ export abstract class DocumentSerializer { * of the node are newline nodes they will be skipped and the next nodes will be returned (if they exist). */ abstract serializeHint(hintNode: Node, parentNode: string | null, neighbors: (skipNewlines: boolean) => {nodeAbove: string | null, nodeBelow: string | null}): string; + + /** + * Describes how to turn a container node into a string representation. + * This node can have children (including input areas and hints), so you probably want to call `this.serializeNode` on every child node. + * The container's name can be retrieved via `containerNode.attrs.name`. + * @param containerNode The container node that is going to be serialized + * @param parentNode The parent node of this node (if it has one) + * @param neighbors Function that upon calling will return the neighbors of the node being serialized. + */ + abstract serializeContainer(containerNode: Node, parentNode: string | null, neighbors: (skipNewlines: boolean) => {nodeAbove: string | null, nodeBelow: string | null}): string; serializeText(node: Node): string { return node.textContent; @@ -57,9 +88,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) { @@ -68,40 +102,47 @@ export abstract class DocumentSerializer { case WaterproofSchema.nodes.math_display: return this.serializeMath(node, parent, neighbors); case WaterproofSchema.nodes.input: return this.serializeInput(node, parent, neighbors); case WaterproofSchema.nodes.hint: return this.serializeHint(node, parent, neighbors); + case WaterproofSchema.nodes.container: return this.serializeContainer(node, parent, neighbors); 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. * - * @param node + * 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 serializeDocument(node: Node) { + public serializeFragment(fragment: Fragment, parent: string | null): string { const output: string[] = []; - node.content.forEach((child, _, idx) => { - const nodeDirectlyAbove = node.maybeChild(idx - 1); - const nodeTwoAbove = node.maybeChild(idx - 2); - - const nodeDirectlyBelow = node.maybeChild(idx + 1); - const nodeTwoBelow = node.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, node.type.name, func)); + fragment.forEach((child, _, idx) => { + const func = makeNeighbors( + fragment.maybeChild(idx - 1), fragment.maybeChild(idx - 2), + fragment.maybeChild(idx + 1), fragment.maybeChild(idx + 2) + ); + output.push(this.serializeNode(child, parent, func)); }); return output.join(""); } + + /** + * Serializes the whole ProseMirror document into its string representation. + * + * @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): string { + return this.serializeFragment(node.content, node.type.name); + } } export class DefaultTagSerializer extends DocumentSerializer { @@ -142,4 +183,9 @@ export class DefaultTagSerializer extends DocumentSerializer { }); return this.tagConf.hint.openTag(title) + textContent.join("") + this.tagConf.hint.closeTag; } + + serializeContainer(node: Node): string { + const name = node.attrs.name as string; + return this.tagConf.container.openTag(name) + this.serializeFragment(node.content, "container") + this.tagConf.container.closeTag; + } } \ No newline at end of file diff --git a/src/styles/container.css b/src/styles/container.css new file mode 100644 index 0000000..6eb18cf --- /dev/null +++ b/src/styles/container.css @@ -0,0 +1,10 @@ +.container { + display: block; + position: relative; + margin: 4px 0; +} + +.teacher-mode .container { + padding-left: 8px; + border-left: 4px dashed var(--wp-listFilterWidgetBackground); +} diff --git a/src/styles/index.ts b/src/styles/index.ts index 1831aec..e0ce729 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -9,6 +9,9 @@ import "./style.css"; // for hints import "./hints.css"; +// for containers +import "./container.css"; + // for menubar import "./menubar.css"; diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..edd0122 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "./dist-test", + "module": "nodenext", + "moduleResolution": "nodenext", + "isolatedModules": true, + "emitDeclarationOnly": false, + "declaration": false, + "declarationMap": false + }, + "include": ["src/**/*.ts", "src/**/*.json", "__tests__/**/*.ts"] +}