From e373315c48214e18640ddd30ded7d48b84072c84 Mon Sep 17 00:00:00 2001 From: DikieDick <26772815+DikieDick@users.noreply.github.com> Date: Sat, 13 Sep 2025 13:55:40 +0200 Subject: [PATCH 01/50] Update to include changes from wp-vscode mapping_reconstruct --- __tests__/utils.test.ts | 66 +++++++++------ src/api/types.ts | 3 +- src/commands/commands.ts | 24 ++---- src/commands/insert-command.ts | 14 +--- src/document/blocks/block.ts | 10 ++- src/document/blocks/blocktypes.ts | 131 ++++++++++++------------------ src/document/blocks/index.ts | 2 +- src/document/blocks/schema.ts | 19 +---- src/document/blocks/typeguards.ts | 9 +- src/document/utils.ts | 37 ++++----- src/editor.ts | 22 +++-- src/menubar/menubar.ts | 26 +++--- src/schema/schema.ts | 45 +++------- 13 files changed, 168 insertions(+), 240 deletions(-) diff --git a/__tests__/utils.test.ts b/__tests__/utils.test.ts index dc3ba7f..a3ef8b3 100644 --- a/__tests__/utils.test.ts +++ b/__tests__/utils.test.ts @@ -1,18 +1,20 @@ -import { Block, utils } from "../src/document"; +import { Block } from "../src/document/blocks"; import { text } from "../src/document/blocks/schema"; +import { extractInterBlockRanges, iteratePairs, maskInputAndHints, sortBlocks } from "../src/document/utils"; const toProseMirror = () => text("null"); const debugPrint = () => null; +const innerRange = {from: 0, to: 0}; test("Sort blocks #1", () => { const stringContent = ""; const testBlocks = [ - {type: "second", range: {from: 1, to: 2}, stringContent, toProseMirror, debugPrint}, - {type: "first", range: {from: 0, to: 1}, stringContent, toProseMirror, debugPrint} + {type: "second", range: {from: 1, to: 2}, innerRange, stringContent, toProseMirror, debugPrint}, + {type: "first", range: {from: 0, to: 1}, innerRange, stringContent, toProseMirror, debugPrint} ]; - const sorted = utils.sortBlocks(testBlocks); + const sorted = sortBlocks(testBlocks); expect(sorted.length).toBe(2); expect(sorted[0].type).toBe("first"); expect(sorted[1].type).toBe("second"); @@ -22,12 +24,12 @@ test("Sort blocks #2", () => { const stringContent = ""; const testBlocks = [ - {type: "second", range: {from: 1, to: 2}, stringContent, toProseMirror, debugPrint}, - {type: "first", range: {from: 0, to: 1}, stringContent, toProseMirror, debugPrint}, - {type: "third", range: {from: 2, to: 3}, stringContent, toProseMirror, debugPrint} + {type: "second", range: {from: 1, to: 2}, innerRange, stringContent, toProseMirror, debugPrint}, + {type: "first", range: {from: 0, to: 1}, innerRange, stringContent, toProseMirror, debugPrint}, + {type: "third", range: {from: 2, to: 3}, innerRange, stringContent, toProseMirror, debugPrint} ]; - const sorted = utils.sortBlocks(testBlocks); + const sorted = sortBlocks(testBlocks); expect(sorted.length).toBe(3); expect(sorted[0].type).toBe("first"); expect(sorted[1].type).toBe("second"); @@ -39,7 +41,7 @@ test("Sort blocks #2", () => { // const stringContent = ""; // const toProseMirror = () => null; // const testBlocks = [ -// {type: "second", range: {from: 0, to: 1}, stringContent, toProseMirror}, +// {type: "second", range: {from: 0, to: 1}, stringContent, toProseMirror}, // {type: "first", range: {from: 0, to: 1}, stringContent, toProseMirror} // ]; @@ -52,43 +54,43 @@ test("Sort blocks #2", () => { test("Iterate pairs (normal)", () => { const input = [1, 2, 3, 4]; const expectedResult = [3, 5, 7]; - const result = utils.iteratePairs(input, (a, b) => a + b); + const result = iteratePairs(input, (a, b) => a + b); expect(result).toEqual(expectedResult); }); test("Iterate pairs (single element array)", () => { const input: never[] = []; const expectedResult: never[] = []; - const result = utils.iteratePairs(input, (a, b) => b); + const result = iteratePairs(input, (a, b) => b); expect(result).toEqual(expectedResult); }); test("Iterate pairs (single element array)", () => { - const input: Array = ["test"]; - const expectedResult: Array = []; - const result = utils.iteratePairs(input, (a, b) => b.length); + const input = ["test"]; + const expectedResult: string[] = []; + const result = iteratePairs(input, (a, b) => b.length); expect(result).toEqual(expectedResult); }); test("Mask input and hints #1", () => { const inputDocument = "# Example\n\n# Test input area\n\n"; const blocks = [ - {type: "input_area", range: {from: 10, to: 54}, stringContent: "# Test input area", toProseMirror, debugPrint} + {type: "input_area", range: {from: 10, to: 54}, innerRange, stringContent: "# Test input area", toProseMirror, debugPrint} ]; const maskedString = "# Example\n \n"; - expect(utils.maskInputAndHints(inputDocument, blocks)).toEqual(maskedString); + expect(maskInputAndHints(inputDocument, blocks)).toEqual(maskedString); }); test("Mask input and hints #2", () => { const inputDocument = `\nThis is a test hint\n<\\hint>\n# Example\n\n# Test input area\n\n`; const blocks = [ - {type: "hint", range: {from: 0, to: 47}, stringContent: "This is a test hint", toProseMirror, debugPrint}, - {type: "input_area", range: {from: 58, to: 102}, stringContent: "# Test input area", toProseMirror, debugPrint} + {type: "hint", range: {from: 0, to: 47}, innerRange, stringContent: "This is a test hint", toProseMirror, debugPrint}, + {type: "input_area", range: {from: 58, to: 102}, innerRange, stringContent: "# Test input area", toProseMirror, debugPrint} ]; const maskedString = " \n# Example\n \n"; - expect(utils.maskInputAndHints(inputDocument, blocks)).toEqual(maskedString); + expect(maskInputAndHints(inputDocument, blocks)).toEqual(maskedString); }); test("Extract inter-block ranges", () => { @@ -98,12 +100,12 @@ test("Extract inter-block ranges", () => { const document = "Hello, this is a test document, I am testing this document. Test test test test." const blocks: Block[] = [ - { range: { from: 0, to: 10 }, type, stringContent, toProseMirror, debugPrint }, - { range: { from: 15, to: 20 }, type, stringContent, toProseMirror, debugPrint }, - { range: { from: 25, to: 30 }, type, stringContent, toProseMirror, debugPrint }, + { range: { from: 0, to: 10 }, innerRange, type, stringContent, toProseMirror, debugPrint }, + { range: { from: 15, to: 20 }, innerRange, type, stringContent, toProseMirror, debugPrint }, + { range: { from: 25, to: 30 }, innerRange, type, stringContent, toProseMirror, debugPrint }, ]; - const interBlockRanges = utils.extractInterBlockRanges(blocks, document); + const interBlockRanges = extractInterBlockRanges(blocks, document); expect(interBlockRanges.length).toBe(3); expect(interBlockRanges[0]).toEqual({ from: 10, to: 15 }); @@ -111,12 +113,28 @@ test("Extract inter-block ranges", () => { expect(interBlockRanges[2]).toEqual({ from: 30, to: document.length }) }); +test("Extract inter-block ranges with touching blocks", () => { + const type = "test"; + const stringContent = "test"; + + const document = "012345678901234567890123456789" + + const blocks: Block[] = [ + { range: { from: 0, to: 10 }, innerRange, type, stringContent, toProseMirror, debugPrint }, + { range: { from: 10, to: 20 }, innerRange, type, stringContent, toProseMirror, debugPrint }, + { range: { from: 20, to: 30 }, innerRange, type, stringContent, toProseMirror, debugPrint }, + ]; + + const interBlockRanges = extractInterBlockRanges(blocks, document); + expect(interBlockRanges.length).toBe(0); +}); + test("Extract inter-block ranges with no blocks", () => { const document = "Hello, this is a test document, I am testing this document. Test test test test." const blocks: Block[] = []; - const interBlockRanges = utils.extractInterBlockRanges(blocks, document); + const interBlockRanges = extractInterBlockRanges(blocks, document); expect(interBlockRanges.length).toBe(1); expect(interBlockRanges[0]).toEqual({ from: 0, to: document.length }); diff --git a/src/api/types.ts b/src/api/types.ts index 9141a12..296475b 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -39,7 +39,6 @@ export type WaterproofCallbacks = { } export abstract class WaterproofMapping { - abstract getMapping: () => Map; abstract get version(): number; abstract findPosition: (index: number) => number; abstract findInvPosition: (index: number) => number; @@ -63,7 +62,7 @@ export type WaterproofEditorConfig = { /** Determines how the editor document gets constructed from a string input */ documentConstructor: (document: string) => WaterproofDocument, /** How to construct a mapping for this editor. The mapping is responsible for mapping changes from the underlying ProseMirror instance into changes that can be applied to the underlying document. */ - mapping: new (inputString: string, versionNum: number) => WaterproofMapping, + mapping: new (inputDocument: WaterproofDocument, versionNum: number) => WaterproofMapping, /** THIS IS A TEMPORARY FEATURE THAT WILL GET REMOVED */ documentPreprocessor?: (inputString: string) => {resultingDocument: string, documentChange: DocChange | WrappingDocChange | undefined}, } diff --git a/src/commands/commands.ts b/src/commands/commands.ts index 2e95e76..0929097 100644 --- a/src/commands/commands.ts +++ b/src/commands/commands.ts @@ -1,4 +1,4 @@ -import { NodeRange, Schema } from "prosemirror-model"; +import { NodeRange } from "prosemirror-model"; import { Command, EditorState, NodeSelection, Transaction } from "prosemirror-state"; import { insertAbove, insertUnder } from "./command-helpers"; import { InsertionPlace } from "./types"; @@ -6,6 +6,7 @@ import { getCodeInsertCommand, getLatexInsertCommand, getMdInsertCommand } from import { EditorView } from "prosemirror-view"; import { liftTarget } from "prosemirror-transform"; import { FileFormat } from "../api"; +import { WaterproofSchema } from "../schema"; /** * Get the insertion function needed for insertion at `place`. @@ -27,17 +28,13 @@ function getInsertionFunction(place: InsertionPlace) { /** * Creates a command that creates a new code cell above/underneath the currently selected node. - * @param schema The schema to use * @param filef The format of the currently opened file. * @param insertionPlace The place to insert the new node into: Underneath or Above the current node. * @returns The `Command`. */ -export function cmdInsertCode(schema: Schema, filef: FileFormat, insertionPlace: InsertionPlace): Command { - // Get node types for coqblock container and coqcode cell from the schema. - const coqblockNodeType = schema.nodes["coqblock"]; - const coqcodeNodeType = schema.nodes["coqcode"]; +export function cmdInsertCode(filef: FileFormat, insertionPlace: InsertionPlace): Command { // Return a command with the correct insertion place and function. - return getCodeInsertCommand(filef, getInsertionFunction(insertionPlace), insertionPlace, coqblockNodeType, coqcodeNodeType); + return getCodeInsertCommand(filef, getInsertionFunction(insertionPlace), insertionPlace, WaterproofSchema.nodes.code); } //// MARKDOWN //// @@ -56,18 +53,16 @@ export function cmdInsertCode(schema: Schema, filef: FileFormat, insertionPlace: /** * Creates a command that creates a new markdown cell underneath/above the currently selected node. - * @param schema The schema to use * @param filef The fileformat of the file currently opened. * @param insertionPlace The place to insert at: Above or Underneath current node. * @returns The `Command`. */ -export function cmdInsertMarkdown(schema: Schema, filef: FileFormat, insertionPlace: InsertionPlace): Command { +export function cmdInsertMarkdown(filef: FileFormat, insertionPlace: InsertionPlace): Command { // Retrieve the node types for both markdown and coqdoc markdown (coqdown) from the schema. - const mdNodeType = schema.nodes["markdown"]; - const coqMdNodeType = schema.nodes["coqdown"]; + const mdNodeType = WaterproofSchema.nodes.markdown; // Return a command with the correct insertion command and place. return getMdInsertCommand(filef, getInsertionFunction(insertionPlace), - insertionPlace, mdNodeType, coqMdNodeType); + insertionPlace, mdNodeType); } //// DISPlAY MATH //// @@ -79,14 +74,13 @@ export function cmdInsertMarkdown(schema: Schema, filef: FileFormat, insertionPl /** * Returns a command that inserts a new Display Math cell above/underneath the currently selected cell. - * @param schema The schema in use. * @param filef The file format of the current file. * @param insertionPlace The place to insert the node at Above or Underneath the current node. * @returns The `Command` */ -export function cmdInsertLatex(schema: Schema, filef: FileFormat, insertionPlace: InsertionPlace): Command { +export function cmdInsertLatex(filef: FileFormat, insertionPlace: InsertionPlace): Command { // Get latex node type from the schema. - const latexNodeType = schema.nodes["math_display"]; + const latexNodeType = WaterproofSchema.nodes.math_display; // Return the command with correct insertion place. return getLatexInsertCommand(filef, getInsertionFunction(insertionPlace), insertionPlace, latexNodeType); } diff --git a/src/commands/insert-command.ts b/src/commands/insert-command.ts index 7229406..fea3efe 100644 --- a/src/commands/insert-command.ts +++ b/src/commands/insert-command.ts @@ -18,16 +18,12 @@ export function getMdInsertCommand( filef: FileFormat, insertionFunction: InsertionFunction, place: InsertionPlace, - mvNodeType: NodeType, - vNodeType: NodeType + nodeType: NodeType ): Command { return (state: EditorState, dispatch?: ((tr: Transaction) => void), _view?: EditorView): boolean => { // Early return when inserting is not allowed if (!allowedToInsert(state)) return false; - // Retrieve the correct node given the fileformat. - const nodeType = filef == FileFormat.MarkdownV ? mvNodeType : vNodeType; - // Get the containing node for this selection. const container = getContainingNode(state.selection); @@ -128,8 +124,7 @@ export function getCodeInsertCommand( filef: FileFormat, insertionFunction: InsertionFunction, place: InsertionPlace, - coqblockNodeType: NodeType, - coqcodeNodeType: NodeType, + codeNodeType: NodeType ): Command { return (state: EditorState, dispatch?: ((tr: Transaction) => void), _view?: EditorView): boolean => { // Again, early return when inserting is not allowed. @@ -142,13 +137,12 @@ export function getCodeInsertCommand( if (filef === FileFormat.MarkdownV) { if (name === "input" || name === "hint" || name === "doc") { // Create a new coqblock *and* coqcode cell and insert Above or Underneath the current selection. - trans = insertionFunction(state, state.tr, coqblockNodeType, coqcodeNodeType); + trans = insertionFunction(state, state.tr, codeNodeType); } else if (name === "coqblock" || name === "coqdoc") { // Find the position outside of the coqblock and insert a new coqblock and coqcode cell above or underneath. const {start, end} = getNearestPosOutsideCoqblock(state.selection, state); const pos = place == InsertionPlace.Above ? start : end; - trans = state.tr.insert(pos, - coqblockNodeType.create()).insert(pos + 1,coqcodeNodeType.create()); + trans = state.tr.insert(pos, codeNodeType.create()); } // If dispatch is given and transaction is set, dispatch the transaction. diff --git a/src/document/blocks/block.ts b/src/document/blocks/block.ts index 66d70aa..302f9ef 100644 --- a/src/document/blocks/block.ts +++ b/src/document/blocks/block.ts @@ -2,14 +2,11 @@ import { Node as ProseNode } from "prosemirror-model"; // The different types of blocks that can be constructed. export enum BLOCK_NAME { - COQ = "coq", MATH_DISPLAY = "math_display", INPUT_AREA = "input_area", HINT = "hint", MARKDOWN = "markdown", - COQ_MARKDOWN = "coqdown", - COQ_CODE = "coq_code", - COQ_DOC = "coq_doc" + CODE = "code", } export interface BlockRange { @@ -20,10 +17,15 @@ export interface BlockRange { export interface Block { type: string; stringContent: string; + /** Range in the original document, including possible tags (like ) */ range: BlockRange; + /** Range in the original document, but only the content within possible tags */ + innerRange: BlockRange; + /** Blocks that are children of this block, only valid for InputArea and Hint Blocks. */ innerBlocks?: Block[]; + /** Convert this block to the corresponding ProseMirror node. */ toProseMirror(): ProseNode; debugPrint(level: number): void; } \ No newline at end of file diff --git a/src/document/blocks/blocktypes.ts b/src/document/blocks/blocktypes.ts index 0a13378..7f455ab 100644 --- a/src/document/blocks/blocktypes.ts +++ b/src/document/blocks/blocktypes.ts @@ -1,17 +1,32 @@ import { WaterproofSchema } from "../../schema"; import { BLOCK_NAME, Block, BlockRange } from "./block"; -// import { createCoqDocInnerBlocks, createCoqInnerBlocks, createInputAndHintInnerBlocks } from "./inner-blocks"; -import { coqCode, coqDoc, coqMarkdown, coqblock, hint, inputArea, markdown, mathDisplay } from "./schema"; +import { code, hint, inputArea, markdown, mathDisplay } from "./schema"; const indentation = (level: number): string => " ".repeat(level); const debugInfo = (block: Block): string => `{range=${block.range.from}-${block.range.to}}`; +/** + * InputAreaBlocks are the parts of the document that should be editable by students. + * Every input area has an accompanying status to indicate whether the input area is 'correct'. + */ export class InputAreaBlock implements Block { public type = BLOCK_NAME.INPUT_AREA; public innerBlocks: Block[]; - constructor( public stringContent: string, public range: BlockRange, innerBlockConstructor: (content: string) => Block[] ) { - this.innerBlocks = innerBlockConstructor(stringContent); + /** + * Construct a new InputAreaBlock. + * @param stringContent Content of the input area + * @param range The range (from position to to position in the original document) of the entire input area block, including the its tags. + * @param innerRange The range (from position to to position in the original document) of the inner content of the input area block, excluding its tags. + * @param childBlocks Either an array of child blocks of this input area block, or a function that constructs the child blocks given the inner range and content. + */ + constructor( public stringContent: string, public range: BlockRange, public innerRange: BlockRange, childBlocks: Block[] | ((innerContent: string, innerRange: BlockRange) => Block[])) { + if (typeof childBlocks === "function") { + this.innerBlocks = childBlocks(stringContent, innerRange); + } + else { + this.innerBlocks = childBlocks; + } }; toProseMirror() { @@ -27,13 +42,28 @@ export class InputAreaBlock implements Block { } } +/** + * HintBlocks are foldable blocks that can be used to hide parts of the document by default. + * Useful for giving hints to students or hiding import/configuration statements from the student. + */ export class HintBlock implements Block { public type = BLOCK_NAME.HINT; public innerBlocks: Block[]; - // Note: Hint blocks have a title attribute. - constructor( public stringContent: string, public title: string, public range: BlockRange, innerBlockConstructor: (content: string) => Block[] ) { - this.innerBlocks = innerBlockConstructor(stringContent); + /** + * Construct a new HintBlock. + * @param stringContent Content of the hint block + * @param title Title of the hint block (the part that is displayed in the document when folded) + * @param range The range (from position to to position in the original document) of the entire hint block, including its tags. + * @param innerRange The range (from position to to position in the original document) of the inner content of the hint block, excluding its tags. + * @param childBlocks Either an array of child blocks of this hint block, or a function that constructs the child blocks given the inner range and content. + */ + constructor( public stringContent: string, public title: string, public range: BlockRange, public innerRange: BlockRange, childBlocks: Block[] | ((innerContent: string, innerRange: BlockRange) => Block[])) { + if (typeof childBlocks === "function") { + this.innerBlocks = childBlocks(stringContent, innerRange); + } else { + this.innerBlocks = childBlocks; + } }; toProseMirror() { @@ -50,9 +80,12 @@ export class HintBlock implements Block { } } +/** + * MathDisplayBlocks display LaTeX in display mode (i.e., centered and on its own line). + */ export class MathDisplayBlock implements Block { public type = BLOCK_NAME.MATH_DISPLAY; - constructor( public stringContent: string, public range: BlockRange ) {}; + constructor( public stringContent: string, public range: BlockRange, public innerRange: BlockRange ) {}; toProseMirror() { return mathDisplay(this.stringContent); @@ -64,38 +97,14 @@ export class MathDisplayBlock implements Block { } } -export class CoqBlock implements Block { - public type = BLOCK_NAME.COQ; - public innerBlocks: Block[]; - - constructor( public stringContent: string, public prePreWhite: string, public prePostWhite: string, public postPreWhite: string, public postPostWhite : string, public range: BlockRange, innerBlockConstructor: (content: string) => Block[] ) { - this.innerBlocks = innerBlockConstructor(stringContent); - }; - - toProseMirror() { - const childNodes = this.innerBlocks.map(block => block.toProseMirror()); - return coqblock( - childNodes, - this.prePreWhite, - this.prePostWhite, - this.postPreWhite, - this.postPostWhite - ); - } - - // Debug print function. - debugPrint(level: number): void { - console.log(`${indentation(level)}CoqBlock {${debugInfo(this)}} [`); - this.innerBlocks.forEach(block => block.debugPrint(level + 1)); - console.log(`${indentation(level)}]`); - } -} - +/** + * MarkdownBlocks contain markdown content (including inline LaTeX inside single dollars `$`). + */ export class MarkdownBlock implements Block { public type = BLOCK_NAME.MARKDOWN; public isNewLineOnly = false; - constructor( public stringContent: string, public range: BlockRange ) { + constructor( public stringContent: string, public range: BlockRange, public innerRange: BlockRange ) { if (stringContent === "\n") this.isNewLineOnly = true; }; @@ -109,54 +118,20 @@ export class MarkdownBlock implements Block { } } -export class CoqDocBlock implements Block { - public type = BLOCK_NAME.COQ_DOC; - public innerBlocks: Block[]; - - constructor( public stringContent: string, public preWhite: string, public postWhite: string, public range: BlockRange, innerBlockConstructor: (content: string) => Block[] ) { - this.innerBlocks = innerBlockConstructor(stringContent); - }; - - toProseMirror() { - const childNodes = this.innerBlocks.map(block => block.toProseMirror()); - return coqDoc(childNodes, this.preWhite, this.postWhite); - } - - // Debug print function. - debugPrint(level: number = 0) { - console.log(`${indentation(level)}CoqDocBlock {${debugInfo(this)}} [`); - this.innerBlocks.forEach(block => block.debugPrint(level + 1)); - console.log(`${indentation(level)}]`); - } -} - -export class CoqMarkdownBlock implements Block { - public type = BLOCK_NAME.COQ_MARKDOWN; - - constructor( public stringContent: string, public range: BlockRange ) {}; - - toProseMirror() { - // We need to do some preprocessing on the string content, since coq markdown uses % for inline math. - return coqMarkdown(this.stringContent); - } - - // Debug print function. - debugPrint(level: number): void { - console.log(`${indentation(level)}CoqMarkdownBlock {${debugInfo(this)}}: {${this.stringContent.replaceAll("\n", "\\n")}}`); - } -} - -export class CoqCodeBlock implements Block { - public type = BLOCK_NAME.COQ_CODE; +/** + * CodeBlocks contain source code. + */ +export class CodeBlock implements Block { + public type = BLOCK_NAME.CODE; - constructor( public stringContent: string, public range: BlockRange ) {}; + constructor( public stringContent: string, public prePreWhite: string, public prePostWhite: string, public postPreWhite: string, public postPostWhite : string, public range: BlockRange, public innerRange: BlockRange) {} toProseMirror() { if (this.stringContent === "") { // If the string content is empty, we create an empty coqcode node. - return WaterproofSchema.nodes.coqcode.create(); + return WaterproofSchema.nodes.code.create(); } - return coqCode(this.stringContent); + return code(this.stringContent); } // Debug print function. diff --git a/src/document/blocks/index.ts b/src/document/blocks/index.ts index 8ae99a1..4f943d6 100644 --- a/src/document/blocks/index.ts +++ b/src/document/blocks/index.ts @@ -1,3 +1,3 @@ export { BlockRange, Block } from "./block"; -export { InputAreaBlock, HintBlock, CoqBlock, MathDisplayBlock, MarkdownBlock, CoqDocBlock, CoqCodeBlock, CoqMarkdownBlock } from "./blocktypes"; \ No newline at end of file +export { InputAreaBlock, HintBlock, CodeBlock, MathDisplayBlock, MarkdownBlock } from "./blocktypes"; \ No newline at end of file diff --git a/src/document/blocks/schema.ts b/src/document/blocks/schema.ts index 40e08e9..1e13b44 100644 --- a/src/document/blocks/schema.ts +++ b/src/document/blocks/schema.ts @@ -9,11 +9,6 @@ export const text = (content: string): ProseNode => { return WaterproofSchema.text(content); } -/** Construct coq markdown prosemirror node. */ -export const coqMarkdown = (content: string): ProseNode => { - return WaterproofSchema.nodes.coqdown.create({}, text(content)); -} - /** Construct math display prosemirror node. */ export const mathDisplay = (content: string): ProseNode => { return WaterproofSchema.nodes.math_display.create({}, text(content)); @@ -25,8 +20,8 @@ export const markdown = (content: string): ProseNode => { } /** Construct coqcode prosemirror node. */ -export const coqCode = (content: string): ProseNode => { - return WaterproofSchema.nodes.coqcode.create({}, text(content)); +export const code = (content: string): ProseNode => { + return WaterproofSchema.nodes.code.create({}, text(content)); } // ##### With inner blocks ##### @@ -41,16 +36,6 @@ export const hint = (title: string, childNodes: ProseNode[]): ProseNode => { return WaterproofSchema.nodes.hint.create({title}, childNodes); } -/** Construct coq prosemirror node. */ -export const coqblock = (childNodes: ProseNode[], prePreWhite: string, prePostWhite: string, postPreWhite: string, postPostWhite: string): ProseNode => { - return WaterproofSchema.nodes.coqblock.create({prePreWhite, prePostWhite, postPreWhite, postPostWhite}, childNodes); -} - -/** Construct coqdoc prosemirror node. */ -export const coqDoc = (childNodes: ProseNode[], preWhite: string, postWhite: string): ProseNode => { - return WaterproofSchema.nodes.coqdoc.create({preWhite, postWhite}, childNodes); -} - // ##### Root Node ##### export const root = (childNodes: ProseNode[]): ProseNode => { return WaterproofSchema.nodes.doc.create({}, childNodes); diff --git a/src/document/blocks/typeguards.ts b/src/document/blocks/typeguards.ts index 98f0b5b..8c1eda9 100644 --- a/src/document/blocks/typeguards.ts +++ b/src/document/blocks/typeguards.ts @@ -1,11 +1,8 @@ import { BLOCK_NAME, Block } from "./block"; -import { CoqBlock, CoqCodeBlock, CoqDocBlock, CoqMarkdownBlock, HintBlock, InputAreaBlock, MarkdownBlock, MathDisplayBlock } from "./blocktypes"; +import { CodeBlock, HintBlock, InputAreaBlock, MarkdownBlock, MathDisplayBlock } 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 isCoqBlock = (block: Block): block is CoqBlock => block.type === BLOCK_NAME.COQ; -export const isMarkdownBlock = (block: Block): block is MarkdownBlock => block.type === BLOCK_NAME.MARKDOWN; -export const isCoqMarkdownBlock = (block: Block): block is CoqMarkdownBlock => block.type === BLOCK_NAME.COQ_MARKDOWN; -export const isCoqDocBlock = (block: Block): block is CoqDocBlock => block.type === BLOCK_NAME.COQ_DOC; -export const isCoqCodeBlock = (block: Block): block is CoqCodeBlock => block.type === BLOCK_NAME.COQ_CODE; \ No newline at end of file +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; \ No newline at end of file diff --git a/src/document/utils.ts b/src/document/utils.ts index f084687..dad145d 100644 --- a/src/document/utils.ts +++ b/src/document/utils.ts @@ -3,14 +3,14 @@ import { Block, BlockRange } from "./blocks"; /** * Convert a list of blocks to a prosemirror compatible node list. * @param blocks Input array of blocks. - * @returns ProseMirror nodes. + * @returns ProseMirror nodes. */ export function blocksToProseMirrorNodes(blocks: Block[]) { return blocks.map((block) => block.toProseMirror()); } /** - * Helper function to sort block type objects. Will sort based on the range object of the block. + * Helper function to sort block type objects. Will sort based on the range object of the block. * Sorts in ascending (`range.from`) order. * @param blocks Blocks to sort. * @returns Sorted array of blocks. @@ -21,7 +21,7 @@ export function sortBlocks(blocks: Block[]) { /** * Map `f` over every consecutive pair from the `input` array. - * @param input Input array. + * @param input Input array. * @param f Function to map over the pairs. * @returns The result of mapping `f` over every consecutive pair. Will return an empty array if the input array has length < 2. */ @@ -29,20 +29,18 @@ export function iteratePairs(input: Array f(a, input[i + 1])); } - /** * Utility function to extract the ranges between blocks (ie. the ranges that are not covered by the blocks). * @param blocks The input array of block. * @param inputDocument The document the blocks are part of. * @returns The ranges between the blocks. */ -export function extractInterBlockRanges(blocks: Array, inputDocument: string): BlockRange[] { +export const extractInterBlockRanges = (blocks: Array, inputDocument: string, parentOffset: number = 0): {from: number, to: number}[] => { let ranges = iteratePairs(blocks, (blockA, blockB) => { return { from: blockA.range.to, to: blockB.range.from }; - }); - + }).filter(range => range.from < range.to); // Filter out empty ranges. // Add first range if it exists - if (blocks.length > 0 && blocks[0].range.from > 0) ranges = [{from: 0, to: blocks[0].range.from}, ...ranges]; + if (blocks.length > 0 && blocks[0].range.from > parentOffset) ranges = [{from: 0, to: blocks[0].range.from}, ...ranges]; // Add last range if it exists if (blocks.length > 0 && blocks[blocks.length - 1].range.to < inputDocument.length) ranges = [...ranges, {from: blocks[blocks.length - 1].range.to, to: inputDocument.length}]; @@ -52,34 +50,29 @@ export function extractInterBlockRanges(blocks: Array, inputDocument: str return ranges; } -/** - * Utility function to mask regions of a document covered by blocks. - * @param inputDocument The input document on which to apply the masking. - * @param blocks The blocks that will mask content from the input document. - * @param mask The mask to use (defaults to `" "`). - * @returns The document (`string`) with the ranges covered by the blocks in `blocks` masked using `mask`. - */ -export function maskInputAndHints(inputDocument: string, blocks: Block[], mask: string = " "): string { +export function maskInputAndHints(inputDocument: string, blocks: Block[]): string { let maskedString = inputDocument; for (const block of blocks) { - maskedString = maskedString.substring(0, block.range.from) + mask.repeat(block.range.to - block.range.from) + maskedString.substring(block.range.to); + maskedString = maskedString.substring(0, block.range.from) + " ".repeat(block.range.to - block.range.from) + maskedString.substring(block.range.to); } return maskedString; } /** * Create blocks based on ranges. - * + * * Extracts the text content of the ranges and creates blocks from them. */ export function extractBlocksUsingRanges( - inputDocument: string, - ranges: {from: number, to: number}[], - BlockConstructor: new (content: string, range: { from: number, to: number }) => BlockType ): BlockType[] + inputDocument: string, + ranges: {from: number, to: number}[], + BlockConstructor: new (content: string, range: { from: number, to: number }, innerRange: BlockRange) => BlockType ): BlockType[] { const blocks = ranges.map((range) => { const content = inputDocument.slice(range.from, range.to); - return new BlockConstructor(content, range); + // fixme + const innerRange = range; + return new BlockConstructor(content, range, innerRange); }).filter(block => { return block.range.from !== block.range.to; }); diff --git a/src/editor.ts b/src/editor.ts index 79e5d81..d35e906 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -139,12 +139,10 @@ export class WaterproofEditor { if (resultingDocument !== content) version = version + 1; } - const parsedContent = this._translator.toProsemirror(resultingDocument); - // this._contentElem.innerHTML = parsedContent; + const blocks = this._editorConfig.documentConstructor(resultingDocument); + const proseDoc = constructDocument(blocks); - const proseDoc = constructDocument(this._editorConfig.documentConstructor(resultingDocument)); - - this._mapping = new this._editorConfig.mapping(parsedContent, version); + this._mapping = new this._editorConfig.mapping(blocks, version); this.createProseMirrorEditor(proseDoc); /** Ask for line numbers */ @@ -261,7 +259,7 @@ export class WaterproofEditor { codePlugin(this._editorConfig.completions, this._editorConfig.symbols), progressBarPlugin, documentProgressDecoratorPlugin, - menuPlugin(WaterproofSchema, FileFormat.MarkdownV, this._userOS), + menuPlugin(FileFormat.MarkdownV, this._userOS), keymap({ "Mod-h": () => { this.executeCommand("Help."); @@ -269,12 +267,12 @@ export class WaterproofEditor { }, "Backspace": deleteSelection, "Delete": deleteSelection, - "Mod-m": cmdInsertMarkdown(WaterproofSchema, FileFormat.MarkdownV, InsertionPlace.Underneath), - "Mod-M": cmdInsertMarkdown(WaterproofSchema, FileFormat.MarkdownV, InsertionPlace.Above), - "Mod-q": cmdInsertCode(WaterproofSchema, FileFormat.MarkdownV, InsertionPlace.Underneath), - "Mod-Q": cmdInsertCode(WaterproofSchema, FileFormat.MarkdownV, InsertionPlace.Above), - "Mod-l": cmdInsertLatex(WaterproofSchema, FileFormat.MarkdownV, InsertionPlace.Underneath), - "Mod-L": cmdInsertLatex(WaterproofSchema, FileFormat.MarkdownV, InsertionPlace.Above), + "Mod-m": cmdInsertMarkdown(FileFormat.MarkdownV, InsertionPlace.Underneath), + "Mod-M": cmdInsertMarkdown(FileFormat.MarkdownV, InsertionPlace.Above), + "Mod-q": cmdInsertCode(FileFormat.MarkdownV, InsertionPlace.Underneath), + "Mod-Q": cmdInsertCode(FileFormat.MarkdownV, InsertionPlace.Above), + "Mod-l": cmdInsertLatex(FileFormat.MarkdownV, InsertionPlace.Underneath), + "Mod-L": cmdInsertLatex(FileFormat.MarkdownV, InsertionPlace.Above), // We bind Ctrl/Cmd+. to selecting the parent node of the currently selected node. "Mod-.": selectParentNode }) diff --git a/src/menubar/menubar.ts b/src/menubar/menubar.ts index 0b64a4a..e321b49 100644 --- a/src/menubar/menubar.ts +++ b/src/menubar/menubar.ts @@ -1,11 +1,11 @@ import { selectParentNode, wrapIn } from "prosemirror-commands"; -import { Schema } from "prosemirror-model"; import { Command, PluginView, Plugin, PluginKey } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; import { INPUT_AREA_PLUGIN_KEY } from "../inputArea"; import { cmdInsertCode, cmdInsertLatex, cmdInsertMarkdown, InsertionPlace, liftWrapper } from "../commands"; import { OS } from "../osType"; import { FileFormat } from "../api/FileFormat"; +import { WaterproofSchema } from "../schema"; /** MenuEntry type contains the DOM, whether to only show it in teacher mode and the command to execute on click */ type MenuEntry = { @@ -149,12 +149,11 @@ function teacherOnlyWrapper(cmd: Command): Command { /** * Creates the default menu bar. - * @param schema The schema in use for the editor. * @param outerView The outer (prosemirror)editor. * @param filef The file format of the current file. Some commands will behave differently in `.mv` vs `.v` context. * @returns A new `MenuView` filled with default menu items. */ -function createDefaultMenu(schema: Schema, outerView: EditorView, filef: FileFormat, os: OS): MenuView { +function createDefaultMenu(outerView: EditorView, filef: FileFormat, os: OS): MenuView { // Platform specific keybinding string: const cmdOrCtrl = os == OS.MacOS ? "Cmd" : "Ctrl"; @@ -165,19 +164,19 @@ function createDefaultMenu(schema: Schema, outerView: EditorView, filef: FileFor // Create the list of menu entries. const items: MenuEntry[] = [ // Insert Coq command - createMenuItem("Math↓", `Insert new verified math block underneath (${keyBinding("q")})`, cmdInsertCode(schema, filef, InsertionPlace.Underneath)), - createMenuItem("Math↑", `Insert new verified math block above (${keyBinding("Q")})`, cmdInsertCode(schema, filef, InsertionPlace.Above)), + createMenuItem("Math↓", `Insert new verified math block underneath (${keyBinding("q")})`, cmdInsertCode(filef, InsertionPlace.Underneath)), + createMenuItem("Math↑", `Insert new verified math block above (${keyBinding("Q")})`, cmdInsertCode(filef, InsertionPlace.Above)), // Insert Markdown - createMenuItem("Text↓", `Insert new text block underneath (${keyBinding("m")})`, cmdInsertMarkdown(schema, filef, InsertionPlace.Underneath)), - createMenuItem("Text↑", `Insert new text block above (${keyBinding("M")})`, cmdInsertMarkdown(schema, filef, InsertionPlace.Above)), + createMenuItem("Text↓", `Insert new text block underneath (${keyBinding("m")})`, cmdInsertMarkdown(filef, InsertionPlace.Underneath)), + createMenuItem("Text↑", `Insert new text block above (${keyBinding("M")})`, cmdInsertMarkdown(filef, InsertionPlace.Above)), // Insert LaTeX - createMenuItem(`${LaTeX_SVG}
`, `Insert new LaTeX block underneath (${keyBinding("l")})`, cmdInsertLatex(schema, filef, InsertionPlace.Underneath)), - createMenuItem(`${LaTeX_SVG}
`, `Insert new LaTeX block above (${keyBinding("L")})`, cmdInsertLatex(schema, filef, InsertionPlace.Above)), + createMenuItem(`${LaTeX_SVG}
`, `Insert new LaTeX block underneath (${keyBinding("l")})`, cmdInsertLatex(filef, InsertionPlace.Underneath)), + createMenuItem(`${LaTeX_SVG}
`, `Insert new LaTeX block above (${keyBinding("L")})`, cmdInsertLatex(filef, InsertionPlace.Above)), // 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(wrapIn(schema.nodes["input"])), teacherOnly), - createMenuItem("?", "Make selection a hint element", teacherOnlyWrapper(wrapIn(schema.nodes["hint"])), teacherOnly), + createMenuItem("ⵊ...", "Make selection an input area", teacherOnlyWrapper(wrapIn(WaterproofSchema.nodes.input)), teacherOnly), + createMenuItem("?", "Make selection a hint element", teacherOnlyWrapper(wrapIn(WaterproofSchema.nodes.hint)), teacherOnly), createMenuItem("↑", "Lift selected node (Reverts the effect of making a 'hint' or 'input area')", teacherOnlyWrapper(liftWrapper), teacherOnly) ] @@ -212,16 +211,15 @@ export const MENU_PLUGIN_KEY = new PluginKey("prosemirror-menu /** * Create a new menu plugin given the schema and file format. - * @param schema The schema in use for the editor. * @param filef The file format of the currently opened file. * @returns A prosemirror `Plugin` type containing the menubar. */ -export function menuPlugin(schema: Schema, filef: FileFormat, os: OS) { +export function menuPlugin(filef: FileFormat, os: OS) { return new Plugin({ // This plugin has an associated `view`. This allows it to add DOM elements. view(outerView: EditorView) { // Create the default menu. - const menuView = createDefaultMenu(schema, outerView, filef, os); + const menuView = createDefaultMenu(outerView, filef, os); // Get the parent node (the parent node of the outer prosemirror dom) const parentNode = outerView.dom.parentNode; if (parentNode == null) { diff --git a/src/schema/schema.ts b/src/schema/schema.ts index 9b1ad95..e9e0e5f 100644 --- a/src/schema/schema.ts +++ b/src/schema/schema.ts @@ -2,6 +2,7 @@ import { Node as PNode, Schema } from "prosemirror-model"; export const SchemaCell = { InputArea: "input", + Hint: "hint", Markdown: "markdown", MathDisplay: "math_display", Code: "code" @@ -10,8 +11,8 @@ export const SchemaCell = { export type SchemaKeys = keyof typeof SchemaCell; export type SchemaNames = typeof SchemaCell[SchemaKeys]; -const cell = `(markdown | hint | coqblock | input | math_display)`; -const containercontent = "(markdown | coqblock | math_display)"; +const cell = `(markdown | hint | code | input | math_display)`; +const containercontent = "(markdown | code | math_display)"; // const groupMarkdown = "markdowncontent"; /** @@ -26,7 +27,7 @@ const containercontent = "(markdown | coqblock | math_display)"; * * see [notes](./notes.md) */ -export const WaterproofSchema: Schema = new Schema({ +export const WaterproofSchema = new Schema({ nodes: { doc: { content: `${cell}*` @@ -76,47 +77,21 @@ export const WaterproofSchema: Schema = new Schema({ }, //#endregion - ////// Coqblock ////// - //#region Coq codeblock - "coqblock": { - content: `(coqdoc | coqcode)+`, + ////// Code ////// + //#region Code + code: { + content: "text*",// content is of type text attrs: { prePreWhite:{default:"newLine"}, prePostWhite:{default:"newLine"}, postPreWhite:{default:"newLine"}, postPostWhite:{default:"newLine"} }, - toDOM: () => { - return ["coqblock", 0]; - } - }, - - coqdoc: { - content: "(math_display | coqdown)*", - attrs: { - preWhite:{default:"newLine"}, - postWhite:{default:"newLine"} - }, - toDOM: () => { - return ["coqdoc", 0]; - } - }, - - coqdown: { - content: "text*", - block: true, - atom: true, - toDOM: () => { - return ["coqdown", 0]; - }, - }, - - coqcode: { - content: "text*",// content is of type text code: true, atom: true, // doesn't have directly editable content (content is edited through codemirror) - toDOM(node) { return ["WaterproofCode", node.attrs, 0] } // cells + toDOM(node) { return ["WaterproofCode", node.attrs, 0] } // cells }, + //#endregion /////// MATH DISPLAY ////// From 1bdc60586dda987a8dfecff505c4727828300407 Mon Sep 17 00:00:00 2001 From: DikieDick <26772815+DikieDick@users.noreply.github.com> Date: Sat, 13 Sep 2025 14:56:48 +0200 Subject: [PATCH 02/50] Fix codeblocks --- src/codeview/code-plugin.ts | 2 +- src/codeview/nodeview.ts | 21 +++++++-------- src/styles/coqdoc.css | 52 ------------------------------------- src/styles/index.ts | 3 --- 4 files changed, 10 insertions(+), 68 deletions(-) delete mode 100644 src/styles/coqdoc.css diff --git a/src/codeview/code-plugin.ts b/src/codeview/code-plugin.ts index f34972e..907767d 100644 --- a/src/codeview/code-plugin.ts +++ b/src/codeview/code-plugin.ts @@ -106,7 +106,7 @@ const CoqCodePluginSpec = (completions: Array, symbols: Array`s only ever appear one level beneath the root node. - // TODO: Hardcoded node names. - const name = outerView.state.doc.resolve(pos).node(1).type.name; - if (name !== "input") return; // This node is not part of an input area. + // We check whether the parent node is an input area. + const parentNodeType = outerView.state.doc.resolve(pos).parent.type; + if (parentNodeType !== WaterproofSchema.nodes.input) return; // This node is not part of an input area. } view.update([tr]); @@ -206,11 +205,9 @@ export class CodeBlockView extends EmbeddedCodeMirrorEditor { private partOfInputArea(): boolean { const pos = this._getPos(); if (pos === undefined) return false; - // Resolve the position in the prosemirror document and get the node one level underneath the root. - // TODO: Assumption that ``s only ever appear one level beneath the root node. - // TODO: Hardcoded node names. - const name = this._outerView.state.doc.resolve(pos).node(1).type.name; - if (name !== "input") return false; + // We check whether the parent node is an input area. + const parentNodeType = this._outerView.state.doc.resolve(pos).parent.type; + if (parentNodeType !== WaterproofSchema.nodes.input) return false; return true; } diff --git a/src/styles/coqdoc.css b/src/styles/coqdoc.css deleted file mode 100644 index a8047bc..0000000 --- a/src/styles/coqdoc.css +++ /dev/null @@ -1,52 +0,0 @@ -/* .coqdoc-view-editor { - -} */ - -/* .coqdoc-view-rendered { - -} */ - -/* .coqdoc-view { - -} */ - -.mv coqblock:has(coqdoc)::before { - display: block; - color: var(--vscode-input-foreground); - background-color: var(--vscode-input-background); - font-family: var(--vscode-font-family); - font-size: var(--vscode-font-size); - font-weight: var(--vscode-font-weight); - content: "Coq with coqdoc"; - padding: 0.2em; -} - -coqblock { - display: block; - background: var(--vscode-listFilterWidget-background); -} - -.coqdoc-view-empty:before { - display: inline; - color: var(--vscode-errorForeground); - content: "..."; -} - -.coqdoc-view-empty > div { - display: none; -} - - -.coqdoc-view-editor::before { - --border-color: var(--vscode-input-border); - border-right: 1px solid var(--border-color); - border-bottom: 1px solid var(--border-color); - color: var(--vscode-input-foreground); - background-color: var(--vscode-input-background); - font-family: var(--vscode-font-family); - font-size: var(--vscode-font-size); - font-weight: var(--vscode-font-weight); - border-radius: 0px 0px 5px 0px; - content: "Coqdoc"; - padding: 0.2em; -} \ No newline at end of file diff --git a/src/styles/index.ts b/src/styles/index.ts index e762f3d..8cd4748 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -21,9 +21,6 @@ import "./markdown.css"; // for popover notifications import "./notifications.css"; -// for the coqdoc outline -import "./coqdoc.css"; - // for the progressBar import "./progressBar.css"; From d201ef75d53bb0fba5a858b26da31965d370f994 Mon Sep 17 00:00:00 2001 From: DikieDick <26772815+DikieDick@users.noreply.github.com> Date: Sun, 14 Sep 2025 16:22:23 +0200 Subject: [PATCH 03/50] Add types for markdown-it --- package-lock.json | 9 +++++---- package.json | 1 + 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index d16bd01..5015500 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "waterproof-editor", - "version": "1.0.0", + "name": "@impermeable/waterproof-editor", + "version": "0.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "waterproof-editor", - "version": "1.0.0", + "name": "@impermeable/waterproof-editor", + "version": "0.9.0", "license": "MIT", "dependencies": { "@benrbray/prosemirror-math": "1.0.0", @@ -17,6 +17,7 @@ "@codemirror/view": "6.27.0", "@lezer/highlight": "1.2.1", "@lezer/lr": "1.4.2", + "@types/markdown-it": "^14.1.2", "jquery": "3.7.1", "katex": "^0.16.22", "markdown-it": "13.0.2", diff --git a/package.json b/package.json index f2fb3ab..dc27cc0 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "@codemirror/view": "6.27.0", "@lezer/highlight": "1.2.1", "@lezer/lr": "1.4.2", + "@types/markdown-it": "^14.1.2", "jquery": "3.7.1", "katex": "^0.16.22", "markdown-it": "13.0.2", From 6f73f72c5896f0e733afd2a5628db051c4acff2b Mon Sep 17 00:00:00 2001 From: DikieDick <26772815+DikieDick@users.noreply.github.com> Date: Sun, 14 Sep 2025 18:00:47 +0200 Subject: [PATCH 04/50] Update extract using ranges to include offset from parent --- src/document/utils.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/document/utils.ts b/src/document/utils.ts index dad145d..bd3cb19 100644 --- a/src/document/utils.ts +++ b/src/document/utils.ts @@ -66,13 +66,15 @@ export function maskInputAndHints(inputDocument: string, blocks: Block[]): strin export function extractBlocksUsingRanges( inputDocument: string, ranges: {from: number, to: number}[], - BlockConstructor: new (content: string, range: { from: number, to: number }, innerRange: BlockRange) => BlockType ): BlockType[] + BlockConstructor: new (content: string, range: { from: number, to: number }, innerRange: BlockRange) => BlockType, + parentOffset: number = 0): BlockType[] { const blocks = ranges.map((range) => { const content = inputDocument.slice(range.from, range.to); - // fixme - const innerRange = range; - return new BlockConstructor(content, range, innerRange); + const eRange = { from: range.from + parentOffset, to: range.to + parentOffset }; + // Fixme: inner range is currently just the same as the outer range (fine for markdown) + const iRange = eRange; + return new BlockConstructor(content, eRange, iRange); }).filter(block => { return block.range.from !== block.range.to; }); From dc6a30fa60a4e1c999dfbd59ae7cefc2c636c298 Mon Sep 17 00:00:00 2001 From: DikieDick <26772815+DikieDick@users.noreply.github.com> Date: Mon, 15 Sep 2025 17:58:30 +0200 Subject: [PATCH 05/50] Remove FileFormat --- src/api/FileFormat.ts | 9 --- src/api/index.ts | 2 - src/commands/commands.ts | 16 ++--- src/commands/insert-command.ts | 122 ++++++++++++--------------------- src/editor.ts | 15 ++-- src/menubar/menubar.ts | 19 +++-- 6 files changed, 66 insertions(+), 117 deletions(-) delete mode 100644 src/api/FileFormat.ts diff --git a/src/api/FileFormat.ts b/src/api/FileFormat.ts deleted file mode 100644 index 5cc1e1a..0000000 --- a/src/api/FileFormat.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * The different supported input/output file types - */ -export enum FileFormat { - /** Markdown enabled coq file (extension: `.mv`) */ - MarkdownV = "MarkdownV", - /** Regular coq file, with the possibility for coqdoc comments (extension: `.v`) */ - RegularV = "RegularV" -} diff --git a/src/api/index.ts b/src/api/index.ts index 6777df9..7e192bc 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -6,8 +6,6 @@ export { InputAreaStatus } from "./InputAreaStatus"; export { LineNumber} from "./LineNumber"; export { Severity, SeverityLabel, SeverityLabelMap } from "./Severity"; -export { FileFormat } from "./FileFormat"; - export * from "./types"; export { WaterproofCompletion, WaterproofSymbol } from "./Completions"; diff --git a/src/commands/commands.ts b/src/commands/commands.ts index 0929097..e46246b 100644 --- a/src/commands/commands.ts +++ b/src/commands/commands.ts @@ -5,7 +5,6 @@ import { InsertionPlace } from "./types"; import { getCodeInsertCommand, getLatexInsertCommand, getMdInsertCommand } from "./insert-command"; import { EditorView } from "prosemirror-view"; import { liftTarget } from "prosemirror-transform"; -import { FileFormat } from "../api"; import { WaterproofSchema } from "../schema"; /** @@ -28,13 +27,12 @@ function getInsertionFunction(place: InsertionPlace) { /** * Creates a command that creates a new code cell above/underneath the currently selected node. - * @param filef The format of the currently opened file. * @param insertionPlace The place to insert the new node into: Underneath or Above the current node. * @returns The `Command`. */ -export function cmdInsertCode(filef: FileFormat, insertionPlace: InsertionPlace): Command { +export function cmdInsertCode(insertionPlace: InsertionPlace): Command { // Return a command with the correct insertion place and function. - return getCodeInsertCommand(filef, getInsertionFunction(insertionPlace), insertionPlace, WaterproofSchema.nodes.code); + return getCodeInsertCommand(getInsertionFunction(insertionPlace), insertionPlace, WaterproofSchema.nodes.code); } //// MARKDOWN //// @@ -53,15 +51,14 @@ export function cmdInsertCode(filef: FileFormat, insertionPlace: InsertionPlace) /** * Creates a command that creates a new markdown cell underneath/above the currently selected node. - * @param filef The fileformat of the file currently opened. * @param insertionPlace The place to insert at: Above or Underneath current node. * @returns The `Command`. */ -export function cmdInsertMarkdown(filef: FileFormat, insertionPlace: InsertionPlace): Command { +export function cmdInsertMarkdown(insertionPlace: InsertionPlace): Command { // Retrieve the node types for both markdown and coqdoc markdown (coqdown) from the schema. const mdNodeType = WaterproofSchema.nodes.markdown; // Return a command with the correct insertion command and place. - return getMdInsertCommand(filef, getInsertionFunction(insertionPlace), + return getMdInsertCommand(getInsertionFunction(insertionPlace), insertionPlace, mdNodeType); } @@ -74,15 +71,14 @@ export function cmdInsertMarkdown(filef: FileFormat, insertionPlace: InsertionPl /** * Returns a command that inserts a new Display Math cell above/underneath the currently selected cell. - * @param filef The file format of the current file. * @param insertionPlace The place to insert the node at Above or Underneath the current node. * @returns The `Command` */ -export function cmdInsertLatex(filef: FileFormat, insertionPlace: InsertionPlace): Command { +export function cmdInsertLatex(insertionPlace: InsertionPlace): Command { // Get latex node type from the schema. const latexNodeType = WaterproofSchema.nodes.math_display; // Return the command with correct insertion place. - return getLatexInsertCommand(filef, getInsertionFunction(insertionPlace), insertionPlace, latexNodeType); + return getLatexInsertCommand(getInsertionFunction(insertionPlace), insertionPlace, latexNodeType); } export const liftWrapper: Command = (state: EditorState, dispatch?: ((tr: Transaction) => void), _view?: EditorView): boolean => { diff --git a/src/commands/insert-command.ts b/src/commands/insert-command.ts index fea3efe..d5a7313 100644 --- a/src/commands/insert-command.ts +++ b/src/commands/insert-command.ts @@ -1,5 +1,4 @@ import { Command, EditorState, Transaction } from "prosemirror-state"; -import { FileFormat } from "../api/FileFormat"; import { InsertionFunction, InsertionPlace } from "./types"; import { NodeType } from "prosemirror-model"; import { EditorView } from "prosemirror-view"; @@ -7,15 +6,12 @@ import { allowedToInsert, getContainingNode, getNearestPosOutsideCoqblock } from /** * Return a Markdown insertion command. - * @param filef The file format of the file in use. * @param insertionFunction The function used to insert the node into the editor. * @param place Where to insert the node into the editor. Either Above or Underneath the currently selected node. - * @param mvNodeType The node to use in the case of a `.mv` file. - * @param vNodeType The node to use in the case of a `.v` file. + * @param nodeType The node type of the markdown node. * @returns The insertion command. */ export function getMdInsertCommand( - filef: FileFormat, insertionFunction: InsertionFunction, place: InsertionPlace, nodeType: NodeType @@ -33,44 +29,33 @@ export function getMdInsertCommand( // Retrieve the name of the containing node. const { name } = container.type; - if (filef === FileFormat.MarkdownV) { - if (name === "input" || name === "hint" || name === "doc") { - // In the case of having `input`, `hint` or `doc` as parent node, we can insert directly - // above or below the selected node. - trans = insertionFunction(state, state.tr, nodeType); - } else if (name === "coqblock" || name === "coqdoc") { - // In the case that the user has a selection within a coqblock or coqdoc cell we need to do more work and - // figure out where this block `starts` and `ends`. - const { start, end } = getNearestPosOutsideCoqblock(state.selection, state); - trans = state.tr.insert(place == InsertionPlace.Above ? start : end, nodeType.create()); - } - - // If the dispatch is given and transaction is not undefined dispatch it. - if (dispatch && trans) dispatch(trans); - - // successful command. - return true; - } else if (filef === FileFormat.RegularV) { - // This command behaves differently in the case of a .v file. - // But since .v files are not working atm, this case is defaulted to false. - return false; - } else { - // This command has no expected outcome when the Fileformat is unknown. - return false; + if (name === "input" || name === "hint" || name === "doc") { + // In the case of having `input`, `hint` or `doc` as parent node, we can insert directly + // above or below the selected node. + trans = insertionFunction(state, state.tr, nodeType); + } else if (name === "coqblock" || name === "coqdoc") { + // In the case that the user has a selection within a coqblock or coqdoc cell we need to do more work and + // figure out where this block `starts` and `ends`. + const { start, end } = getNearestPosOutsideCoqblock(state.selection, state); + trans = state.tr.insert(place == InsertionPlace.Above ? start : end, nodeType.create()); } + + // If the dispatch is given and transaction is not undefined dispatch it. + if (dispatch && trans) dispatch(trans); + + // successful command. + return true; } } /** * Returns an insertion command for insertion display latex into the editor. - * @param filef The file format of the file currently being edited. * @param insertionFunction The insertion function to use. * @param place The place to insert into, either Above or Underneath the currently selected node. * @param latexNodeType The node type for a 'display latex' node. * @returns The insertion command. */ export function getLatexInsertCommand( - filef: FileFormat, insertionFunction: InsertionFunction, place: InsertionPlace, latexNodeType: NodeType, @@ -86,42 +71,31 @@ export function getLatexInsertCommand( const { name } = container.type - if (filef === FileFormat.MarkdownV) { - if (name === "input" || name === "hint" || name === "doc") { - // `Easy` insertion since we can just insert directly above or below the selection. - trans = insertionFunction(state, state.tr, latexNodeType); - } else if (name === "coqblock" || name === "coqdoc") { - // More difficult insertion since we have to `escape` the current coqblock. - const { start, end } = getNearestPosOutsideCoqblock(state.selection, state); - trans = state.tr.insert(place == InsertionPlace.Above ? start : end, latexNodeType.create()); - } - - // Dispatch the transaction when dispatch is given and transaction is not undefined. - if (dispatch && trans) dispatch(trans); - - // Indicate successful command. - return true; - } else if (filef === FileFormat.RegularV) { - // FIXME: Implement .v file case. - return false; - } else { - // This command has no expected outcome when the Fileformat is unknown. - return false; + if (name === "input" || name === "hint" || name === "doc") { + // `Easy` insertion since we can just insert directly above or below the selection. + trans = insertionFunction(state, state.tr, latexNodeType); + } else if (name === "coqblock" || name === "coqdoc") { + // More difficult insertion since we have to `escape` the current coqblock. + const { start, end } = getNearestPosOutsideCoqblock(state.selection, state); + trans = state.tr.insert(place == InsertionPlace.Above ? start : end, latexNodeType.create()); } + + // Dispatch the transaction when dispatch is given and transaction is not undefined. + if (dispatch && trans) dispatch(trans); + + // Indicate successful command. + return true; } } /** * Returns an insertion command for inserting a new coq code cell. Will create a new coqblock if necessary. - * @param filef The file format of the file that is being edited. * @param insertionFunction The insertion function to use. * @param place The place of insertion, either Above or Underneath the currently selected node. - * @param coqblockNodeType The node type of a coqblock node (contains coqdoc and coqcode). - * @param coqcodeNodeType The node type of a coqcode node. + * @param codeNodeType The node type for a code cell. * @returns The insertion command. */ export function getCodeInsertCommand( - filef: FileFormat, insertionFunction: InsertionFunction, place: InsertionPlace, codeNodeType: NodeType @@ -134,28 +108,20 @@ export function getCodeInsertCommand( if (name === undefined) return false; let trans: Transaction | undefined; - if (filef === FileFormat.MarkdownV) { - if (name === "input" || name === "hint" || name === "doc") { - // Create a new coqblock *and* coqcode cell and insert Above or Underneath the current selection. - trans = insertionFunction(state, state.tr, codeNodeType); - } else if (name === "coqblock" || name === "coqdoc") { - // Find the position outside of the coqblock and insert a new coqblock and coqcode cell above or underneath. - const {start, end} = getNearestPosOutsideCoqblock(state.selection, state); - const pos = place == InsertionPlace.Above ? start : end; - trans = state.tr.insert(pos, codeNodeType.create()); - } - - // If dispatch is given and transaction is set, dispatch the transaction. - if (dispatch && trans) dispatch(trans); - - // Indicate that this command was successful. - return true; - } else if (filef === FileFormat.RegularV) { - // FIXME: Implement .v file case. - return false; - } else { - // This command has no expected outcome when the Fileformat is unknown. - return false; + if (name === "input" || name === "hint" || name === "doc") { + // Create a new coqblock *and* coqcode cell and insert Above or Underneath the current selection. + trans = insertionFunction(state, state.tr, codeNodeType); + } else if (name === "coqblock" || name === "coqdoc") { + // Find the position outside of the coqblock and insert a new coqblock and coqcode cell above or underneath. + const {start, end} = getNearestPosOutsideCoqblock(state.selection, state); + const pos = place == InsertionPlace.Above ? start : end; + trans = state.tr.insert(pos, codeNodeType.create()); } + + // If dispatch is given and transaction is set, dispatch the transaction. + if (dispatch && trans) dispatch(trans); + + // Indicate that this command was successful. + return true; } } \ No newline at end of file diff --git a/src/editor.ts b/src/editor.ts index d35e906..c27b912 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -31,7 +31,6 @@ import { InsertionPlace, cmdInsertCode, cmdInsertLatex, cmdInsertMarkdown } from import { OS } from "./osType"; import { Positioned, WaterproofMapping, WaterproofEditorConfig, DiagnosticMessage, ThemeStyle } from "./api"; import { Completion } from "@codemirror/autocomplete"; -import { FileFormat } from "./api/FileFormat"; import { setCurrentTheme } from "./themeStore"; import { ServerStatus } from "./api"; @@ -259,7 +258,7 @@ export class WaterproofEditor { codePlugin(this._editorConfig.completions, this._editorConfig.symbols), progressBarPlugin, documentProgressDecoratorPlugin, - menuPlugin(FileFormat.MarkdownV, this._userOS), + menuPlugin(this._userOS), keymap({ "Mod-h": () => { this.executeCommand("Help."); @@ -267,12 +266,12 @@ export class WaterproofEditor { }, "Backspace": deleteSelection, "Delete": deleteSelection, - "Mod-m": cmdInsertMarkdown(FileFormat.MarkdownV, InsertionPlace.Underneath), - "Mod-M": cmdInsertMarkdown(FileFormat.MarkdownV, InsertionPlace.Above), - "Mod-q": cmdInsertCode(FileFormat.MarkdownV, InsertionPlace.Underneath), - "Mod-Q": cmdInsertCode(FileFormat.MarkdownV, InsertionPlace.Above), - "Mod-l": cmdInsertLatex(FileFormat.MarkdownV, InsertionPlace.Underneath), - "Mod-L": cmdInsertLatex(FileFormat.MarkdownV, InsertionPlace.Above), + "Mod-m": cmdInsertMarkdown(InsertionPlace.Underneath), + "Mod-M": cmdInsertMarkdown(InsertionPlace.Above), + "Mod-q": cmdInsertCode(InsertionPlace.Underneath), + "Mod-Q": cmdInsertCode(InsertionPlace.Above), + "Mod-l": cmdInsertLatex(InsertionPlace.Underneath), + "Mod-L": cmdInsertLatex(InsertionPlace.Above), // We bind Ctrl/Cmd+. to selecting the parent node of the currently selected node. "Mod-.": selectParentNode }) diff --git a/src/menubar/menubar.ts b/src/menubar/menubar.ts index e321b49..5f6fe90 100644 --- a/src/menubar/menubar.ts +++ b/src/menubar/menubar.ts @@ -4,7 +4,6 @@ import { EditorView } from "prosemirror-view"; import { INPUT_AREA_PLUGIN_KEY } from "../inputArea"; import { cmdInsertCode, cmdInsertLatex, cmdInsertMarkdown, InsertionPlace, liftWrapper } from "../commands"; import { OS } from "../osType"; -import { FileFormat } from "../api/FileFormat"; import { WaterproofSchema } from "../schema"; /** MenuEntry type contains the DOM, whether to only show it in teacher mode and the command to execute on click */ @@ -153,7 +152,7 @@ function teacherOnlyWrapper(cmd: Command): Command { * @param filef The file format of the current file. Some commands will behave differently in `.mv` vs `.v` context. * @returns A new `MenuView` filled with default menu items. */ -function createDefaultMenu(outerView: EditorView, filef: FileFormat, os: OS): MenuView { +function createDefaultMenu(outerView: EditorView, os: OS): MenuView { // Platform specific keybinding string: const cmdOrCtrl = os == OS.MacOS ? "Cmd" : "Ctrl"; @@ -164,14 +163,14 @@ function createDefaultMenu(outerView: EditorView, filef: FileFormat, os: OS): Me // Create the list of menu entries. const items: MenuEntry[] = [ // Insert Coq command - createMenuItem("Math↓", `Insert new verified math block underneath (${keyBinding("q")})`, cmdInsertCode(filef, InsertionPlace.Underneath)), - createMenuItem("Math↑", `Insert new verified math block above (${keyBinding("Q")})`, cmdInsertCode(filef, InsertionPlace.Above)), + createMenuItem("Math↓", `Insert new verified math block underneath (${keyBinding("q")})`, cmdInsertCode(InsertionPlace.Underneath)), + createMenuItem("Math↑", `Insert new verified math block above (${keyBinding("Q")})`, cmdInsertCode(InsertionPlace.Above)), // Insert Markdown - createMenuItem("Text↓", `Insert new text block underneath (${keyBinding("m")})`, cmdInsertMarkdown(filef, InsertionPlace.Underneath)), - createMenuItem("Text↑", `Insert new text block above (${keyBinding("M")})`, cmdInsertMarkdown(filef, InsertionPlace.Above)), + createMenuItem("Text↓", `Insert new text block underneath (${keyBinding("m")})`, cmdInsertMarkdown(InsertionPlace.Underneath)), + createMenuItem("Text↑", `Insert new text block above (${keyBinding("M")})`, cmdInsertMarkdown(InsertionPlace.Above)), // Insert LaTeX - createMenuItem(`${LaTeX_SVG}
`, `Insert new LaTeX block underneath (${keyBinding("l")})`, cmdInsertLatex(filef, InsertionPlace.Underneath)), - createMenuItem(`${LaTeX_SVG}
`, `Insert new LaTeX block above (${keyBinding("L")})`, cmdInsertLatex(filef, InsertionPlace.Above)), + createMenuItem(`${LaTeX_SVG}
`, `Insert new LaTeX block underneath (${keyBinding("l")})`, cmdInsertLatex(InsertionPlace.Underneath)), + createMenuItem(`${LaTeX_SVG}
`, `Insert new LaTeX block above (${keyBinding("L")})`, cmdInsertLatex(InsertionPlace.Above)), // Select the parent node. createMenuItem("Parent", `Select the parent node (${keyBinding(".")})`, selectParentNode), // in teacher mode, display input area, hint and lift buttons. @@ -214,12 +213,12 @@ export const MENU_PLUGIN_KEY = new PluginKey("prosemirror-menu * @param filef The file format of the currently opened file. * @returns A prosemirror `Plugin` type containing the menubar. */ -export function menuPlugin(filef: FileFormat, os: OS) { +export function menuPlugin(os: OS) { return new Plugin({ // This plugin has an associated `view`. This allows it to add DOM elements. view(outerView: EditorView) { // Create the default menu. - const menuView = createDefaultMenu(outerView, filef, os); + const menuView = createDefaultMenu(outerView, os); // Get the parent node (the parent node of the outer prosemirror dom) const parentNode = outerView.dom.parentNode; if (parentNode == null) { From 17b552523c6a25b1722ae87a421b03fc03ea5c26 Mon Sep 17 00:00:00 2001 From: DikieDick <26772815+DikieDick@users.noreply.github.com> Date: Mon, 15 Sep 2025 19:44:05 +0200 Subject: [PATCH 06/50] Refactor SwitchableView, Remove Completions, Remove Translation --- src/api/types.ts | 5 + src/autocomplete/coqTerms.json | 91 - src/autocomplete/coqTerms.ts | 22 - src/autocomplete/emojis.json | 11222 ---------------- src/autocomplete/emojis.ts | 23 - src/autocomplete/symbols.json | 656 - src/autocomplete/symbols.ts | 24 - src/editor.ts | 17 +- src/index.ts | 2 +- src/markup-views/CoqdocPlugin.ts | 69 - src/markup-views/CoqdocView.ts | 32 - src/markup-views/MarkdownView.ts | 33 - ...kdownPlugin.ts => SwitchableViewPlugin.ts} | 30 +- src/markup-views/index.ts | 9 +- .../switchable-view/RenderedView.ts | 8 +- .../switchable-view/SwitchableView.ts | 36 +- src/styles/markdown.css | 2 +- src/translation/Translator.ts | 17 - src/translation/index.ts | 2 +- src/translation/toMarkdownTranslation.ts | 5 + src/translation/toProsemirror/index.ts | 2 - .../toProsemirror/mvFileToProsemirror.ts | 300 - src/translation/toProsemirror/parseAsMv.ts | 50 - src/translation/toProsemirror/parser.ts | 88 - src/translation/types.ts | 35 - 25 files changed, 50 insertions(+), 12730 deletions(-) delete mode 100644 src/autocomplete/coqTerms.json delete mode 100644 src/autocomplete/coqTerms.ts delete mode 100755 src/autocomplete/emojis.json delete mode 100644 src/autocomplete/emojis.ts delete mode 100644 src/autocomplete/symbols.json delete mode 100644 src/autocomplete/symbols.ts delete mode 100644 src/markup-views/CoqdocPlugin.ts delete mode 100644 src/markup-views/CoqdocView.ts delete mode 100644 src/markup-views/MarkdownView.ts rename src/markup-views/{MarkdownPlugin.ts => SwitchableViewPlugin.ts} (61%) delete mode 100644 src/translation/Translator.ts create mode 100644 src/translation/toMarkdownTranslation.ts delete mode 100644 src/translation/toProsemirror/index.ts delete mode 100644 src/translation/toProsemirror/mvFileToProsemirror.ts delete mode 100644 src/translation/toProsemirror/parseAsMv.ts delete mode 100644 src/translation/toProsemirror/parser.ts delete mode 100644 src/translation/types.ts diff --git a/src/api/types.ts b/src/api/types.ts index 296475b..f1d0edc 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -63,6 +63,11 @@ export type WaterproofEditorConfig = { documentConstructor: (document: string) => WaterproofDocument, /** How to construct a mapping for this editor. The mapping is responsible for mapping changes from the underlying ProseMirror instance into changes that can be applied to the underlying document. */ mapping: new (inputDocument: WaterproofDocument, versionNum: number) => WaterproofMapping, + /** The name of the markdown node view, defaults to "markdown" */ + markdownName?: string, + + toMarkdown?: (inputString: string) => string, + /** THIS IS A TEMPORARY FEATURE THAT WILL GET REMOVED */ documentPreprocessor?: (inputString: string) => {resultingDocument: string, documentChange: DocChange | WrappingDocChange | undefined}, } diff --git a/src/autocomplete/coqTerms.json b/src/autocomplete/coqTerms.json deleted file mode 100644 index df32a9f..0000000 --- a/src/autocomplete/coqTerms.json +++ /dev/null @@ -1,91 +0,0 @@ -[ - { "label": "Abort", "type": "text", "detail": "vernacular" }, - { "label": "About", "type": "text", "detail": "vernacular" }, - { "label": "Add", "type": "text", "detail": "vernacular" }, - { "label": "All", "type": "text", "detail": "vernacular" }, - { "label": "Arguments", "type": "text", "detail": "vernacular" }, - { "label": "Asymmetric", "type": "text", "detail": "vernacular" }, - { "label": "Axiom", "type": "text", "detail": "vernacular" }, - { "label": "Bind", "type": "text", "detail": "vernacular" }, - { "label": "Canonical", "type": "text", "detail": "vernacular" }, - { "label": "Check", "type": "text", "detail": "vernacular" }, - { "label": "Class", "type": "text", "detail": "vernacular" }, - { "label": "Close", "type": "text", "detail": "vernacular" }, - { "label": "Coercion", "type": "text", "detail": "vernacular" }, - { "label": "CoFixpoint", "type": "text", "detail": "vernacular" }, - { "label": "Comments", "type": "text", "detail": "vernacular" }, - { "label": "CoInductive", "type": "text", "detail": "vernacular" }, - { "label": "Compute", "type": "text", "detail": "vernacular" }, - { "label": "Context", "type": "text", "detail": "vernacular" }, - { "label": "Constructors", "type": "text", "detail": "vernacular" }, - { "label": "Contextual", "type": "text", "detail": "vernacular" }, - { "label": "Corollary", "type": "text", "detail": "vernacular" }, - { "label": "Defined", "type": "text", "detail": "vernacular" }, - { "label": "Definition", "type": "text", "detail": "vernacular" }, - { "label": "Delimit", "type": "text", "detail": "vernacular" }, - { "label": "Fail", "type": "text", "detail": "vernacular" }, - { "label": "Eval", "type": "text", "detail": "vernacular" }, - { "label": "End", "type": "text", "detail": "vernacular" }, - { "label": "Example", "type": "text", "detail": "vernacular" }, - { "label": "Export", "type": "text", "detail": "vernacular" }, - { "label": "Fact", "type": "text", "detail": "vernacular" }, - { "label": "Fixpoint", "type": "text", "detail": "vernacular" }, - { "label": "From", "type": "text", "detail": "vernacular" }, - { "label": "Global", "type": "text", "detail": "vernacular" }, - { "label": "Goal", "type": "text", "detail": "vernacular" }, - { "label": "Graph", "type": "text", "detail": "vernacular" }, - { "label": "Hint", "type": "text", "detail": "vernacular" }, - { "label": "Hypotheses", "type": "text", "detail": "vernacular" }, - { "label": "Hypothesis", "type": "text", "detail": "vernacular" }, - { "label": "Implicit", "type": "text", "detail": "vernacular" }, - { "label": "Implicits", "type": "text", "detail": "vernacular" }, - { "label": "Import", "type": "text", "detail": "vernacular" }, - { "label": "Inductive", "type": "text", "detail": "vernacular" }, - { "label": "Infix", "type": "text", "detail": "vernacular" }, - { "label": "Instance", "type": "text", "detail": "vernacular" }, - { "label": "Lemma", "type": "text", "detail": "vernacular" }, - { "label": "Let", "type": "text", "detail": "vernacular" }, - { "label": "Local", "type": "text", "detail": "vernacular" }, - { "label": "Ltac", "type": "text", "detail": "vernacular" }, - { "label": "Module", "type": "text", "detail": "vernacular" }, - { "label": "Morphism", "type": "text", "detail": "vernacular" }, - { "label": "Next", "type": "text", "detail": "vernacular" }, - { "label": "Notation", "type": "text", "detail": "vernacular" }, - { "label": "Obligation", "type": "text", "detail": "vernacular" }, - { "label": "Open", "type": "text", "detail": "vernacular" }, - { "label": "Parameter", "type": "text", "detail": "vernacular" }, - { "label": "Parameters", "type": "text", "detail": "vernacular" }, - { "label": "Prenex", "type": "text", "detail": "vernacular" }, - { "label": "Print", "type": "text", "detail": "vernacular" }, - { "label": "Printing", "type": "text", "detail": "vernacular" }, - { "label": "Program", "type": "text", "detail": "vernacular" }, - { "label": "Patterns", "type": "text", "detail": "vernacular" }, - { "label": "Projections", "type": "text", "detail": "vernacular" }, - { "label": "Proof", "type": "text", "detail": "vernacular" }, - { "label": "Proposition", "type": "text", "detail": "vernacular" }, - { "label": "Qed", "type": "text", "detail": "vernacular" }, - { "label": "Record", "type": "text", "detail": "vernacular" }, - { "label": "Relation", "type": "text", "detail": "vernacular" }, - { "label": "Remark", "type": "text", "detail": "vernacular" }, - { "label": "Require", "type": "text", "detail": "vernacular" }, - { "label": "Reserved", "type": "text", "detail": "vernacular" }, - { "label": "Resolve", "type": "text", "detail": "vernacular" }, - { "label": "Rewrite", "type": "text", "detail": "vernacular" }, - { "label": "Save", "type": "text", "detail": "vernacular" }, - { "label": "Scope", "type": "text", "detail": "vernacular" }, - { "label": "Search", "type": "text", "detail": "vernacular" }, - { "label": "SearchAbout", "type": "text", "detail": "vernacular" }, - { "label": "Section", "type": "text", "detail": "vernacular" }, - { "label": "Set", "type": "text", "detail": "vernacular" }, - { "label": "Show", "type": "text", "detail": "vernacular" }, - { "label": "Strict", "type": "text", "detail": "vernacular" }, - { "label": "Structure", "type": "text", "detail": "vernacular" }, - { "label": "Tactic", "type": "text", "detail": "vernacular" }, - { "label": "Time", "type": "text", "detail": "vernacular" }, - { "label": "Theorem", "type": "text", "detail": "vernacular" }, - { "label": "Types", "type": "text", "detail": "vernacular" }, - { "label": "Unset", "type": "text", "detail": "vernacular" }, - { "label": "Variable", "type": "text", "detail": "vernacular" }, - { "label": "Variables", "type": "text", "detail": "vernacular" }, - { "label": "View", "type": "text", "detail": "vernacular" } -] \ No newline at end of file diff --git a/src/autocomplete/coqTerms.ts b/src/autocomplete/coqTerms.ts deleted file mode 100644 index e6cbdc7..0000000 --- a/src/autocomplete/coqTerms.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Completion, CompletionContext, CompletionResult, CompletionSource } from "@codemirror/autocomplete"; -import coqWords from "./coqTerms.json"; - -// Our list of completions (can be static, since the editor -/// will do filtering based on context). -const coqCompletions: Completion[] = coqWords; - -export const coqCompletionSource: CompletionSource = function(context: CompletionContext): Promise { - return new Promise((resolve, _reject) => { - const before = context.matchBefore(/\w/); - const period = /\./gm //Regex expression to search entire line for period - const contextline = context.state.doc.lineAt(context.pos).text // line at the completetion context - // If completion wasn't explicitly started and there - // is no word before the cursor, don't open completions. - if ((!context.explicit && !before) || period.test(contextline)) resolve(null); - resolve({ - from: before ? before.from : context.pos, - options: coqCompletions, - validFor: /[^ ]*/ - }); - }); -} \ No newline at end of file diff --git a/src/autocomplete/emojis.json b/src/autocomplete/emojis.json deleted file mode 100755 index e1d218e..0000000 --- a/src/autocomplete/emojis.json +++ /dev/null @@ -1,11222 +0,0 @@ -[ - { - "label": ":grinning-face:", - "detail": "emoji", - "info": "😀", - "apply": "😀" - }, - { - "label": ":grinning-face-with-big-eyes:", - "detail": "emoji", - "info": "😃", - "apply": "😃" - }, - { - "label": ":grinning-face-with-smiling-eyes:", - "detail": "emoji", - "info": "😄", - "apply": "😄" - }, - { - "label": ":beaming-face-with-smiling-eyes:", - "detail": "emoji", - "info": "😁", - "apply": "😁" - }, - { - "label": ":grinning-squinting-face:", - "detail": "emoji", - "info": "😆", - "apply": "😆" - }, - { - "label": ":grinning-face-with-sweat:", - "detail": "emoji", - "info": "😅", - "apply": "😅" - }, - { - "label": ":rolling-on-the-floor-laughing:", - "detail": "emoji", - "info": "🤣", - "apply": "🤣" - }, - { - "label": ":face-with-tears-of-joy:", - "detail": "emoji", - "info": "😂", - "apply": "😂" - }, - { - "label": ":slightly-smiling-face:", - "detail": "emoji", - "info": "🙂", - "apply": "🙂" - }, - { - "label": ":upside-down-face:", - "detail": "emoji", - "info": "🙃", - "apply": "🙃" - }, - { - "label": ":melting-face:", - "detail": "emoji", - "info": "🫠", - "apply": "🫠" - }, - { - "label": ":winking-face:", - "detail": "emoji", - "info": "😉", - "apply": "😉" - }, - { - "label": ":smiling-face-with-smiling-eyes:", - "detail": "emoji", - "info": "😊", - "apply": "😊" - }, - { - "label": ":smiling-face-with-halo:", - "detail": "emoji", - "info": "😇", - "apply": "😇" - }, - { - "label": ":smiling-face-with-hearts:", - "detail": "emoji", - "info": "🥰", - "apply": "🥰" - }, - { - "label": ":smiling-face-with-heart-eyes:", - "detail": "emoji", - "info": "😍", - "apply": "😍" - }, - { - "label": ":star-struck:", - "detail": "emoji", - "info": "🤩", - "apply": "🤩" - }, - { - "label": ":face-blowing-a-kiss:", - "detail": "emoji", - "info": "😘", - "apply": "😘" - }, - { - "label": ":kissing-face:", - "detail": "emoji", - "info": "😗", - "apply": "😗" - }, - { - "label": ":smiling-face:", - "detail": "emoji", - "info": "☺️", - "apply": "☺️" - }, - { - "label": ":kissing-face-with-closed-eyes:", - "detail": "emoji", - "info": "😚", - "apply": "😚" - }, - { - "label": ":kissing-face-with-smiling-eyes:", - "detail": "emoji", - "info": "😙", - "apply": "😙" - }, - { - "label": ":smiling-face-with-tear:", - "detail": "emoji", - "info": "🥲", - "apply": "🥲" - }, - { - "label": ":face-savoring-food:", - "detail": "emoji", - "info": "😋", - "apply": "😋" - }, - { - "label": ":face-with-tongue:", - "detail": "emoji", - "info": "😛", - "apply": "😛" - }, - { - "label": ":winking-face-with-tongue:", - "detail": "emoji", - "info": "😜", - "apply": "😜" - }, - { - "label": ":zany-face:", - "detail": "emoji", - "info": "🤪", - "apply": "🤪" - }, - { - "label": ":squinting-face-with-tongue:", - "detail": "emoji", - "info": "😝", - "apply": "😝" - }, - { - "label": ":money-mouth-face:", - "detail": "emoji", - "info": "🤑", - "apply": "🤑" - }, - { - "label": ":smiling-face-with-open-hands:", - "detail": "emoji", - "info": "🤗", - "apply": "🤗" - }, - { - "label": ":face-with-hand-over-mouth:", - "detail": "emoji", - "info": "🤭", - "apply": "🤭" - }, - { - "label": ":face-with-open-eyes-and-hand-over-mouth:", - "detail": "emoji", - "info": "🫢", - "apply": "🫢" - }, - { - "label": ":face-with-peeking-eye:", - "detail": "emoji", - "info": "🫣", - "apply": "🫣" - }, - { - "label": ":shushing-face:", - "detail": "emoji", - "info": "🤫", - "apply": "🤫" - }, - { - "label": ":thinking-face:", - "detail": "emoji", - "info": "🤔", - "apply": "🤔" - }, - { - "label": ":saluting-face:", - "detail": "emoji", - "info": "🫡", - "apply": "🫡" - }, - { - "label": ":zipper-mouth-face:", - "detail": "emoji", - "info": "🤐", - "apply": "🤐" - }, - { - "label": ":face-with-raised-eyebrow:", - "detail": "emoji", - "info": "🤨", - "apply": "🤨" - }, - { - "label": ":neutral-face:", - "detail": "emoji", - "info": "😐", - "apply": "😐" - }, - { - "label": ":expressionless-face:", - "detail": "emoji", - "info": "😑", - "apply": "😑" - }, - { - "label": ":face-without-mouth:", - "detail": "emoji", - "info": "😶", - "apply": "😶" - }, - { - "label": ":dotted-line-face:", - "detail": "emoji", - "info": "🫥", - "apply": "🫥" - }, - { - "label": ":face-in-clouds:", - "detail": "emoji", - "info": "😶‍🌫️", - "apply": "😶‍🌫️" - }, - { - "label": ":smirking-face:", - "detail": "emoji", - "info": "😏", - "apply": "😏" - }, - { - "label": ":unamused-face:", - "detail": "emoji", - "info": "😒", - "apply": "😒" - }, - { - "label": ":face-with-rolling-eyes:", - "detail": "emoji", - "info": "🙄", - "apply": "🙄" - }, - { - "label": ":grimacing-face:", - "detail": "emoji", - "info": "😬", - "apply": "😬" - }, - { - "label": ":face-exhaling:", - "detail": "emoji", - "info": "😮‍💨", - "apply": "😮‍💨" - }, - { - "label": ":lying-face:", - "detail": "emoji", - "info": "🤥", - "apply": "🤥" - }, - { - "label": ":shaking-face:", - "detail": "emoji", - "info": "🫨", - "apply": "🫨" - }, - { - "label": ":relieved-face:", - "detail": "emoji", - "info": "😌", - "apply": "😌" - }, - { - "label": ":pensive-face:", - "detail": "emoji", - "info": "😔", - "apply": "😔" - }, - { - "label": ":sleepy-face:", - "detail": "emoji", - "info": "😪", - "apply": "😪" - }, - { - "label": ":drooling-face:", - "detail": "emoji", - "info": "🤤", - "apply": "🤤" - }, - { - "label": ":sleeping-face:", - "detail": "emoji", - "info": "😴", - "apply": "😴" - }, - { - "label": ":face-with-medical-mask:", - "detail": "emoji", - "info": "😷", - "apply": "😷" - }, - { - "label": ":face-with-thermometer:", - "detail": "emoji", - "info": "🤒", - "apply": "🤒" - }, - { - "label": ":face-with-head-bandage:", - "detail": "emoji", - "info": "🤕", - "apply": "🤕" - }, - { - "label": ":nauseated-face:", - "detail": "emoji", - "info": "🤢", - "apply": "🤢" - }, - { - "label": ":face-vomiting:", - "detail": "emoji", - "info": "🤮", - "apply": "🤮" - }, - { - "label": ":sneezing-face:", - "detail": "emoji", - "info": "🤧", - "apply": "🤧" - }, - { - "label": ":hot-face:", - "detail": "emoji", - "info": "🥵", - "apply": "🥵" - }, - { - "label": ":cold-face:", - "detail": "emoji", - "info": "🥶", - "apply": "🥶" - }, - { - "label": ":woozy-face:", - "detail": "emoji", - "info": "🥴", - "apply": "🥴" - }, - { - "label": ":face-with-crossed-out-eyes:", - "detail": "emoji", - "info": "😵", - "apply": "😵" - }, - { - "label": ":face-with-spiral-eyes:", - "detail": "emoji", - "info": "😵‍💫", - "apply": "😵‍💫" - }, - { - "label": ":exploding-head:", - "detail": "emoji", - "info": "🤯", - "apply": "🤯" - }, - { - "label": ":cowboy-hat-face:", - "detail": "emoji", - "info": "🤠", - "apply": "🤠" - }, - { - "label": ":partying-face:", - "detail": "emoji", - "info": "🥳", - "apply": "🥳" - }, - { - "label": ":disguised-face:", - "detail": "emoji", - "info": "🥸", - "apply": "🥸" - }, - { - "label": ":smiling-face-with-sunglasses:", - "detail": "emoji", - "info": "😎", - "apply": "😎" - }, - { - "label": ":nerd-face:", - "detail": "emoji", - "info": "🤓", - "apply": "🤓" - }, - { - "label": ":face-with-monocle:", - "detail": "emoji", - "info": "🧐", - "apply": "🧐" - }, - { - "label": ":confused-face:", - "detail": "emoji", - "info": "😕", - "apply": "😕" - }, - { - "label": ":face-with-diagonal-mouth:", - "detail": "emoji", - "info": "🫤", - "apply": "🫤" - }, - { - "label": ":worried-face:", - "detail": "emoji", - "info": "😟", - "apply": "😟" - }, - { - "label": ":slightly-frowning-face:", - "detail": "emoji", - "info": "🙁", - "apply": "🙁" - }, - { - "label": ":frowning-face:", - "detail": "emoji", - "info": "☹️", - "apply": "☹️" - }, - { - "label": ":face-with-open-mouth:", - "detail": "emoji", - "info": "😮", - "apply": "😮" - }, - { - "label": ":hushed-face:", - "detail": "emoji", - "info": "😯", - "apply": "😯" - }, - { - "label": ":astonished-face:", - "detail": "emoji", - "info": "😲", - "apply": "😲" - }, - { - "label": ":flushed-face:", - "detail": "emoji", - "info": "😳", - "apply": "😳" - }, - { - "label": ":pleading-face:", - "detail": "emoji", - "info": "🥺", - "apply": "🥺" - }, - { - "label": ":face-holding-back-tears:", - "detail": "emoji", - "info": "🥹", - "apply": "🥹" - }, - { - "label": ":frowning-face-with-open-mouth:", - "detail": "emoji", - "info": "😦", - "apply": "😦" - }, - { - "label": ":anguished-face:", - "detail": "emoji", - "info": "😧", - "apply": "😧" - }, - { - "label": ":fearful-face:", - "detail": "emoji", - "info": "😨", - "apply": "😨" - }, - { - "label": ":anxious-face-with-sweat:", - "detail": "emoji", - "info": "😰", - "apply": "😰" - }, - { - "label": ":sad-but-relieved-face:", - "detail": "emoji", - "info": "😥", - "apply": "😥" - }, - { - "label": ":crying-face:", - "detail": "emoji", - "info": "😢", - "apply": "😢" - }, - { - "label": ":loudly-crying-face:", - "detail": "emoji", - "info": "😭", - "apply": "😭" - }, - { - "label": ":face-screaming-in-fear:", - "detail": "emoji", - "info": "😱", - "apply": "😱" - }, - { - "label": ":confounded-face:", - "detail": "emoji", - "info": "😖", - "apply": "😖" - }, - { - "label": ":persevering-face:", - "detail": "emoji", - "info": "😣", - "apply": "😣" - }, - { - "label": ":disappointed-face:", - "detail": "emoji", - "info": "😞", - "apply": "😞" - }, - { - "label": ":downcast-face-with-sweat:", - "detail": "emoji", - "info": "😓", - "apply": "😓" - }, - { - "label": ":weary-face:", - "detail": "emoji", - "info": "😩", - "apply": "😩" - }, - { - "label": ":tired-face:", - "detail": "emoji", - "info": "😫", - "apply": "😫" - }, - { - "label": ":yawning-face:", - "detail": "emoji", - "info": "🥱", - "apply": "🥱" - }, - { - "label": ":face-with-steam-from-nose:", - "detail": "emoji", - "info": "😤", - "apply": "😤" - }, - { - "label": ":enraged-face:", - "detail": "emoji", - "info": "😡", - "apply": "😡" - }, - { - "label": ":angry-face:", - "detail": "emoji", - "info": "😠", - "apply": "😠" - }, - { - "label": ":face-with-symbols-on-mouth:", - "detail": "emoji", - "info": "🤬", - "apply": "🤬" - }, - { - "label": ":smiling-face-with-horns:", - "detail": "emoji", - "info": "😈", - "apply": "😈" - }, - { - "label": ":angry-face-with-horns:", - "detail": "emoji", - "info": "👿", - "apply": "👿" - }, - { - "label": ":skull:", - "detail": "emoji", - "info": "💀", - "apply": "💀" - }, - { - "label": ":skull-and-crossbones:", - "detail": "emoji", - "info": "☠️", - "apply": "☠️" - }, - { - "label": ":pile-of-poo:", - "detail": "emoji", - "info": "💩", - "apply": "💩" - }, - { - "label": ":clown-face:", - "detail": "emoji", - "info": "🤡", - "apply": "🤡" - }, - { - "label": ":ogre:", - "detail": "emoji", - "info": "👹", - "apply": "👹" - }, - { - "label": ":goblin:", - "detail": "emoji", - "info": "👺", - "apply": "👺" - }, - { - "label": ":ghost:", - "detail": "emoji", - "info": "👻", - "apply": "👻" - }, - { - "label": ":alien:", - "detail": "emoji", - "info": "👽", - "apply": "👽" - }, - { - "label": ":alien-monster:", - "detail": "emoji", - "info": "👾", - "apply": "👾" - }, - { - "label": ":robot:", - "detail": "emoji", - "info": "🤖", - "apply": "🤖" - }, - { - "label": ":grinning-cat:", - "detail": "emoji", - "info": "😺", - "apply": "😺" - }, - { - "label": ":grinning-cat-with-smiling-eyes:", - "detail": "emoji", - "info": "😸", - "apply": "😸" - }, - { - "label": ":cat-with-tears-of-joy:", - "detail": "emoji", - "info": "😹", - "apply": "😹" - }, - { - "label": ":smiling-cat-with-heart-eyes:", - "detail": "emoji", - "info": "😻", - "apply": "😻" - }, - { - "label": ":cat-with-wry-smile:", - "detail": "emoji", - "info": "😼", - "apply": "😼" - }, - { - "label": ":kissing-cat:", - "detail": "emoji", - "info": "😽", - "apply": "😽" - }, - { - "label": ":weary-cat:", - "detail": "emoji", - "info": "🙀", - "apply": "🙀" - }, - { - "label": ":crying-cat:", - "detail": "emoji", - "info": "😿", - "apply": "😿" - }, - { - "label": ":pouting-cat:", - "detail": "emoji", - "info": "😾", - "apply": "😾" - }, - { - "label": ":see-no-evil-monkey:", - "detail": "emoji", - "info": "🙈", - "apply": "🙈" - }, - { - "label": ":hear-no-evil-monkey:", - "detail": "emoji", - "info": "🙉", - "apply": "🙉" - }, - { - "label": ":speak-no-evil-monkey:", - "detail": "emoji", - "info": "🙊", - "apply": "🙊" - }, - { - "label": ":love-letter:", - "detail": "emoji", - "info": "💌", - "apply": "💌" - }, - { - "label": ":heart-with-arrow:", - "detail": "emoji", - "info": "💘", - "apply": "💘" - }, - { - "label": ":heart-with-ribbon:", - "detail": "emoji", - "info": "💝", - "apply": "💝" - }, - { - "label": ":sparkling-heart:", - "detail": "emoji", - "info": "💖", - "apply": "💖" - }, - { - "label": ":growing-heart:", - "detail": "emoji", - "info": "💗", - "apply": "💗" - }, - { - "label": ":beating-heart:", - "detail": "emoji", - "info": "💓", - "apply": "💓" - }, - { - "label": ":revolving-hearts:", - "detail": "emoji", - "info": "💞", - "apply": "💞" - }, - { - "label": ":two-hearts:", - "detail": "emoji", - "info": "💕", - "apply": "💕" - }, - { - "label": ":heart-decoration:", - "detail": "emoji", - "info": "💟", - "apply": "💟" - }, - { - "label": ":heart-exclamation:", - "detail": "emoji", - "info": "❣️", - "apply": "❣️" - }, - { - "label": ":broken-heart:", - "detail": "emoji", - "info": "💔", - "apply": "💔" - }, - { - "label": ":heart-on-fire:", - "detail": "emoji", - "info": "❤️‍🔥", - "apply": "❤️‍🔥" - }, - { - "label": ":mending-heart:", - "detail": "emoji", - "info": "❤️‍🩹", - "apply": "❤️‍🩹" - }, - { - "label": ":red-heart:", - "detail": "emoji", - "info": "❤️", - "apply": "❤️" - }, - { - "label": ":pink-heart:", - "detail": "emoji", - "info": "🩷", - "apply": "🩷" - }, - { - "label": ":orange-heart:", - "detail": "emoji", - "info": "🧡", - "apply": "🧡" - }, - { - "label": ":yellow-heart:", - "detail": "emoji", - "info": "💛", - "apply": "💛" - }, - { - "label": ":green-heart:", - "detail": "emoji", - "info": "💚", - "apply": "💚" - }, - { - "label": ":blue-heart:", - "detail": "emoji", - "info": "💙", - "apply": "💙" - }, - { - "label": ":light-blue-heart:", - "detail": "emoji", - "info": "🩵", - "apply": "🩵" - }, - { - "label": ":purple-heart:", - "detail": "emoji", - "info": "💜", - "apply": "💜" - }, - { - "label": ":brown-heart:", - "detail": "emoji", - "info": "🤎", - "apply": "🤎" - }, - { - "label": ":black-heart:", - "detail": "emoji", - "info": "🖤", - "apply": "🖤" - }, - { - "label": ":grey-heart:", - "detail": "emoji", - "info": "🩶", - "apply": "🩶" - }, - { - "label": ":white-heart:", - "detail": "emoji", - "info": "🤍", - "apply": "🤍" - }, - { - "label": ":kiss-mark:", - "detail": "emoji", - "info": "💋", - "apply": "💋" - }, - { - "label": ":hundred-points:", - "detail": "emoji", - "info": "💯", - "apply": "💯" - }, - { - "label": ":anger-symbol:", - "detail": "emoji", - "info": "💢", - "apply": "💢" - }, - { - "label": ":collision:", - "detail": "emoji", - "info": "💥", - "apply": "💥" - }, - { - "label": ":dizzy:", - "detail": "emoji", - "info": "💫", - "apply": "💫" - }, - { - "label": ":sweat-droplets:", - "detail": "emoji", - "info": "💦", - "apply": "💦" - }, - { - "label": ":dashing-away:", - "detail": "emoji", - "info": "💨", - "apply": "💨" - }, - { - "label": ":hole:", - "detail": "emoji", - "info": "🕳️", - "apply": "🕳️" - }, - { - "label": ":speech-balloon:", - "detail": "emoji", - "info": "💬", - "apply": "💬" - }, - { - "label": ":eye-in-speech-bubble:", - "detail": "emoji", - "info": "👁️‍🗨️", - "apply": "👁️‍🗨️" - }, - { - "label": ":left-speech-bubble:", - "detail": "emoji", - "info": "🗨️", - "apply": "🗨️" - }, - { - "label": ":right-anger-bubble:", - "detail": "emoji", - "info": "🗯️", - "apply": "🗯️" - }, - { - "label": ":thought-balloon:", - "detail": "emoji", - "info": "💭", - "apply": "💭" - }, - { - "label": ":zzz:", - "detail": "emoji", - "info": "💤", - "apply": "💤" - }, - { - "label": ":waving-hand:", - "detail": "emoji", - "info": "👋", - "apply": "👋" - }, - { - "label": ":raised-back-of-hand:", - "detail": "emoji", - "info": "🤚", - "apply": "🤚" - }, - { - "label": ":hand-with-fingers-splayed:", - "detail": "emoji", - "info": "🖐️", - "apply": "🖐️" - }, - { - "label": ":raised-hand:", - "detail": "emoji", - "info": "✋", - "apply": "✋" - }, - { - "label": ":vulcan-salute:", - "detail": "emoji", - "info": "🖖", - "apply": "🖖" - }, - { - "label": ":rightwards-hand:", - "detail": "emoji", - "info": "🫱", - "apply": "🫱" - }, - { - "label": ":leftwards-hand:", - "detail": "emoji", - "info": "🫲", - "apply": "🫲" - }, - { - "label": ":palm-down-hand:", - "detail": "emoji", - "info": "🫳", - "apply": "🫳" - }, - { - "label": ":palm-up-hand:", - "detail": "emoji", - "info": "🫴", - "apply": "🫴" - }, - { - "label": ":leftwards-pushing-hand:", - "detail": "emoji", - "info": "🫷", - "apply": "🫷" - }, - { - "label": ":rightwards-pushing-hand:", - "detail": "emoji", - "info": "🫸", - "apply": "🫸" - }, - { - "label": ":ok-hand:", - "detail": "emoji", - "info": "👌", - "apply": "👌" - }, - { - "label": ":pinched-fingers:", - "detail": "emoji", - "info": "🤌", - "apply": "🤌" - }, - { - "label": ":pinching-hand:", - "detail": "emoji", - "info": "🤏", - "apply": "🤏" - }, - { - "label": ":victory-hand:", - "detail": "emoji", - "info": "✌️", - "apply": "✌️" - }, - { - "label": ":crossed-fingers:", - "detail": "emoji", - "info": "🤞", - "apply": "🤞" - }, - { - "label": ":hand-with-index-finger-and-thumb-crossed:", - "detail": "emoji", - "info": "🫰", - "apply": "🫰" - }, - { - "label": ":love-you-gesture:", - "detail": "emoji", - "info": "🤟", - "apply": "🤟" - }, - { - "label": ":sign-of-the-horns:", - "detail": "emoji", - "info": "🤘", - "apply": "🤘" - }, - { - "label": ":call-me-hand:", - "detail": "emoji", - "info": "🤙", - "apply": "🤙" - }, - { - "label": ":backhand-index-pointing-left:", - "detail": "emoji", - "info": "👈", - "apply": "👈" - }, - { - "label": ":backhand-index-pointing-right:", - "detail": "emoji", - "info": "👉", - "apply": "👉" - }, - { - "label": ":backhand-index-pointing-up:", - "detail": "emoji", - "info": "👆", - "apply": "👆" - }, - { - "label": ":middle-finger:", - "detail": "emoji", - "info": "🖕", - "apply": "🖕" - }, - { - "label": ":backhand-index-pointing-down:", - "detail": "emoji", - "info": "👇", - "apply": "👇" - }, - { - "label": ":index-pointing-up:", - "detail": "emoji", - "info": "☝️", - "apply": "☝️" - }, - { - "label": ":index-pointing-at-the-viewer:", - "detail": "emoji", - "info": "🫵", - "apply": "🫵" - }, - { - "label": ":thumbs-up:", - "detail": "emoji", - "info": "👍", - "apply": "👍" - }, - { - "label": ":thumbs-down:", - "detail": "emoji", - "info": "👎", - "apply": "👎" - }, - { - "label": ":raised-fist:", - "detail": "emoji", - "info": "✊", - "apply": "✊" - }, - { - "label": ":oncoming-fist:", - "detail": "emoji", - "info": "👊", - "apply": "👊" - }, - { - "label": ":left-facing-fist:", - "detail": "emoji", - "info": "🤛", - "apply": "🤛" - }, - { - "label": ":right-facing-fist:", - "detail": "emoji", - "info": "🤜", - "apply": "🤜" - }, - { - "label": ":clapping-hands:", - "detail": "emoji", - "info": "👏", - "apply": "👏" - }, - { - "label": ":raising-hands:", - "detail": "emoji", - "info": "🙌", - "apply": "🙌" - }, - { - "label": ":heart-hands:", - "detail": "emoji", - "info": "🫶", - "apply": "🫶" - }, - { - "label": ":open-hands:", - "detail": "emoji", - "info": "👐", - "apply": "👐" - }, - { - "label": ":palms-up-together:", - "detail": "emoji", - "info": "🤲", - "apply": "🤲" - }, - { - "label": ":handshake:", - "detail": "emoji", - "info": "🤝", - "apply": "🤝" - }, - { - "label": ":folded-hands:", - "detail": "emoji", - "info": "🙏", - "apply": "🙏" - }, - { - "label": ":writing-hand:", - "detail": "emoji", - "info": "✍️", - "apply": "✍️" - }, - { - "label": ":nail-polish:", - "detail": "emoji", - "info": "💅", - "apply": "💅" - }, - { - "label": ":selfie:", - "detail": "emoji", - "info": "🤳", - "apply": "🤳" - }, - { - "label": ":flexed-biceps:", - "detail": "emoji", - "info": "💪", - "apply": "💪" - }, - { - "label": ":mechanical-arm:", - "detail": "emoji", - "info": "🦾", - "apply": "🦾" - }, - { - "label": ":mechanical-leg:", - "detail": "emoji", - "info": "🦿", - "apply": "🦿" - }, - { - "label": ":leg:", - "detail": "emoji", - "info": "🦵", - "apply": "🦵" - }, - { - "label": ":foot:", - "detail": "emoji", - "info": "🦶", - "apply": "🦶" - }, - { - "label": ":ear:", - "detail": "emoji", - "info": "👂", - "apply": "👂" - }, - { - "label": ":ear-with-hearing-aid:", - "detail": "emoji", - "info": "🦻", - "apply": "🦻" - }, - { - "label": ":nose:", - "detail": "emoji", - "info": "👃", - "apply": "👃" - }, - { - "label": ":brain:", - "detail": "emoji", - "info": "🧠", - "apply": "🧠" - }, - { - "label": ":anatomical-heart:", - "detail": "emoji", - "info": "🫀", - "apply": "🫀" - }, - { - "label": ":lungs:", - "detail": "emoji", - "info": "🫁", - "apply": "🫁" - }, - { - "label": ":tooth:", - "detail": "emoji", - "info": "🦷", - "apply": "🦷" - }, - { - "label": ":bone:", - "detail": "emoji", - "info": "🦴", - "apply": "🦴" - }, - { - "label": ":eyes:", - "detail": "emoji", - "info": "👀", - "apply": "👀" - }, - { - "label": ":eye:", - "detail": "emoji", - "info": "👁️", - "apply": "👁️" - }, - { - "label": ":tongue:", - "detail": "emoji", - "info": "👅", - "apply": "👅" - }, - { - "label": ":mouth:", - "detail": "emoji", - "info": "👄", - "apply": "👄" - }, - { - "label": ":biting-lip:", - "detail": "emoji", - "info": "🫦", - "apply": "🫦" - }, - { - "label": ":baby:", - "detail": "emoji", - "info": "👶", - "apply": "👶" - }, - { - "label": ":child:", - "detail": "emoji", - "info": "🧒", - "apply": "🧒" - }, - { - "label": ":boy:", - "detail": "emoji", - "info": "👦", - "apply": "👦" - }, - { - "label": ":girl:", - "detail": "emoji", - "info": "👧", - "apply": "👧" - }, - { - "label": ":person:", - "detail": "emoji", - "info": "🧑", - "apply": "🧑" - }, - { - "label": ":person-blond-hair:", - "detail": "emoji", - "info": "👱", - "apply": "👱" - }, - { - "label": ":man:", - "detail": "emoji", - "info": "👨", - "apply": "👨" - }, - { - "label": ":person-beard:", - "detail": "emoji", - "info": "🧔", - "apply": "🧔" - }, - { - "label": ":man-beard:", - "detail": "emoji", - "info": "🧔‍♂️", - "apply": "🧔‍♂️" - }, - { - "label": ":woman-beard:", - "detail": "emoji", - "info": "🧔‍♀️", - "apply": "🧔‍♀️" - }, - { - "label": ":man-red-hair:", - "detail": "emoji", - "info": "👨‍🦰", - "apply": "👨‍🦰" - }, - { - "label": ":man-curly-hair:", - "detail": "emoji", - "info": "👨‍🦱", - "apply": "👨‍🦱" - }, - { - "label": ":man-white-hair:", - "detail": "emoji", - "info": "👨‍🦳", - "apply": "👨‍🦳" - }, - { - "label": ":man-bald:", - "detail": "emoji", - "info": "👨‍🦲", - "apply": "👨‍🦲" - }, - { - "label": ":woman:", - "detail": "emoji", - "info": "👩", - "apply": "👩" - }, - { - "label": ":woman-red-hair:", - "detail": "emoji", - "info": "👩‍🦰", - "apply": "👩‍🦰" - }, - { - "label": ":person-red-hair:", - "detail": "emoji", - "info": "🧑‍🦰", - "apply": "🧑‍🦰" - }, - { - "label": ":woman-curly-hair:", - "detail": "emoji", - "info": "👩‍🦱", - "apply": "👩‍🦱" - }, - { - "label": ":person-curly-hair:", - "detail": "emoji", - "info": "🧑‍🦱", - "apply": "🧑‍🦱" - }, - { - "label": ":woman-white-hair:", - "detail": "emoji", - "info": "👩‍🦳", - "apply": "👩‍🦳" - }, - { - "label": ":person-white-hair:", - "detail": "emoji", - "info": "🧑‍🦳", - "apply": "🧑‍🦳" - }, - { - "label": ":woman-bald:", - "detail": "emoji", - "info": "👩‍🦲", - "apply": "👩‍🦲" - }, - { - "label": ":person-bald:", - "detail": "emoji", - "info": "🧑‍🦲", - "apply": "🧑‍🦲" - }, - { - "label": ":woman-blond-hair:", - "detail": "emoji", - "info": "👱‍♀️", - "apply": "👱‍♀️" - }, - { - "label": ":man-blond-hair:", - "detail": "emoji", - "info": "👱‍♂️", - "apply": "👱‍♂️" - }, - { - "label": ":older-person:", - "detail": "emoji", - "info": "🧓", - "apply": "🧓" - }, - { - "label": ":old-man:", - "detail": "emoji", - "info": "👴", - "apply": "👴" - }, - { - "label": ":old-woman:", - "detail": "emoji", - "info": "👵", - "apply": "👵" - }, - { - "label": ":person-frowning:", - "detail": "emoji", - "info": "🙍", - "apply": "🙍" - }, - { - "label": ":man-frowning:", - "detail": "emoji", - "info": "🙍‍♂️", - "apply": "🙍‍♂️" - }, - { - "label": ":woman-frowning:", - "detail": "emoji", - "info": "🙍‍♀️", - "apply": "🙍‍♀️" - }, - { - "label": ":person-pouting:", - "detail": "emoji", - "info": "🙎", - "apply": "🙎" - }, - { - "label": ":man-pouting:", - "detail": "emoji", - "info": "🙎‍♂️", - "apply": "🙎‍♂️" - }, - { - "label": ":woman-pouting:", - "detail": "emoji", - "info": "🙎‍♀️", - "apply": "🙎‍♀️" - }, - { - "label": ":person-gesturing-no:", - "detail": "emoji", - "info": "🙅", - "apply": "🙅" - }, - { - "label": ":man-gesturing-no:", - "detail": "emoji", - "info": "🙅‍♂️", - "apply": "🙅‍♂️" - }, - { - "label": ":woman-gesturing-no:", - "detail": "emoji", - "info": "🙅‍♀️", - "apply": "🙅‍♀️" - }, - { - "label": ":person-gesturing-ok:", - "detail": "emoji", - "info": "🙆", - "apply": "🙆" - }, - { - "label": ":man-gesturing-ok:", - "detail": "emoji", - "info": "🙆‍♂️", - "apply": "🙆‍♂️" - }, - { - "label": ":woman-gesturing-ok:", - "detail": "emoji", - "info": "🙆‍♀️", - "apply": "🙆‍♀️" - }, - { - "label": ":person-tipping-hand:", - "detail": "emoji", - "info": "💁", - "apply": "💁" - }, - { - "label": ":man-tipping-hand:", - "detail": "emoji", - "info": "💁‍♂️", - "apply": "💁‍♂️" - }, - { - "label": ":woman-tipping-hand:", - "detail": "emoji", - "info": "💁‍♀️", - "apply": "💁‍♀️" - }, - { - "label": ":person-raising-hand:", - "detail": "emoji", - "info": "🙋", - "apply": "🙋" - }, - { - "label": ":man-raising-hand:", - "detail": "emoji", - "info": "🙋‍♂️", - "apply": "🙋‍♂️" - }, - { - "label": ":woman-raising-hand:", - "detail": "emoji", - "info": "🙋‍♀️", - "apply": "🙋‍♀️" - }, - { - "label": ":deaf-person:", - "detail": "emoji", - "info": "🧏", - "apply": "🧏" - }, - { - "label": ":deaf-man:", - "detail": "emoji", - "info": "🧏‍♂️", - "apply": "🧏‍♂️" - }, - { - "label": ":deaf-woman:", - "detail": "emoji", - "info": "🧏‍♀️", - "apply": "🧏‍♀️" - }, - { - "label": ":person-bowing:", - "detail": "emoji", - "info": "🙇", - "apply": "🙇" - }, - { - "label": ":man-bowing:", - "detail": "emoji", - "info": "🙇‍♂️", - "apply": "🙇‍♂️" - }, - { - "label": ":woman-bowing:", - "detail": "emoji", - "info": "🙇‍♀️", - "apply": "🙇‍♀️" - }, - { - "label": ":person-facepalming:", - "detail": "emoji", - "info": "🤦", - "apply": "🤦" - }, - { - "label": ":man-facepalming:", - "detail": "emoji", - "info": "🤦‍♂️", - "apply": "🤦‍♂️" - }, - { - "label": ":woman-facepalming:", - "detail": "emoji", - "info": "🤦‍♀️", - "apply": "🤦‍♀️" - }, - { - "label": ":person-shrugging:", - "detail": "emoji", - "info": "🤷", - "apply": "🤷" - }, - { - "label": ":man-shrugging:", - "detail": "emoji", - "info": "🤷‍♂️", - "apply": "🤷‍♂️" - }, - { - "label": ":woman-shrugging:", - "detail": "emoji", - "info": "🤷‍♀️", - "apply": "🤷‍♀️" - }, - { - "label": ":health-worker:", - "detail": "emoji", - "info": "🧑‍⚕️", - "apply": "🧑‍⚕️" - }, - { - "label": ":man-health-worker:", - "detail": "emoji", - "info": "👨‍⚕️", - "apply": "👨‍⚕️" - }, - { - "label": ":woman-health-worker:", - "detail": "emoji", - "info": "👩‍⚕️", - "apply": "👩‍⚕️" - }, - { - "label": ":student:", - "detail": "emoji", - "info": "🧑‍🎓", - "apply": "🧑‍🎓" - }, - { - "label": ":man-student:", - "detail": "emoji", - "info": "👨‍🎓", - "apply": "👨‍🎓" - }, - { - "label": ":woman-student:", - "detail": "emoji", - "info": "👩‍🎓", - "apply": "👩‍🎓" - }, - { - "label": ":teacher:", - "detail": "emoji", - "info": "🧑‍🏫", - "apply": "🧑‍🏫" - }, - { - "label": ":man-teacher:", - "detail": "emoji", - "info": "👨‍🏫", - "apply": "👨‍🏫" - }, - { - "label": ":woman-teacher:", - "detail": "emoji", - "info": "👩‍🏫", - "apply": "👩‍🏫" - }, - { - "label": ":judge:", - "detail": "emoji", - "info": "🧑‍⚖️", - "apply": "🧑‍⚖️" - }, - { - "label": ":man-judge:", - "detail": "emoji", - "info": "👨‍⚖️", - "apply": "👨‍⚖️" - }, - { - "label": ":woman-judge:", - "detail": "emoji", - "info": "👩‍⚖️", - "apply": "👩‍⚖️" - }, - { - "label": ":farmer:", - "detail": "emoji", - "info": "🧑‍🌾", - "apply": "🧑‍🌾" - }, - { - "label": ":man-farmer:", - "detail": "emoji", - "info": "👨‍🌾", - "apply": "👨‍🌾" - }, - { - "label": ":woman-farmer:", - "detail": "emoji", - "info": "👩‍🌾", - "apply": "👩‍🌾" - }, - { - "label": ":cook:", - "detail": "emoji", - "info": "🧑‍🍳", - "apply": "🧑‍🍳" - }, - { - "label": ":man-cook:", - "detail": "emoji", - "info": "👨‍🍳", - "apply": "👨‍🍳" - }, - { - "label": ":woman-cook:", - "detail": "emoji", - "info": "👩‍🍳", - "apply": "👩‍🍳" - }, - { - "label": ":mechanic:", - "detail": "emoji", - "info": "🧑‍🔧", - "apply": "🧑‍🔧" - }, - { - "label": ":man-mechanic:", - "detail": "emoji", - "info": "👨‍🔧", - "apply": "👨‍🔧" - }, - { - "label": ":woman-mechanic:", - "detail": "emoji", - "info": "👩‍🔧", - "apply": "👩‍🔧" - }, - { - "label": ":factory-worker:", - "detail": "emoji", - "info": "🧑‍🏭", - "apply": "🧑‍🏭" - }, - { - "label": ":man-factory-worker:", - "detail": "emoji", - "info": "👨‍🏭", - "apply": "👨‍🏭" - }, - { - "label": ":woman-factory-worker:", - "detail": "emoji", - "info": "👩‍🏭", - "apply": "👩‍🏭" - }, - { - "label": ":office-worker:", - "detail": "emoji", - "info": "🧑‍💼", - "apply": "🧑‍💼" - }, - { - "label": ":man-office-worker:", - "detail": "emoji", - "info": "👨‍💼", - "apply": "👨‍💼" - }, - { - "label": ":woman-office-worker:", - "detail": "emoji", - "info": "👩‍💼", - "apply": "👩‍💼" - }, - { - "label": ":scientist:", - "detail": "emoji", - "info": "🧑‍🔬", - "apply": "🧑‍🔬" - }, - { - "label": ":man-scientist:", - "detail": "emoji", - "info": "👨‍🔬", - "apply": "👨‍🔬" - }, - { - "label": ":woman-scientist:", - "detail": "emoji", - "info": "👩‍🔬", - "apply": "👩‍🔬" - }, - { - "label": ":technologist:", - "detail": "emoji", - "info": "🧑‍💻", - "apply": "🧑‍💻" - }, - { - "label": ":man-technologist:", - "detail": "emoji", - "info": "👨‍💻", - "apply": "👨‍💻" - }, - { - "label": ":woman-technologist:", - "detail": "emoji", - "info": "👩‍💻", - "apply": "👩‍💻" - }, - { - "label": ":singer:", - "detail": "emoji", - "info": "🧑‍🎤", - "apply": "🧑‍🎤" - }, - { - "label": ":man-singer:", - "detail": "emoji", - "info": "👨‍🎤", - "apply": "👨‍🎤" - }, - { - "label": ":woman-singer:", - "detail": "emoji", - "info": "👩‍🎤", - "apply": "👩‍🎤" - }, - { - "label": ":artist:", - "detail": "emoji", - "info": "🧑‍🎨", - "apply": "🧑‍🎨" - }, - { - "label": ":man-artist:", - "detail": "emoji", - "info": "👨‍🎨", - "apply": "👨‍🎨" - }, - { - "label": ":woman-artist:", - "detail": "emoji", - "info": "👩‍🎨", - "apply": "👩‍🎨" - }, - { - "label": ":pilot:", - "detail": "emoji", - "info": "🧑‍✈️", - "apply": "🧑‍✈️" - }, - { - "label": ":man-pilot:", - "detail": "emoji", - "info": "👨‍✈️", - "apply": "👨‍✈️" - }, - { - "label": ":woman-pilot:", - "detail": "emoji", - "info": "👩‍✈️", - "apply": "👩‍✈️" - }, - { - "label": ":astronaut:", - "detail": "emoji", - "info": "🧑‍🚀", - "apply": "🧑‍🚀" - }, - { - "label": ":man-astronaut:", - "detail": "emoji", - "info": "👨‍🚀", - "apply": "👨‍🚀" - }, - { - "label": ":woman-astronaut:", - "detail": "emoji", - "info": "👩‍🚀", - "apply": "👩‍🚀" - }, - { - "label": ":firefighter:", - "detail": "emoji", - "info": "🧑‍🚒", - "apply": "🧑‍🚒" - }, - { - "label": ":man-firefighter:", - "detail": "emoji", - "info": "👨‍🚒", - "apply": "👨‍🚒" - }, - { - "label": ":woman-firefighter:", - "detail": "emoji", - "info": "👩‍🚒", - "apply": "👩‍🚒" - }, - { - "label": ":police-officer:", - "detail": "emoji", - "info": "👮", - "apply": "👮" - }, - { - "label": ":man-police-officer:", - "detail": "emoji", - "info": "👮‍♂️", - "apply": "👮‍♂️" - }, - { - "label": ":woman-police-officer:", - "detail": "emoji", - "info": "👮‍♀️", - "apply": "👮‍♀️" - }, - { - "label": ":detective:", - "detail": "emoji", - "info": "🕵️", - "apply": "🕵️" - }, - { - "label": ":man-detective:", - "detail": "emoji", - "info": "🕵️‍♂️", - "apply": "🕵️‍♂️" - }, - { - "label": ":woman-detective:", - "detail": "emoji", - "info": "🕵️‍♀️", - "apply": "🕵️‍♀️" - }, - { - "label": ":guard:", - "detail": "emoji", - "info": "💂", - "apply": "💂" - }, - { - "label": ":man-guard:", - "detail": "emoji", - "info": "💂‍♂️", - "apply": "💂‍♂️" - }, - { - "label": ":woman-guard:", - "detail": "emoji", - "info": "💂‍♀️", - "apply": "💂‍♀️" - }, - { - "label": ":ninja:", - "detail": "emoji", - "info": "🥷", - "apply": "🥷" - }, - { - "label": ":construction-worker:", - "detail": "emoji", - "info": "👷", - "apply": "👷" - }, - { - "label": ":man-construction-worker:", - "detail": "emoji", - "info": "👷‍♂️", - "apply": "👷‍♂️" - }, - { - "label": ":woman-construction-worker:", - "detail": "emoji", - "info": "👷‍♀️", - "apply": "👷‍♀️" - }, - { - "label": ":person-with-crown:", - "detail": "emoji", - "info": "🫅", - "apply": "🫅" - }, - { - "label": ":prince:", - "detail": "emoji", - "info": "🤴", - "apply": "🤴" - }, - { - "label": ":princess:", - "detail": "emoji", - "info": "👸", - "apply": "👸" - }, - { - "label": ":person-wearing-turban:", - "detail": "emoji", - "info": "👳", - "apply": "👳" - }, - { - "label": ":man-wearing-turban:", - "detail": "emoji", - "info": "👳‍♂️", - "apply": "👳‍♂️" - }, - { - "label": ":woman-wearing-turban:", - "detail": "emoji", - "info": "👳‍♀️", - "apply": "👳‍♀️" - }, - { - "label": ":person-with-skullcap:", - "detail": "emoji", - "info": "👲", - "apply": "👲" - }, - { - "label": ":woman-with-headscarf:", - "detail": "emoji", - "info": "🧕", - "apply": "🧕" - }, - { - "label": ":person-in-tuxedo:", - "detail": "emoji", - "info": "🤵", - "apply": "🤵" - }, - { - "label": ":man-in-tuxedo:", - "detail": "emoji", - "info": "🤵‍♂️", - "apply": "🤵‍♂️" - }, - { - "label": ":woman-in-tuxedo:", - "detail": "emoji", - "info": "🤵‍♀️", - "apply": "🤵‍♀️" - }, - { - "label": ":person-with-veil:", - "detail": "emoji", - "info": "👰", - "apply": "👰" - }, - { - "label": ":man-with-veil:", - "detail": "emoji", - "info": "👰‍♂️", - "apply": "👰‍♂️" - }, - { - "label": ":woman-with-veil:", - "detail": "emoji", - "info": "👰‍♀️", - "apply": "👰‍♀️" - }, - { - "label": ":pregnant-woman:", - "detail": "emoji", - "info": "🤰", - "apply": "🤰" - }, - { - "label": ":pregnant-man:", - "detail": "emoji", - "info": "🫃", - "apply": "🫃" - }, - { - "label": ":pregnant-person:", - "detail": "emoji", - "info": "🫄", - "apply": "🫄" - }, - { - "label": ":breast-feeding:", - "detail": "emoji", - "info": "🤱", - "apply": "🤱" - }, - { - "label": ":woman-feeding-baby:", - "detail": "emoji", - "info": "👩‍🍼", - "apply": "👩‍🍼" - }, - { - "label": ":man-feeding-baby:", - "detail": "emoji", - "info": "👨‍🍼", - "apply": "👨‍🍼" - }, - { - "label": ":person-feeding-baby:", - "detail": "emoji", - "info": "🧑‍🍼", - "apply": "🧑‍🍼" - }, - { - "label": ":baby-angel:", - "detail": "emoji", - "info": "👼", - "apply": "👼" - }, - { - "label": ":santa-claus:", - "detail": "emoji", - "info": "🎅", - "apply": "🎅" - }, - { - "label": ":mrs.-claus:", - "detail": "emoji", - "info": "🤶", - "apply": "🤶" - }, - { - "label": ":mx-claus:", - "detail": "emoji", - "info": "🧑‍🎄", - "apply": "🧑‍🎄" - }, - { - "label": ":superhero:", - "detail": "emoji", - "info": "🦸", - "apply": "🦸" - }, - { - "label": ":man-superhero:", - "detail": "emoji", - "info": "🦸‍♂️", - "apply": "🦸‍♂️" - }, - { - "label": ":woman-superhero:", - "detail": "emoji", - "info": "🦸‍♀️", - "apply": "🦸‍♀️" - }, - { - "label": ":supervillain:", - "detail": "emoji", - "info": "🦹", - "apply": "🦹" - }, - { - "label": ":man-supervillain:", - "detail": "emoji", - "info": "🦹‍♂️", - "apply": "🦹‍♂️" - }, - { - "label": ":woman-supervillain:", - "detail": "emoji", - "info": "🦹‍♀️", - "apply": "🦹‍♀️" - }, - { - "label": ":mage:", - "detail": "emoji", - "info": "🧙", - "apply": "🧙" - }, - { - "label": ":man-mage:", - "detail": "emoji", - "info": "🧙‍♂️", - "apply": "🧙‍♂️" - }, - { - "label": ":woman-mage:", - "detail": "emoji", - "info": "🧙‍♀️", - "apply": "🧙‍♀️" - }, - { - "label": ":fairy:", - "detail": "emoji", - "info": "🧚", - "apply": "🧚" - }, - { - "label": ":man-fairy:", - "detail": "emoji", - "info": "🧚‍♂️", - "apply": "🧚‍♂️" - }, - { - "label": ":woman-fairy:", - "detail": "emoji", - "info": "🧚‍♀️", - "apply": "🧚‍♀️" - }, - { - "label": ":vampire:", - "detail": "emoji", - "info": "🧛", - "apply": "🧛" - }, - { - "label": ":man-vampire:", - "detail": "emoji", - "info": "🧛‍♂️", - "apply": "🧛‍♂️" - }, - { - "label": ":woman-vampire:", - "detail": "emoji", - "info": "🧛‍♀️", - "apply": "🧛‍♀️" - }, - { - "label": ":merperson:", - "detail": "emoji", - "info": "🧜", - "apply": "🧜" - }, - { - "label": ":merman:", - "detail": "emoji", - "info": "🧜‍♂️", - "apply": "🧜‍♂️" - }, - { - "label": ":mermaid:", - "detail": "emoji", - "info": "🧜‍♀️", - "apply": "🧜‍♀️" - }, - { - "label": ":elf:", - "detail": "emoji", - "info": "🧝", - "apply": "🧝" - }, - { - "label": ":man-elf:", - "detail": "emoji", - "info": "🧝‍♂️", - "apply": "🧝‍♂️" - }, - { - "label": ":woman-elf:", - "detail": "emoji", - "info": "🧝‍♀️", - "apply": "🧝‍♀️" - }, - { - "label": ":genie:", - "detail": "emoji", - "info": "🧞", - "apply": "🧞" - }, - { - "label": ":man-genie:", - "detail": "emoji", - "info": "🧞‍♂️", - "apply": "🧞‍♂️" - }, - { - "label": ":woman-genie:", - "detail": "emoji", - "info": "🧞‍♀️", - "apply": "🧞‍♀️" - }, - { - "label": ":zombie:", - "detail": "emoji", - "info": "🧟", - "apply": "🧟" - }, - { - "label": ":man-zombie:", - "detail": "emoji", - "info": "🧟‍♂️", - "apply": "🧟‍♂️" - }, - { - "label": ":woman-zombie:", - "detail": "emoji", - "info": "🧟‍♀️", - "apply": "🧟‍♀️" - }, - { - "label": ":troll:", - "detail": "emoji", - "info": "🧌", - "apply": "🧌" - }, - { - "label": ":person-getting-massage:", - "detail": "emoji", - "info": "💆", - "apply": "💆" - }, - { - "label": ":man-getting-massage:", - "detail": "emoji", - "info": "💆‍♂️", - "apply": "💆‍♂️" - }, - { - "label": ":woman-getting-massage:", - "detail": "emoji", - "info": "💆‍♀️", - "apply": "💆‍♀️" - }, - { - "label": ":person-getting-haircut:", - "detail": "emoji", - "info": "💇", - "apply": "💇" - }, - { - "label": ":man-getting-haircut:", - "detail": "emoji", - "info": "💇‍♂️", - "apply": "💇‍♂️" - }, - { - "label": ":woman-getting-haircut:", - "detail": "emoji", - "info": "💇‍♀️", - "apply": "💇‍♀️" - }, - { - "label": ":person-walking:", - "detail": "emoji", - "info": "🚶", - "apply": "🚶" - }, - { - "label": ":man-walking:", - "detail": "emoji", - "info": "🚶‍♂️", - "apply": "🚶‍♂️" - }, - { - "label": ":woman-walking:", - "detail": "emoji", - "info": "🚶‍♀️", - "apply": "🚶‍♀️" - }, - { - "label": ":person-standing:", - "detail": "emoji", - "info": "🧍", - "apply": "🧍" - }, - { - "label": ":man-standing:", - "detail": "emoji", - "info": "🧍‍♂️", - "apply": "🧍‍♂️" - }, - { - "label": ":woman-standing:", - "detail": "emoji", - "info": "🧍‍♀️", - "apply": "🧍‍♀️" - }, - { - "label": ":person-kneeling:", - "detail": "emoji", - "info": "🧎", - "apply": "🧎" - }, - { - "label": ":man-kneeling:", - "detail": "emoji", - "info": "🧎‍♂️", - "apply": "🧎‍♂️" - }, - { - "label": ":woman-kneeling:", - "detail": "emoji", - "info": "🧎‍♀️", - "apply": "🧎‍♀️" - }, - { - "label": ":person-with-white-cane:", - "detail": "emoji", - "info": "🧑‍🦯", - "apply": "🧑‍🦯" - }, - { - "label": ":man-with-white-cane:", - "detail": "emoji", - "info": "👨‍🦯", - "apply": "👨‍🦯" - }, - { - "label": ":woman-with-white-cane:", - "detail": "emoji", - "info": "👩‍🦯", - "apply": "👩‍🦯" - }, - { - "label": ":person-in-motorized-wheelchair:", - "detail": "emoji", - "info": "🧑‍🦼", - "apply": "🧑‍🦼" - }, - { - "label": ":man-in-motorized-wheelchair:", - "detail": "emoji", - "info": "👨‍🦼", - "apply": "👨‍🦼" - }, - { - "label": ":woman-in-motorized-wheelchair:", - "detail": "emoji", - "info": "👩‍🦼", - "apply": "👩‍🦼" - }, - { - "label": ":person-in-manual-wheelchair:", - "detail": "emoji", - "info": "🧑‍🦽", - "apply": "🧑‍🦽" - }, - { - "label": ":man-in-manual-wheelchair:", - "detail": "emoji", - "info": "👨‍🦽", - "apply": "👨‍🦽" - }, - { - "label": ":woman-in-manual-wheelchair:", - "detail": "emoji", - "info": "👩‍🦽", - "apply": "👩‍🦽" - }, - { - "label": ":person-running:", - "detail": "emoji", - "info": "🏃", - "apply": "🏃" - }, - { - "label": ":man-running:", - "detail": "emoji", - "info": "🏃‍♂️", - "apply": "🏃‍♂️" - }, - { - "label": ":woman-running:", - "detail": "emoji", - "info": "🏃‍♀️", - "apply": "🏃‍♀️" - }, - { - "label": ":woman-dancing:", - "detail": "emoji", - "info": "💃", - "apply": "💃" - }, - { - "label": ":man-dancing:", - "detail": "emoji", - "info": "🕺", - "apply": "🕺" - }, - { - "label": ":person-in-suit-levitating:", - "detail": "emoji", - "info": "🕴️", - "apply": "🕴️" - }, - { - "label": ":people-with-bunny-ears:", - "detail": "emoji", - "info": "👯", - "apply": "👯" - }, - { - "label": ":men-with-bunny-ears:", - "detail": "emoji", - "info": "👯‍♂️", - "apply": "👯‍♂️" - }, - { - "label": ":women-with-bunny-ears:", - "detail": "emoji", - "info": "👯‍♀️", - "apply": "👯‍♀️" - }, - { - "label": ":person-in-steamy-room:", - "detail": "emoji", - "info": "🧖", - "apply": "🧖" - }, - { - "label": ":man-in-steamy-room:", - "detail": "emoji", - "info": "🧖‍♂️", - "apply": "🧖‍♂️" - }, - { - "label": ":woman-in-steamy-room:", - "detail": "emoji", - "info": "🧖‍♀️", - "apply": "🧖‍♀️" - }, - { - "label": ":person-climbing:", - "detail": "emoji", - "info": "🧗", - "apply": "🧗" - }, - { - "label": ":man-climbing:", - "detail": "emoji", - "info": "🧗‍♂️", - "apply": "🧗‍♂️" - }, - { - "label": ":woman-climbing:", - "detail": "emoji", - "info": "🧗‍♀️", - "apply": "🧗‍♀️" - }, - { - "label": ":person-fencing:", - "detail": "emoji", - "info": "🤺", - "apply": "🤺" - }, - { - "label": ":horse-racing:", - "detail": "emoji", - "info": "🏇", - "apply": "🏇" - }, - { - "label": ":skier:", - "detail": "emoji", - "info": "⛷️", - "apply": "⛷️" - }, - { - "label": ":snowboarder:", - "detail": "emoji", - "info": "🏂", - "apply": "🏂" - }, - { - "label": ":person-golfing:", - "detail": "emoji", - "info": "🏌️", - "apply": "🏌️" - }, - { - "label": ":man-golfing:", - "detail": "emoji", - "info": "🏌️‍♂️", - "apply": "🏌️‍♂️" - }, - { - "label": ":woman-golfing:", - "detail": "emoji", - "info": "🏌️‍♀️", - "apply": "🏌️‍♀️" - }, - { - "label": ":person-surfing:", - "detail": "emoji", - "info": "🏄", - "apply": "🏄" - }, - { - "label": ":man-surfing:", - "detail": "emoji", - "info": "🏄‍♂️", - "apply": "🏄‍♂️" - }, - { - "label": ":woman-surfing:", - "detail": "emoji", - "info": "🏄‍♀️", - "apply": "🏄‍♀️" - }, - { - "label": ":person-rowing-boat:", - "detail": "emoji", - "info": "🚣", - "apply": "🚣" - }, - { - "label": ":man-rowing-boat:", - "detail": "emoji", - "info": "🚣‍♂️", - "apply": "🚣‍♂️" - }, - { - "label": ":woman-rowing-boat:", - "detail": "emoji", - "info": "🚣‍♀️", - "apply": "🚣‍♀️" - }, - { - "label": ":person-swimming:", - "detail": "emoji", - "info": "🏊", - "apply": "🏊" - }, - { - "label": ":man-swimming:", - "detail": "emoji", - "info": "🏊‍♂️", - "apply": "🏊‍♂️" - }, - { - "label": ":woman-swimming:", - "detail": "emoji", - "info": "🏊‍♀️", - "apply": "🏊‍♀️" - }, - { - "label": ":person-bouncing-ball:", - "detail": "emoji", - "info": "⛹️", - "apply": "⛹️" - }, - { - "label": ":man-bouncing-ball:", - "detail": "emoji", - "info": "⛹️‍♂️", - "apply": "⛹️‍♂️" - }, - { - "label": ":woman-bouncing-ball:", - "detail": "emoji", - "info": "⛹️‍♀️", - "apply": "⛹️‍♀️" - }, - { - "label": ":person-lifting-weights:", - "detail": "emoji", - "info": "🏋️", - "apply": "🏋️" - }, - { - "label": ":man-lifting-weights:", - "detail": "emoji", - "info": "🏋️‍♂️", - "apply": "🏋️‍♂️" - }, - { - "label": ":woman-lifting-weights:", - "detail": "emoji", - "info": "🏋️‍♀️", - "apply": "🏋️‍♀️" - }, - { - "label": ":person-biking:", - "detail": "emoji", - "info": "🚴", - "apply": "🚴" - }, - { - "label": ":man-biking:", - "detail": "emoji", - "info": "🚴‍♂️", - "apply": "🚴‍♂️" - }, - { - "label": ":woman-biking:", - "detail": "emoji", - "info": "🚴‍♀️", - "apply": "🚴‍♀️" - }, - { - "label": ":person-mountain-biking:", - "detail": "emoji", - "info": "🚵", - "apply": "🚵" - }, - { - "label": ":man-mountain-biking:", - "detail": "emoji", - "info": "🚵‍♂️", - "apply": "🚵‍♂️" - }, - { - "label": ":woman-mountain-biking:", - "detail": "emoji", - "info": "🚵‍♀️", - "apply": "🚵‍♀️" - }, - { - "label": ":person-cartwheeling:", - "detail": "emoji", - "info": "🤸", - "apply": "🤸" - }, - { - "label": ":man-cartwheeling:", - "detail": "emoji", - "info": "🤸‍♂️", - "apply": "🤸‍♂️" - }, - { - "label": ":woman-cartwheeling:", - "detail": "emoji", - "info": "🤸‍♀️", - "apply": "🤸‍♀️" - }, - { - "label": ":people-wrestling:", - "detail": "emoji", - "info": "🤼", - "apply": "🤼" - }, - { - "label": ":men-wrestling:", - "detail": "emoji", - "info": "🤼‍♂️", - "apply": "🤼‍♂️" - }, - { - "label": ":women-wrestling:", - "detail": "emoji", - "info": "🤼‍♀️", - "apply": "🤼‍♀️" - }, - { - "label": ":person-playing-water-polo:", - "detail": "emoji", - "info": "🤽", - "apply": "🤽" - }, - { - "label": ":man-playing-water-polo:", - "detail": "emoji", - "info": "🤽‍♂️", - "apply": "🤽‍♂️" - }, - { - "label": ":woman-playing-water-polo:", - "detail": "emoji", - "info": "🤽‍♀️", - "apply": "🤽‍♀️" - }, - { - "label": ":person-playing-handball:", - "detail": "emoji", - "info": "🤾", - "apply": "🤾" - }, - { - "label": ":man-playing-handball:", - "detail": "emoji", - "info": "🤾‍♂️", - "apply": "🤾‍♂️" - }, - { - "label": ":woman-playing-handball:", - "detail": "emoji", - "info": "🤾‍♀️", - "apply": "🤾‍♀️" - }, - { - "label": ":person-juggling:", - "detail": "emoji", - "info": "🤹", - "apply": "🤹" - }, - { - "label": ":man-juggling:", - "detail": "emoji", - "info": "🤹‍♂️", - "apply": "🤹‍♂️" - }, - { - "label": ":woman-juggling:", - "detail": "emoji", - "info": "🤹‍♀️", - "apply": "🤹‍♀️" - }, - { - "label": ":person-in-lotus-position:", - "detail": "emoji", - "info": "🧘", - "apply": "🧘" - }, - { - "label": ":man-in-lotus-position:", - "detail": "emoji", - "info": "🧘‍♂️", - "apply": "🧘‍♂️" - }, - { - "label": ":woman-in-lotus-position:", - "detail": "emoji", - "info": "🧘‍♀️", - "apply": "🧘‍♀️" - }, - { - "label": ":person-taking-bath:", - "detail": "emoji", - "info": "🛀", - "apply": "🛀" - }, - { - "label": ":person-in-bed:", - "detail": "emoji", - "info": "🛌", - "apply": "🛌" - }, - { - "label": ":people-holding-hands:", - "detail": "emoji", - "info": "🧑‍🤝‍🧑", - "apply": "🧑‍🤝‍🧑" - }, - { - "label": ":women-holding-hands:", - "detail": "emoji", - "info": "👭", - "apply": "👭" - }, - { - "label": ":woman-and-man-holding-hands:", - "detail": "emoji", - "info": "👫", - "apply": "👫" - }, - { - "label": ":men-holding-hands:", - "detail": "emoji", - "info": "👬", - "apply": "👬" - }, - { - "label": ":kiss:", - "detail": "emoji", - "info": "💏", - "apply": "💏" - }, - { - "label": ":kiss-woman,-man:", - "detail": "emoji", - "info": "👩‍❤️‍💋‍👨", - "apply": "👩‍❤️‍💋‍👨" - }, - { - "label": ":kiss-man,-man:", - "detail": "emoji", - "info": "👨‍❤️‍💋‍👨", - "apply": "👨‍❤️‍💋‍👨" - }, - { - "label": ":kiss-woman,-woman:", - "detail": "emoji", - "info": "👩‍❤️‍💋‍👩", - "apply": "👩‍❤️‍💋‍👩" - }, - { - "label": ":couple-with-heart:", - "detail": "emoji", - "info": "💑", - "apply": "💑" - }, - { - "label": ":couple-with-heart-woman,-man:", - "detail": "emoji", - "info": "👩‍❤️‍👨", - "apply": "👩‍❤️‍👨" - }, - { - "label": ":couple-with-heart-man,-man:", - "detail": "emoji", - "info": "👨‍❤️‍👨", - "apply": "👨‍❤️‍👨" - }, - { - "label": ":couple-with-heart-woman,-woman:", - "detail": "emoji", - "info": "👩‍❤️‍👩", - "apply": "👩‍❤️‍👩" - }, - { - "label": ":family:", - "detail": "emoji", - "info": "👪", - "apply": "👪" - }, - { - "label": ":family-man,-woman,-boy:", - "detail": "emoji", - "info": "👨‍👩‍👦", - "apply": "👨‍👩‍👦" - }, - { - "label": ":family-man,-woman,-girl:", - "detail": "emoji", - "info": "👨‍👩‍👧", - "apply": "👨‍👩‍👧" - }, - { - "label": ":family-man,-woman,-girl,-boy:", - "detail": "emoji", - "info": "👨‍👩‍👧‍👦", - "apply": "👨‍👩‍👧‍👦" - }, - { - "label": ":family-man,-woman,-boy,-boy:", - "detail": "emoji", - "info": "👨‍👩‍👦‍👦", - "apply": "👨‍👩‍👦‍👦" - }, - { - "label": ":family-man,-woman,-girl,-girl:", - "detail": "emoji", - "info": "👨‍👩‍👧‍👧", - "apply": "👨‍👩‍👧‍👧" - }, - { - "label": ":family-man,-man,-boy:", - "detail": "emoji", - "info": "👨‍👨‍👦", - "apply": "👨‍👨‍👦" - }, - { - "label": ":family-man,-man,-girl:", - "detail": "emoji", - "info": "👨‍👨‍👧", - "apply": "👨‍👨‍👧" - }, - { - "label": ":family-man,-man,-girl,-boy:", - "detail": "emoji", - "info": "👨‍👨‍👧‍👦", - "apply": "👨‍👨‍👧‍👦" - }, - { - "label": ":family-man,-man,-boy,-boy:", - "detail": "emoji", - "info": "👨‍👨‍👦‍👦", - "apply": "👨‍👨‍👦‍👦" - }, - { - "label": ":family-man,-man,-girl,-girl:", - "detail": "emoji", - "info": "👨‍👨‍👧‍👧", - "apply": "👨‍👨‍👧‍👧" - }, - { - "label": ":family-woman,-woman,-boy:", - "detail": "emoji", - "info": "👩‍👩‍👦", - "apply": "👩‍👩‍👦" - }, - { - "label": ":family-woman,-woman,-girl:", - "detail": "emoji", - "info": "👩‍👩‍👧", - "apply": "👩‍👩‍👧" - }, - { - "label": ":family-woman,-woman,-girl,-boy:", - "detail": "emoji", - "info": "👩‍👩‍👧‍👦", - "apply": "👩‍👩‍👧‍👦" - }, - { - "label": ":family-woman,-woman,-boy,-boy:", - "detail": "emoji", - "info": "👩‍👩‍👦‍👦", - "apply": "👩‍👩‍👦‍👦" - }, - { - "label": ":family-woman,-woman,-girl,-girl:", - "detail": "emoji", - "info": "👩‍👩‍👧‍👧", - "apply": "👩‍👩‍👧‍👧" - }, - { - "label": ":family-man,-boy:", - "detail": "emoji", - "info": "👨‍👦", - "apply": "👨‍👦" - }, - { - "label": ":family-man,-boy,-boy:", - "detail": "emoji", - "info": "👨‍👦‍👦", - "apply": "👨‍👦‍👦" - }, - { - "label": ":family-man,-girl:", - "detail": "emoji", - "info": "👨‍👧", - "apply": "👨‍👧" - }, - { - "label": ":family-man,-girl,-boy:", - "detail": "emoji", - "info": "👨‍👧‍👦", - "apply": "👨‍👧‍👦" - }, - { - "label": ":family-man,-girl,-girl:", - "detail": "emoji", - "info": "👨‍👧‍👧", - "apply": "👨‍👧‍👧" - }, - { - "label": ":family-woman,-boy:", - "detail": "emoji", - "info": "👩‍👦", - "apply": "👩‍👦" - }, - { - "label": ":family-woman,-boy,-boy:", - "detail": "emoji", - "info": "👩‍👦‍👦", - "apply": "👩‍👦‍👦" - }, - { - "label": ":family-woman,-girl:", - "detail": "emoji", - "info": "👩‍👧", - "apply": "👩‍👧" - }, - { - "label": ":family-woman,-girl,-boy:", - "detail": "emoji", - "info": "👩‍👧‍👦", - "apply": "👩‍👧‍👦" - }, - { - "label": ":family-woman,-girl,-girl:", - "detail": "emoji", - "info": "👩‍👧‍👧", - "apply": "👩‍👧‍👧" - }, - { - "label": ":speaking-head:", - "detail": "emoji", - "info": "🗣️", - "apply": "🗣️" - }, - { - "label": ":bust-in-silhouette:", - "detail": "emoji", - "info": "👤", - "apply": "👤" - }, - { - "label": ":busts-in-silhouette:", - "detail": "emoji", - "info": "👥", - "apply": "👥" - }, - { - "label": ":people-hugging:", - "detail": "emoji", - "info": "🫂", - "apply": "🫂" - }, - { - "label": ":footprints:", - "detail": "emoji", - "info": "👣", - "apply": "👣" - }, - { - "label": ":monkey-face:", - "detail": "emoji", - "info": "🐵", - "apply": "🐵" - }, - { - "label": ":monkey:", - "detail": "emoji", - "info": "🐒", - "apply": "🐒" - }, - { - "label": ":gorilla:", - "detail": "emoji", - "info": "🦍", - "apply": "🦍" - }, - { - "label": ":orangutan:", - "detail": "emoji", - "info": "🦧", - "apply": "🦧" - }, - { - "label": ":dog-face:", - "detail": "emoji", - "info": "🐶", - "apply": "🐶" - }, - { - "label": ":dog:", - "detail": "emoji", - "info": "🐕", - "apply": "🐕" - }, - { - "label": ":guide-dog:", - "detail": "emoji", - "info": "🦮", - "apply": "🦮" - }, - { - "label": ":service-dog:", - "detail": "emoji", - "info": "🐕‍🦺", - "apply": "🐕‍🦺" - }, - { - "label": ":poodle:", - "detail": "emoji", - "info": "🐩", - "apply": "🐩" - }, - { - "label": ":wolf:", - "detail": "emoji", - "info": "🐺", - "apply": "🐺" - }, - { - "label": ":fox:", - "detail": "emoji", - "info": "🦊", - "apply": "🦊" - }, - { - "label": ":raccoon:", - "detail": "emoji", - "info": "🦝", - "apply": "🦝" - }, - { - "label": ":cat-face:", - "detail": "emoji", - "info": "🐱", - "apply": "🐱" - }, - { - "label": ":cat:", - "detail": "emoji", - "info": "🐈", - "apply": "🐈" - }, - { - "label": ":black-cat:", - "detail": "emoji", - "info": "🐈‍⬛", - "apply": "🐈‍⬛" - }, - { - "label": ":lion:", - "detail": "emoji", - "info": "🦁", - "apply": "🦁" - }, - { - "label": ":tiger-face:", - "detail": "emoji", - "info": "🐯", - "apply": "🐯" - }, - { - "label": ":tiger:", - "detail": "emoji", - "info": "🐅", - "apply": "🐅" - }, - { - "label": ":leopard:", - "detail": "emoji", - "info": "🐆", - "apply": "🐆" - }, - { - "label": ":horse-face:", - "detail": "emoji", - "info": "🐴", - "apply": "🐴" - }, - { - "label": ":moose:", - "detail": "emoji", - "info": "🫎", - "apply": "🫎" - }, - { - "label": ":donkey:", - "detail": "emoji", - "info": "🫏", - "apply": "🫏" - }, - { - "label": ":horse:", - "detail": "emoji", - "info": "🐎", - "apply": "🐎" - }, - { - "label": ":unicorn:", - "detail": "emoji", - "info": "🦄", - "apply": "🦄" - }, - { - "label": ":zebra:", - "detail": "emoji", - "info": "🦓", - "apply": "🦓" - }, - { - "label": ":deer:", - "detail": "emoji", - "info": "🦌", - "apply": "🦌" - }, - { - "label": ":bison:", - "detail": "emoji", - "info": "🦬", - "apply": "🦬" - }, - { - "label": ":cow-face:", - "detail": "emoji", - "info": "🐮", - "apply": "🐮" - }, - { - "label": ":ox:", - "detail": "emoji", - "info": "🐂", - "apply": "🐂" - }, - { - "label": ":water-buffalo:", - "detail": "emoji", - "info": "🐃", - "apply": "🐃" - }, - { - "label": ":cow:", - "detail": "emoji", - "info": "🐄", - "apply": "🐄" - }, - { - "label": ":pig-face:", - "detail": "emoji", - "info": "🐷", - "apply": "🐷" - }, - { - "label": ":pig:", - "detail": "emoji", - "info": "🐖", - "apply": "🐖" - }, - { - "label": ":boar:", - "detail": "emoji", - "info": "🐗", - "apply": "🐗" - }, - { - "label": ":pig-nose:", - "detail": "emoji", - "info": "🐽", - "apply": "🐽" - }, - { - "label": ":ram:", - "detail": "emoji", - "info": "🐏", - "apply": "🐏" - }, - { - "label": ":ewe:", - "detail": "emoji", - "info": "🐑", - "apply": "🐑" - }, - { - "label": ":goat:", - "detail": "emoji", - "info": "🐐", - "apply": "🐐" - }, - { - "label": ":camel:", - "detail": "emoji", - "info": "🐪", - "apply": "🐪" - }, - { - "label": ":two-hump-camel:", - "detail": "emoji", - "info": "🐫", - "apply": "🐫" - }, - { - "label": ":llama:", - "detail": "emoji", - "info": "🦙", - "apply": "🦙" - }, - { - "label": ":giraffe:", - "detail": "emoji", - "info": "🦒", - "apply": "🦒" - }, - { - "label": ":elephant:", - "detail": "emoji", - "info": "🐘", - "apply": "🐘" - }, - { - "label": ":mammoth:", - "detail": "emoji", - "info": "🦣", - "apply": "🦣" - }, - { - "label": ":rhinoceros:", - "detail": "emoji", - "info": "🦏", - "apply": "🦏" - }, - { - "label": ":hippopotamus:", - "detail": "emoji", - "info": "🦛", - "apply": "🦛" - }, - { - "label": ":mouse-face:", - "detail": "emoji", - "info": "🐭", - "apply": "🐭" - }, - { - "label": ":mouse:", - "detail": "emoji", - "info": "🐁", - "apply": "🐁" - }, - { - "label": ":rat:", - "detail": "emoji", - "info": "🐀", - "apply": "🐀" - }, - { - "label": ":hamster:", - "detail": "emoji", - "info": "🐹", - "apply": "🐹" - }, - { - "label": ":rabbit-face:", - "detail": "emoji", - "info": "🐰", - "apply": "🐰" - }, - { - "label": ":rabbit:", - "detail": "emoji", - "info": "🐇", - "apply": "🐇" - }, - { - "label": ":chipmunk:", - "detail": "emoji", - "info": "🐿️", - "apply": "🐿️" - }, - { - "label": ":beaver:", - "detail": "emoji", - "info": "🦫", - "apply": "🦫" - }, - { - "label": ":hedgehog:", - "detail": "emoji", - "info": "🦔", - "apply": "🦔" - }, - { - "label": ":bat:", - "detail": "emoji", - "info": "🦇", - "apply": "🦇" - }, - { - "label": ":bear:", - "detail": "emoji", - "info": "🐻", - "apply": "🐻" - }, - { - "label": ":polar-bear:", - "detail": "emoji", - "info": "🐻‍❄️", - "apply": "🐻‍❄️" - }, - { - "label": ":koala:", - "detail": "emoji", - "info": "🐨", - "apply": "🐨" - }, - { - "label": ":panda:", - "detail": "emoji", - "info": "🐼", - "apply": "🐼" - }, - { - "label": ":sloth:", - "detail": "emoji", - "info": "🦥", - "apply": "🦥" - }, - { - "label": ":otter:", - "detail": "emoji", - "info": "🦦", - "apply": "🦦" - }, - { - "label": ":skunk:", - "detail": "emoji", - "info": "🦨", - "apply": "🦨" - }, - { - "label": ":kangaroo:", - "detail": "emoji", - "info": "🦘", - "apply": "🦘" - }, - { - "label": ":badger:", - "detail": "emoji", - "info": "🦡", - "apply": "🦡" - }, - { - "label": ":paw-prints:", - "detail": "emoji", - "info": "🐾", - "apply": "🐾" - }, - { - "label": ":turkey:", - "detail": "emoji", - "info": "🦃", - "apply": "🦃" - }, - { - "label": ":chicken:", - "detail": "emoji", - "info": "🐔", - "apply": "🐔" - }, - { - "label": ":rooster:", - "detail": "emoji", - "info": "🐓", - "apply": "🐓" - }, - { - "label": ":hatching-chick:", - "detail": "emoji", - "info": "🐣", - "apply": "🐣" - }, - { - "label": ":baby-chick:", - "detail": "emoji", - "info": "🐤", - "apply": "🐤" - }, - { - "label": ":front-facing-baby-chick:", - "detail": "emoji", - "info": "🐥", - "apply": "🐥" - }, - { - "label": ":bird:", - "detail": "emoji", - "info": "🐦", - "apply": "🐦" - }, - { - "label": ":penguin:", - "detail": "emoji", - "info": "🐧", - "apply": "🐧" - }, - { - "label": ":dove:", - "detail": "emoji", - "info": "🕊️", - "apply": "🕊️" - }, - { - "label": ":eagle:", - "detail": "emoji", - "info": "🦅", - "apply": "🦅" - }, - { - "label": ":duck:", - "detail": "emoji", - "info": "🦆", - "apply": "🦆" - }, - { - "label": ":swan:", - "detail": "emoji", - "info": "🦢", - "apply": "🦢" - }, - { - "label": ":owl:", - "detail": "emoji", - "info": "🦉", - "apply": "🦉" - }, - { - "label": ":dodo:", - "detail": "emoji", - "info": "🦤", - "apply": "🦤" - }, - { - "label": ":feather:", - "detail": "emoji", - "info": "🪶", - "apply": "🪶" - }, - { - "label": ":flamingo:", - "detail": "emoji", - "info": "🦩", - "apply": "🦩" - }, - { - "label": ":peacock:", - "detail": "emoji", - "info": "🦚", - "apply": "🦚" - }, - { - "label": ":parrot:", - "detail": "emoji", - "info": "🦜", - "apply": "🦜" - }, - { - "label": ":wing:", - "detail": "emoji", - "info": "🪽", - "apply": "🪽" - }, - { - "label": ":black-bird:", - "detail": "emoji", - "info": "🐦‍⬛", - "apply": "🐦‍⬛" - }, - { - "label": ":goose:", - "detail": "emoji", - "info": "🪿", - "apply": "🪿" - }, - { - "label": ":frog:", - "detail": "emoji", - "info": "🐸", - "apply": "🐸" - }, - { - "label": ":crocodile:", - "detail": "emoji", - "info": "🐊", - "apply": "🐊" - }, - { - "label": ":turtle:", - "detail": "emoji", - "info": "🐢", - "apply": "🐢" - }, - { - "label": ":lizard:", - "detail": "emoji", - "info": "🦎", - "apply": "🦎" - }, - { - "label": ":snake:", - "detail": "emoji", - "info": "🐍", - "apply": "🐍" - }, - { - "label": ":dragon-face:", - "detail": "emoji", - "info": "🐲", - "apply": "🐲" - }, - { - "label": ":dragon:", - "detail": "emoji", - "info": "🐉", - "apply": "🐉" - }, - { - "label": ":sauropod:", - "detail": "emoji", - "info": "🦕", - "apply": "🦕" - }, - { - "label": ":t-rex:", - "detail": "emoji", - "info": "🦖", - "apply": "🦖" - }, - { - "label": ":spouting-whale:", - "detail": "emoji", - "info": "🐳", - "apply": "🐳" - }, - { - "label": ":whale:", - "detail": "emoji", - "info": "🐋", - "apply": "🐋" - }, - { - "label": ":dolphin:", - "detail": "emoji", - "info": "🐬", - "apply": "🐬" - }, - { - "label": ":seal:", - "detail": "emoji", - "info": "🦭", - "apply": "🦭" - }, - { - "label": ":fish:", - "detail": "emoji", - "info": "🐟", - "apply": "🐟" - }, - { - "label": ":tropical-fish:", - "detail": "emoji", - "info": "🐠", - "apply": "🐠" - }, - { - "label": ":blowfish:", - "detail": "emoji", - "info": "🐡", - "apply": "🐡" - }, - { - "label": ":shark:", - "detail": "emoji", - "info": "🦈", - "apply": "🦈" - }, - { - "label": ":octopus:", - "detail": "emoji", - "info": "🐙", - "apply": "🐙" - }, - { - "label": ":spiral-shell:", - "detail": "emoji", - "info": "🐚", - "apply": "🐚" - }, - { - "label": ":coral:", - "detail": "emoji", - "info": "🪸", - "apply": "🪸" - }, - { - "label": ":jellyfish:", - "detail": "emoji", - "info": "🪼", - "apply": "🪼" - }, - { - "label": ":snail:", - "detail": "emoji", - "info": "🐌", - "apply": "🐌" - }, - { - "label": ":butterfly:", - "detail": "emoji", - "info": "🦋", - "apply": "🦋" - }, - { - "label": ":bug:", - "detail": "emoji", - "info": "🐛", - "apply": "🐛" - }, - { - "label": ":ant:", - "detail": "emoji", - "info": "🐜", - "apply": "🐜" - }, - { - "label": ":honeybee:", - "detail": "emoji", - "info": "🐝", - "apply": "🐝" - }, - { - "label": ":beetle:", - "detail": "emoji", - "info": "🪲", - "apply": "🪲" - }, - { - "label": ":lady-beetle:", - "detail": "emoji", - "info": "🐞", - "apply": "🐞" - }, - { - "label": ":cricket:", - "detail": "emoji", - "info": "🦗", - "apply": "🦗" - }, - { - "label": ":cockroach:", - "detail": "emoji", - "info": "🪳", - "apply": "🪳" - }, - { - "label": ":spider:", - "detail": "emoji", - "info": "🕷️", - "apply": "🕷️" - }, - { - "label": ":spider-web:", - "detail": "emoji", - "info": "🕸️", - "apply": "🕸️" - }, - { - "label": ":scorpion:", - "detail": "emoji", - "info": "🦂", - "apply": "🦂" - }, - { - "label": ":mosquito:", - "detail": "emoji", - "info": "🦟", - "apply": "🦟" - }, - { - "label": ":fly:", - "detail": "emoji", - "info": "🪰", - "apply": "🪰" - }, - { - "label": ":worm:", - "detail": "emoji", - "info": "🪱", - "apply": "🪱" - }, - { - "label": ":microbe:", - "detail": "emoji", - "info": "🦠", - "apply": "🦠" - }, - { - "label": ":bouquet:", - "detail": "emoji", - "info": "💐", - "apply": "💐" - }, - { - "label": ":cherry-blossom:", - "detail": "emoji", - "info": "🌸", - "apply": "🌸" - }, - { - "label": ":white-flower:", - "detail": "emoji", - "info": "💮", - "apply": "💮" - }, - { - "label": ":lotus:", - "detail": "emoji", - "info": "🪷", - "apply": "🪷" - }, - { - "label": ":rosette:", - "detail": "emoji", - "info": "🏵️", - "apply": "🏵️" - }, - { - "label": ":rose:", - "detail": "emoji", - "info": "🌹", - "apply": "🌹" - }, - { - "label": ":wilted-flower:", - "detail": "emoji", - "info": "🥀", - "apply": "🥀" - }, - { - "label": ":hibiscus:", - "detail": "emoji", - "info": "🌺", - "apply": "🌺" - }, - { - "label": ":sunflower:", - "detail": "emoji", - "info": "🌻", - "apply": "🌻" - }, - { - "label": ":blossom:", - "detail": "emoji", - "info": "🌼", - "apply": "🌼" - }, - { - "label": ":tulip:", - "detail": "emoji", - "info": "🌷", - "apply": "🌷" - }, - { - "label": ":hyacinth:", - "detail": "emoji", - "info": "🪻", - "apply": "🪻" - }, - { - "label": ":seedling:", - "detail": "emoji", - "info": "🌱", - "apply": "🌱" - }, - { - "label": ":potted-plant:", - "detail": "emoji", - "info": "🪴", - "apply": "🪴" - }, - { - "label": ":evergreen-tree:", - "detail": "emoji", - "info": "🌲", - "apply": "🌲" - }, - { - "label": ":deciduous-tree:", - "detail": "emoji", - "info": "🌳", - "apply": "🌳" - }, - { - "label": ":palm-tree:", - "detail": "emoji", - "info": "🌴", - "apply": "🌴" - }, - { - "label": ":cactus:", - "detail": "emoji", - "info": "🌵", - "apply": "🌵" - }, - { - "label": ":sheaf-of-rice:", - "detail": "emoji", - "info": "🌾", - "apply": "🌾" - }, - { - "label": ":herb:", - "detail": "emoji", - "info": "🌿", - "apply": "🌿" - }, - { - "label": ":shamrock:", - "detail": "emoji", - "info": "☘️", - "apply": "☘️" - }, - { - "label": ":four-leaf-clover:", - "detail": "emoji", - "info": "🍀", - "apply": "🍀" - }, - { - "label": ":maple-leaf:", - "detail": "emoji", - "info": "🍁", - "apply": "🍁" - }, - { - "label": ":fallen-leaf:", - "detail": "emoji", - "info": "🍂", - "apply": "🍂" - }, - { - "label": ":leaf-fluttering-in-wind:", - "detail": "emoji", - "info": "🍃", - "apply": "🍃" - }, - { - "label": ":empty-nest:", - "detail": "emoji", - "info": "🪹", - "apply": "🪹" - }, - { - "label": ":nest-with-eggs:", - "detail": "emoji", - "info": "🪺", - "apply": "🪺" - }, - { - "label": ":mushroom:", - "detail": "emoji", - "info": "🍄", - "apply": "🍄" - }, - { - "label": ":grapes:", - "detail": "emoji", - "info": "🍇", - "apply": "🍇" - }, - { - "label": ":melon:", - "detail": "emoji", - "info": "🍈", - "apply": "🍈" - }, - { - "label": ":watermelon:", - "detail": "emoji", - "info": "🍉", - "apply": "🍉" - }, - { - "label": ":tangerine:", - "detail": "emoji", - "info": "🍊", - "apply": "🍊" - }, - { - "label": ":lemon:", - "detail": "emoji", - "info": "🍋", - "apply": "🍋" - }, - { - "label": ":banana:", - "detail": "emoji", - "info": "🍌", - "apply": "🍌" - }, - { - "label": ":pineapple:", - "detail": "emoji", - "info": "🍍", - "apply": "🍍" - }, - { - "label": ":mango:", - "detail": "emoji", - "info": "🥭", - "apply": "🥭" - }, - { - "label": ":red-apple:", - "detail": "emoji", - "info": "🍎", - "apply": "🍎" - }, - { - "label": ":green-apple:", - "detail": "emoji", - "info": "🍏", - "apply": "🍏" - }, - { - "label": ":pear:", - "detail": "emoji", - "info": "🍐", - "apply": "🍐" - }, - { - "label": ":peach:", - "detail": "emoji", - "info": "🍑", - "apply": "🍑" - }, - { - "label": ":cherries:", - "detail": "emoji", - "info": "🍒", - "apply": "🍒" - }, - { - "label": ":strawberry:", - "detail": "emoji", - "info": "🍓", - "apply": "🍓" - }, - { - "label": ":blueberries:", - "detail": "emoji", - "info": "🫐", - "apply": "🫐" - }, - { - "label": ":kiwi-fruit:", - "detail": "emoji", - "info": "🥝", - "apply": "🥝" - }, - { - "label": ":tomato:", - "detail": "emoji", - "info": "🍅", - "apply": "🍅" - }, - { - "label": ":olive:", - "detail": "emoji", - "info": "🫒", - "apply": "🫒" - }, - { - "label": ":coconut:", - "detail": "emoji", - "info": "🥥", - "apply": "🥥" - }, - { - "label": ":avocado:", - "detail": "emoji", - "info": "🥑", - "apply": "🥑" - }, - { - "label": ":eggplant:", - "detail": "emoji", - "info": "🍆", - "apply": "🍆" - }, - { - "label": ":potato:", - "detail": "emoji", - "info": "🥔", - "apply": "🥔" - }, - { - "label": ":carrot:", - "detail": "emoji", - "info": "🥕", - "apply": "🥕" - }, - { - "label": ":ear-of-corn:", - "detail": "emoji", - "info": "🌽", - "apply": "🌽" - }, - { - "label": ":hot-pepper:", - "detail": "emoji", - "info": "🌶️", - "apply": "🌶️" - }, - { - "label": ":bell-pepper:", - "detail": "emoji", - "info": "🫑", - "apply": "🫑" - }, - { - "label": ":cucumber:", - "detail": "emoji", - "info": "🥒", - "apply": "🥒" - }, - { - "label": ":leafy-green:", - "detail": "emoji", - "info": "🥬", - "apply": "🥬" - }, - { - "label": ":broccoli:", - "detail": "emoji", - "info": "🥦", - "apply": "🥦" - }, - { - "label": ":garlic:", - "detail": "emoji", - "info": "🧄", - "apply": "🧄" - }, - { - "label": ":onion:", - "detail": "emoji", - "info": "🧅", - "apply": "🧅" - }, - { - "label": ":peanuts:", - "detail": "emoji", - "info": "🥜", - "apply": "🥜" - }, - { - "label": ":beans:", - "detail": "emoji", - "info": "🫘", - "apply": "🫘" - }, - { - "label": ":chestnut:", - "detail": "emoji", - "info": "🌰", - "apply": "🌰" - }, - { - "label": ":ginger-root:", - "detail": "emoji", - "info": "🫚", - "apply": "🫚" - }, - { - "label": ":pea-pod:", - "detail": "emoji", - "info": "🫛", - "apply": "🫛" - }, - { - "label": ":bread:", - "detail": "emoji", - "info": "🍞", - "apply": "🍞" - }, - { - "label": ":croissant:", - "detail": "emoji", - "info": "🥐", - "apply": "🥐" - }, - { - "label": ":baguette-bread:", - "detail": "emoji", - "info": "🥖", - "apply": "🥖" - }, - { - "label": ":flatbread:", - "detail": "emoji", - "info": "🫓", - "apply": "🫓" - }, - { - "label": ":pretzel:", - "detail": "emoji", - "info": "🥨", - "apply": "🥨" - }, - { - "label": ":bagel:", - "detail": "emoji", - "info": "🥯", - "apply": "🥯" - }, - { - "label": ":pancakes:", - "detail": "emoji", - "info": "🥞", - "apply": "🥞" - }, - { - "label": ":waffle:", - "detail": "emoji", - "info": "🧇", - "apply": "🧇" - }, - { - "label": ":cheese-wedge:", - "detail": "emoji", - "info": "🧀", - "apply": "🧀" - }, - { - "label": ":meat-on-bone:", - "detail": "emoji", - "info": "🍖", - "apply": "🍖" - }, - { - "label": ":poultry-leg:", - "detail": "emoji", - "info": "🍗", - "apply": "🍗" - }, - { - "label": ":cut-of-meat:", - "detail": "emoji", - "info": "🥩", - "apply": "🥩" - }, - { - "label": ":bacon:", - "detail": "emoji", - "info": "🥓", - "apply": "🥓" - }, - { - "label": ":hamburger:", - "detail": "emoji", - "info": "🍔", - "apply": "🍔" - }, - { - "label": ":french-fries:", - "detail": "emoji", - "info": "🍟", - "apply": "🍟" - }, - { - "label": ":pizza:", - "detail": "emoji", - "info": "🍕", - "apply": "🍕" - }, - { - "label": ":hot-dog:", - "detail": "emoji", - "info": "🌭", - "apply": "🌭" - }, - { - "label": ":sandwich:", - "detail": "emoji", - "info": "🥪", - "apply": "🥪" - }, - { - "label": ":taco:", - "detail": "emoji", - "info": "🌮", - "apply": "🌮" - }, - { - "label": ":burrito:", - "detail": "emoji", - "info": "🌯", - "apply": "🌯" - }, - { - "label": ":tamale:", - "detail": "emoji", - "info": "🫔", - "apply": "🫔" - }, - { - "label": ":stuffed-flatbread:", - "detail": "emoji", - "info": "🥙", - "apply": "🥙" - }, - { - "label": ":falafel:", - "detail": "emoji", - "info": "🧆", - "apply": "🧆" - }, - { - "label": ":egg:", - "detail": "emoji", - "info": "🥚", - "apply": "🥚" - }, - { - "label": ":cooking:", - "detail": "emoji", - "info": "🍳", - "apply": "🍳" - }, - { - "label": ":shallow-pan-of-food:", - "detail": "emoji", - "info": "🥘", - "apply": "🥘" - }, - { - "label": ":pot-of-food:", - "detail": "emoji", - "info": "🍲", - "apply": "🍲" - }, - { - "label": ":fondue:", - "detail": "emoji", - "info": "🫕", - "apply": "🫕" - }, - { - "label": ":bowl-with-spoon:", - "detail": "emoji", - "info": "🥣", - "apply": "🥣" - }, - { - "label": ":green-salad:", - "detail": "emoji", - "info": "🥗", - "apply": "🥗" - }, - { - "label": ":popcorn:", - "detail": "emoji", - "info": "🍿", - "apply": "🍿" - }, - { - "label": ":butter:", - "detail": "emoji", - "info": "🧈", - "apply": "🧈" - }, - { - "label": ":salt:", - "detail": "emoji", - "info": "🧂", - "apply": "🧂" - }, - { - "label": ":canned-food:", - "detail": "emoji", - "info": "🥫", - "apply": "🥫" - }, - { - "label": ":bento-box:", - "detail": "emoji", - "info": "🍱", - "apply": "🍱" - }, - { - "label": ":rice-cracker:", - "detail": "emoji", - "info": "🍘", - "apply": "🍘" - }, - { - "label": ":rice-ball:", - "detail": "emoji", - "info": "🍙", - "apply": "🍙" - }, - { - "label": ":cooked-rice:", - "detail": "emoji", - "info": "🍚", - "apply": "🍚" - }, - { - "label": ":curry-rice:", - "detail": "emoji", - "info": "🍛", - "apply": "🍛" - }, - { - "label": ":steaming-bowl:", - "detail": "emoji", - "info": "🍜", - "apply": "🍜" - }, - { - "label": ":spaghetti:", - "detail": "emoji", - "info": "🍝", - "apply": "🍝" - }, - { - "label": ":roasted-sweet-potato:", - "detail": "emoji", - "info": "🍠", - "apply": "🍠" - }, - { - "label": ":oden:", - "detail": "emoji", - "info": "🍢", - "apply": "🍢" - }, - { - "label": ":sushi:", - "detail": "emoji", - "info": "🍣", - "apply": "🍣" - }, - { - "label": ":fried-shrimp:", - "detail": "emoji", - "info": "🍤", - "apply": "🍤" - }, - { - "label": ":fish-cake-with-swirl:", - "detail": "emoji", - "info": "🍥", - "apply": "🍥" - }, - { - "label": ":moon-cake:", - "detail": "emoji", - "info": "🥮", - "apply": "🥮" - }, - { - "label": ":dango:", - "detail": "emoji", - "info": "🍡", - "apply": "🍡" - }, - { - "label": ":dumpling:", - "detail": "emoji", - "info": "🥟", - "apply": "🥟" - }, - { - "label": ":fortune-cookie:", - "detail": "emoji", - "info": "🥠", - "apply": "🥠" - }, - { - "label": ":takeout-box:", - "detail": "emoji", - "info": "🥡", - "apply": "🥡" - }, - { - "label": ":crab:", - "detail": "emoji", - "info": "🦀", - "apply": "🦀" - }, - { - "label": ":lobster:", - "detail": "emoji", - "info": "🦞", - "apply": "🦞" - }, - { - "label": ":shrimp:", - "detail": "emoji", - "info": "🦐", - "apply": "🦐" - }, - { - "label": ":squid:", - "detail": "emoji", - "info": "🦑", - "apply": "🦑" - }, - { - "label": ":oyster:", - "detail": "emoji", - "info": "🦪", - "apply": "🦪" - }, - { - "label": ":soft-ice-cream:", - "detail": "emoji", - "info": "🍦", - "apply": "🍦" - }, - { - "label": ":shaved-ice:", - "detail": "emoji", - "info": "🍧", - "apply": "🍧" - }, - { - "label": ":ice-cream:", - "detail": "emoji", - "info": "🍨", - "apply": "🍨" - }, - { - "label": ":doughnut:", - "detail": "emoji", - "info": "🍩", - "apply": "🍩" - }, - { - "label": ":cookie:", - "detail": "emoji", - "info": "🍪", - "apply": "🍪" - }, - { - "label": ":birthday-cake:", - "detail": "emoji", - "info": "🎂", - "apply": "🎂" - }, - { - "label": ":shortcake:", - "detail": "emoji", - "info": "🍰", - "apply": "🍰" - }, - { - "label": ":cupcake:", - "detail": "emoji", - "info": "🧁", - "apply": "🧁" - }, - { - "label": ":pie:", - "detail": "emoji", - "info": "🥧", - "apply": "🥧" - }, - { - "label": ":chocolate-bar:", - "detail": "emoji", - "info": "🍫", - "apply": "🍫" - }, - { - "label": ":candy:", - "detail": "emoji", - "info": "🍬", - "apply": "🍬" - }, - { - "label": ":lollipop:", - "detail": "emoji", - "info": "🍭", - "apply": "🍭" - }, - { - "label": ":custard:", - "detail": "emoji", - "info": "🍮", - "apply": "🍮" - }, - { - "label": ":honey-pot:", - "detail": "emoji", - "info": "🍯", - "apply": "🍯" - }, - { - "label": ":baby-bottle:", - "detail": "emoji", - "info": "🍼", - "apply": "🍼" - }, - { - "label": ":glass-of-milk:", - "detail": "emoji", - "info": "🥛", - "apply": "🥛" - }, - { - "label": ":hot-beverage:", - "detail": "emoji", - "info": "☕", - "apply": "☕" - }, - { - "label": ":teapot:", - "detail": "emoji", - "info": "🫖", - "apply": "🫖" - }, - { - "label": ":teacup-without-handle:", - "detail": "emoji", - "info": "🍵", - "apply": "🍵" - }, - { - "label": ":sake:", - "detail": "emoji", - "info": "🍶", - "apply": "🍶" - }, - { - "label": ":bottle-with-popping-cork:", - "detail": "emoji", - "info": "🍾", - "apply": "🍾" - }, - { - "label": ":wine-glass:", - "detail": "emoji", - "info": "🍷", - "apply": "🍷" - }, - { - "label": ":cocktail-glass:", - "detail": "emoji", - "info": "🍸", - "apply": "🍸" - }, - { - "label": ":tropical-drink:", - "detail": "emoji", - "info": "🍹", - "apply": "🍹" - }, - { - "label": ":beer-mug:", - "detail": "emoji", - "info": "🍺", - "apply": "🍺" - }, - { - "label": ":clinking-beer-mugs:", - "detail": "emoji", - "info": "🍻", - "apply": "🍻" - }, - { - "label": ":clinking-glasses:", - "detail": "emoji", - "info": "🥂", - "apply": "🥂" - }, - { - "label": ":tumbler-glass:", - "detail": "emoji", - "info": "🥃", - "apply": "🥃" - }, - { - "label": ":pouring-liquid:", - "detail": "emoji", - "info": "🫗", - "apply": "🫗" - }, - { - "label": ":cup-with-straw:", - "detail": "emoji", - "info": "🥤", - "apply": "🥤" - }, - { - "label": ":bubble-tea:", - "detail": "emoji", - "info": "🧋", - "apply": "🧋" - }, - { - "label": ":beverage-box:", - "detail": "emoji", - "info": "🧃", - "apply": "🧃" - }, - { - "label": ":mate:", - "detail": "emoji", - "info": "🧉", - "apply": "🧉" - }, - { - "label": ":ice:", - "detail": "emoji", - "info": "🧊", - "apply": "🧊" - }, - { - "label": ":chopsticks:", - "detail": "emoji", - "info": "🥢", - "apply": "🥢" - }, - { - "label": ":fork-and-knife-with-plate:", - "detail": "emoji", - "info": "🍽️", - "apply": "🍽️" - }, - { - "label": ":fork-and-knife:", - "detail": "emoji", - "info": "🍴", - "apply": "🍴" - }, - { - "label": ":spoon:", - "detail": "emoji", - "info": "🥄", - "apply": "🥄" - }, - { - "label": ":kitchen-knife:", - "detail": "emoji", - "info": "🔪", - "apply": "🔪" - }, - { - "label": ":jar:", - "detail": "emoji", - "info": "🫙", - "apply": "🫙" - }, - { - "label": ":amphora:", - "detail": "emoji", - "info": "🏺", - "apply": "🏺" - }, - { - "label": ":globe-showing-europe-africa:", - "detail": "emoji", - "info": "🌍", - "apply": "🌍" - }, - { - "label": ":globe-showing-americas:", - "detail": "emoji", - "info": "🌎", - "apply": "🌎" - }, - { - "label": ":globe-showing-asia-australia:", - "detail": "emoji", - "info": "🌏", - "apply": "🌏" - }, - { - "label": ":globe-with-meridians:", - "detail": "emoji", - "info": "🌐", - "apply": "🌐" - }, - { - "label": ":world-map:", - "detail": "emoji", - "info": "🗺️", - "apply": "🗺️" - }, - { - "label": ":map-of-japan:", - "detail": "emoji", - "info": "🗾", - "apply": "🗾" - }, - { - "label": ":compass:", - "detail": "emoji", - "info": "🧭", - "apply": "🧭" - }, - { - "label": ":snow-capped-mountain:", - "detail": "emoji", - "info": "🏔️", - "apply": "🏔️" - }, - { - "label": ":mountain:", - "detail": "emoji", - "info": "⛰️", - "apply": "⛰️" - }, - { - "label": ":volcano:", - "detail": "emoji", - "info": "🌋", - "apply": "🌋" - }, - { - "label": ":mount-fuji:", - "detail": "emoji", - "info": "🗻", - "apply": "🗻" - }, - { - "label": ":camping:", - "detail": "emoji", - "info": "🏕️", - "apply": "🏕️" - }, - { - "label": ":beach-with-umbrella:", - "detail": "emoji", - "info": "🏖️", - "apply": "🏖️" - }, - { - "label": ":desert:", - "detail": "emoji", - "info": "🏜️", - "apply": "🏜️" - }, - { - "label": ":desert-island:", - "detail": "emoji", - "info": "🏝️", - "apply": "🏝️" - }, - { - "label": ":national-park:", - "detail": "emoji", - "info": "🏞️", - "apply": "🏞️" - }, - { - "label": ":stadium:", - "detail": "emoji", - "info": "🏟️", - "apply": "🏟️" - }, - { - "label": ":classical-building:", - "detail": "emoji", - "info": "🏛️", - "apply": "🏛️" - }, - { - "label": ":building-construction:", - "detail": "emoji", - "info": "🏗️", - "apply": "🏗️" - }, - { - "label": ":brick:", - "detail": "emoji", - "info": "🧱", - "apply": "🧱" - }, - { - "label": ":rock:", - "detail": "emoji", - "info": "🪨", - "apply": "🪨" - }, - { - "label": ":wood:", - "detail": "emoji", - "info": "🪵", - "apply": "🪵" - }, - { - "label": ":hut:", - "detail": "emoji", - "info": "🛖", - "apply": "🛖" - }, - { - "label": ":houses:", - "detail": "emoji", - "info": "🏘️", - "apply": "🏘️" - }, - { - "label": ":derelict-house:", - "detail": "emoji", - "info": "🏚️", - "apply": "🏚️" - }, - { - "label": ":house:", - "detail": "emoji", - "info": "🏠", - "apply": "🏠" - }, - { - "label": ":house-with-garden:", - "detail": "emoji", - "info": "🏡", - "apply": "🏡" - }, - { - "label": ":office-building:", - "detail": "emoji", - "info": "🏢", - "apply": "🏢" - }, - { - "label": ":japanese-post-office:", - "detail": "emoji", - "info": "🏣", - "apply": "🏣" - }, - { - "label": ":post-office:", - "detail": "emoji", - "info": "🏤", - "apply": "🏤" - }, - { - "label": ":hospital:", - "detail": "emoji", - "info": "🏥", - "apply": "🏥" - }, - { - "label": ":bank:", - "detail": "emoji", - "info": "🏦", - "apply": "🏦" - }, - { - "label": ":hotel:", - "detail": "emoji", - "info": "🏨", - "apply": "🏨" - }, - { - "label": ":love-hotel:", - "detail": "emoji", - "info": "🏩", - "apply": "🏩" - }, - { - "label": ":convenience-store:", - "detail": "emoji", - "info": "🏪", - "apply": "🏪" - }, - { - "label": ":school:", - "detail": "emoji", - "info": "🏫", - "apply": "🏫" - }, - { - "label": ":department-store:", - "detail": "emoji", - "info": "🏬", - "apply": "🏬" - }, - { - "label": ":factory:", - "detail": "emoji", - "info": "🏭", - "apply": "🏭" - }, - { - "label": ":japanese-castle:", - "detail": "emoji", - "info": "🏯", - "apply": "🏯" - }, - { - "label": ":castle:", - "detail": "emoji", - "info": "🏰", - "apply": "🏰" - }, - { - "label": ":wedding:", - "detail": "emoji", - "info": "💒", - "apply": "💒" - }, - { - "label": ":tokyo-tower:", - "detail": "emoji", - "info": "🗼", - "apply": "🗼" - }, - { - "label": ":statue-of-liberty:", - "detail": "emoji", - "info": "🗽", - "apply": "🗽" - }, - { - "label": ":church:", - "detail": "emoji", - "info": "⛪", - "apply": "⛪" - }, - { - "label": ":mosque:", - "detail": "emoji", - "info": "🕌", - "apply": "🕌" - }, - { - "label": ":hindu-temple:", - "detail": "emoji", - "info": "🛕", - "apply": "🛕" - }, - { - "label": ":synagogue:", - "detail": "emoji", - "info": "🕍", - "apply": "🕍" - }, - { - "label": ":shinto-shrine:", - "detail": "emoji", - "info": "⛩️", - "apply": "⛩️" - }, - { - "label": ":kaaba:", - "detail": "emoji", - "info": "🕋", - "apply": "🕋" - }, - { - "label": ":fountain:", - "detail": "emoji", - "info": "⛲", - "apply": "⛲" - }, - { - "label": ":tent:", - "detail": "emoji", - "info": "⛺", - "apply": "⛺" - }, - { - "label": ":foggy:", - "detail": "emoji", - "info": "🌁", - "apply": "🌁" - }, - { - "label": ":night-with-stars:", - "detail": "emoji", - "info": "🌃", - "apply": "🌃" - }, - { - "label": ":cityscape:", - "detail": "emoji", - "info": "🏙️", - "apply": "🏙️" - }, - { - "label": ":sunrise-over-mountains:", - "detail": "emoji", - "info": "🌄", - "apply": "🌄" - }, - { - "label": ":sunrise:", - "detail": "emoji", - "info": "🌅", - "apply": "🌅" - }, - { - "label": ":cityscape-at-dusk:", - "detail": "emoji", - "info": "🌆", - "apply": "🌆" - }, - { - "label": ":sunset:", - "detail": "emoji", - "info": "🌇", - "apply": "🌇" - }, - { - "label": ":bridge-at-night:", - "detail": "emoji", - "info": "🌉", - "apply": "🌉" - }, - { - "label": ":hot-springs:", - "detail": "emoji", - "info": "♨️", - "apply": "♨️" - }, - { - "label": ":carousel-horse:", - "detail": "emoji", - "info": "🎠", - "apply": "🎠" - }, - { - "label": ":playground-slide:", - "detail": "emoji", - "info": "🛝", - "apply": "🛝" - }, - { - "label": ":ferris-wheel:", - "detail": "emoji", - "info": "🎡", - "apply": "🎡" - }, - { - "label": ":roller-coaster:", - "detail": "emoji", - "info": "🎢", - "apply": "🎢" - }, - { - "label": ":barber-pole:", - "detail": "emoji", - "info": "💈", - "apply": "💈" - }, - { - "label": ":circus-tent:", - "detail": "emoji", - "info": "🎪", - "apply": "🎪" - }, - { - "label": ":locomotive:", - "detail": "emoji", - "info": "🚂", - "apply": "🚂" - }, - { - "label": ":railway-car:", - "detail": "emoji", - "info": "🚃", - "apply": "🚃" - }, - { - "label": ":high-speed-train:", - "detail": "emoji", - "info": "🚄", - "apply": "🚄" - }, - { - "label": ":bullet-train:", - "detail": "emoji", - "info": "🚅", - "apply": "🚅" - }, - { - "label": ":train:", - "detail": "emoji", - "info": "🚆", - "apply": "🚆" - }, - { - "label": ":metro:", - "detail": "emoji", - "info": "🚇", - "apply": "🚇" - }, - { - "label": ":light-rail:", - "detail": "emoji", - "info": "🚈", - "apply": "🚈" - }, - { - "label": ":station:", - "detail": "emoji", - "info": "🚉", - "apply": "🚉" - }, - { - "label": ":tram:", - "detail": "emoji", - "info": "🚊", - "apply": "🚊" - }, - { - "label": ":monorail:", - "detail": "emoji", - "info": "🚝", - "apply": "🚝" - }, - { - "label": ":mountain-railway:", - "detail": "emoji", - "info": "🚞", - "apply": "🚞" - }, - { - "label": ":tram-car:", - "detail": "emoji", - "info": "🚋", - "apply": "🚋" - }, - { - "label": ":bus:", - "detail": "emoji", - "info": "🚌", - "apply": "🚌" - }, - { - "label": ":oncoming-bus:", - "detail": "emoji", - "info": "🚍", - "apply": "🚍" - }, - { - "label": ":trolleybus:", - "detail": "emoji", - "info": "🚎", - "apply": "🚎" - }, - { - "label": ":minibus:", - "detail": "emoji", - "info": "🚐", - "apply": "🚐" - }, - { - "label": ":ambulance:", - "detail": "emoji", - "info": "🚑", - "apply": "🚑" - }, - { - "label": ":fire-engine:", - "detail": "emoji", - "info": "🚒", - "apply": "🚒" - }, - { - "label": ":police-car:", - "detail": "emoji", - "info": "🚓", - "apply": "🚓" - }, - { - "label": ":oncoming-police-car:", - "detail": "emoji", - "info": "🚔", - "apply": "🚔" - }, - { - "label": ":taxi:", - "detail": "emoji", - "info": "🚕", - "apply": "🚕" - }, - { - "label": ":oncoming-taxi:", - "detail": "emoji", - "info": "🚖", - "apply": "🚖" - }, - { - "label": ":automobile:", - "detail": "emoji", - "info": "🚗", - "apply": "🚗" - }, - { - "label": ":oncoming-automobile:", - "detail": "emoji", - "info": "🚘", - "apply": "🚘" - }, - { - "label": ":sport-utility-vehicle:", - "detail": "emoji", - "info": "🚙", - "apply": "🚙" - }, - { - "label": ":pickup-truck:", - "detail": "emoji", - "info": "🛻", - "apply": "🛻" - }, - { - "label": ":delivery-truck:", - "detail": "emoji", - "info": "🚚", - "apply": "🚚" - }, - { - "label": ":articulated-lorry:", - "detail": "emoji", - "info": "🚛", - "apply": "🚛" - }, - { - "label": ":tractor:", - "detail": "emoji", - "info": "🚜", - "apply": "🚜" - }, - { - "label": ":racing-car:", - "detail": "emoji", - "info": "🏎️", - "apply": "🏎️" - }, - { - "label": ":motorcycle:", - "detail": "emoji", - "info": "🏍️", - "apply": "🏍️" - }, - { - "label": ":motor-scooter:", - "detail": "emoji", - "info": "🛵", - "apply": "🛵" - }, - { - "label": ":manual-wheelchair:", - "detail": "emoji", - "info": "🦽", - "apply": "🦽" - }, - { - "label": ":motorized-wheelchair:", - "detail": "emoji", - "info": "🦼", - "apply": "🦼" - }, - { - "label": ":auto-rickshaw:", - "detail": "emoji", - "info": "🛺", - "apply": "🛺" - }, - { - "label": ":bicycle:", - "detail": "emoji", - "info": "🚲", - "apply": "🚲" - }, - { - "label": ":kick-scooter:", - "detail": "emoji", - "info": "🛴", - "apply": "🛴" - }, - { - "label": ":skateboard:", - "detail": "emoji", - "info": "🛹", - "apply": "🛹" - }, - { - "label": ":roller-skate:", - "detail": "emoji", - "info": "🛼", - "apply": "🛼" - }, - { - "label": ":bus-stop:", - "detail": "emoji", - "info": "🚏", - "apply": "🚏" - }, - { - "label": ":motorway:", - "detail": "emoji", - "info": "🛣️", - "apply": "🛣️" - }, - { - "label": ":railway-track:", - "detail": "emoji", - "info": "🛤️", - "apply": "🛤️" - }, - { - "label": ":oil-drum:", - "detail": "emoji", - "info": "🛢️", - "apply": "🛢️" - }, - { - "label": ":fuel-pump:", - "detail": "emoji", - "info": "⛽", - "apply": "⛽" - }, - { - "label": ":wheel:", - "detail": "emoji", - "info": "🛞", - "apply": "🛞" - }, - { - "label": ":police-car-light:", - "detail": "emoji", - "info": "🚨", - "apply": "🚨" - }, - { - "label": ":horizontal-traffic-light:", - "detail": "emoji", - "info": "🚥", - "apply": "🚥" - }, - { - "label": ":vertical-traffic-light:", - "detail": "emoji", - "info": "🚦", - "apply": "🚦" - }, - { - "label": ":stop-sign:", - "detail": "emoji", - "info": "🛑", - "apply": "🛑" - }, - { - "label": ":construction:", - "detail": "emoji", - "info": "🚧", - "apply": "🚧" - }, - { - "label": ":anchor:", - "detail": "emoji", - "info": "⚓", - "apply": "⚓" - }, - { - "label": ":ring-buoy:", - "detail": "emoji", - "info": "🛟", - "apply": "🛟" - }, - { - "label": ":sailboat:", - "detail": "emoji", - "info": "⛵", - "apply": "⛵" - }, - { - "label": ":canoe:", - "detail": "emoji", - "info": "🛶", - "apply": "🛶" - }, - { - "label": ":speedboat:", - "detail": "emoji", - "info": "🚤", - "apply": "🚤" - }, - { - "label": ":passenger-ship:", - "detail": "emoji", - "info": "🛳️", - "apply": "🛳️" - }, - { - "label": ":ferry:", - "detail": "emoji", - "info": "⛴️", - "apply": "⛴️" - }, - { - "label": ":motor-boat:", - "detail": "emoji", - "info": "🛥️", - "apply": "🛥️" - }, - { - "label": ":ship:", - "detail": "emoji", - "info": "🚢", - "apply": "🚢" - }, - { - "label": ":airplane:", - "detail": "emoji", - "info": "✈️", - "apply": "✈️" - }, - { - "label": ":small-airplane:", - "detail": "emoji", - "info": "🛩️", - "apply": "🛩️" - }, - { - "label": ":airplane-departure:", - "detail": "emoji", - "info": "🛫", - "apply": "🛫" - }, - { - "label": ":airplane-arrival:", - "detail": "emoji", - "info": "🛬", - "apply": "🛬" - }, - { - "label": ":parachute:", - "detail": "emoji", - "info": "🪂", - "apply": "🪂" - }, - { - "label": ":seat:", - "detail": "emoji", - "info": "💺", - "apply": "💺" - }, - { - "label": ":helicopter:", - "detail": "emoji", - "info": "🚁", - "apply": "🚁" - }, - { - "label": ":suspension-railway:", - "detail": "emoji", - "info": "🚟", - "apply": "🚟" - }, - { - "label": ":mountain-cableway:", - "detail": "emoji", - "info": "🚠", - "apply": "🚠" - }, - { - "label": ":aerial-tramway:", - "detail": "emoji", - "info": "🚡", - "apply": "🚡" - }, - { - "label": ":satellite:", - "detail": "emoji", - "info": "🛰️", - "apply": "🛰️" - }, - { - "label": ":rocket:", - "detail": "emoji", - "info": "🚀", - "apply": "🚀" - }, - { - "label": ":flying-saucer:", - "detail": "emoji", - "info": "🛸", - "apply": "🛸" - }, - { - "label": ":bellhop-bell:", - "detail": "emoji", - "info": "🛎️", - "apply": "🛎️" - }, - { - "label": ":luggage:", - "detail": "emoji", - "info": "🧳", - "apply": "🧳" - }, - { - "label": ":hourglass-done:", - "detail": "emoji", - "info": "⌛", - "apply": "⌛" - }, - { - "label": ":hourglass-not-done:", - "detail": "emoji", - "info": "⏳", - "apply": "⏳" - }, - { - "label": ":watch:", - "detail": "emoji", - "info": "⌚", - "apply": "⌚" - }, - { - "label": ":alarm-clock:", - "detail": "emoji", - "info": "⏰", - "apply": "⏰" - }, - { - "label": ":stopwatch:", - "detail": "emoji", - "info": "⏱️", - "apply": "⏱️" - }, - { - "label": ":timer-clock:", - "detail": "emoji", - "info": "⏲️", - "apply": "⏲️" - }, - { - "label": ":mantelpiece-clock:", - "detail": "emoji", - "info": "🕰️", - "apply": "🕰️" - }, - { - "label": ":twelve-oclock:", - "detail": "emoji", - "info": "🕛", - "apply": "🕛" - }, - { - "label": ":twelve-thirty:", - "detail": "emoji", - "info": "🕧", - "apply": "🕧" - }, - { - "label": ":one-oclock:", - "detail": "emoji", - "info": "🕐", - "apply": "🕐" - }, - { - "label": ":one-thirty:", - "detail": "emoji", - "info": "🕜", - "apply": "🕜" - }, - { - "label": ":two-oclock:", - "detail": "emoji", - "info": "🕑", - "apply": "🕑" - }, - { - "label": ":two-thirty:", - "detail": "emoji", - "info": "🕝", - "apply": "🕝" - }, - { - "label": ":three-oclock:", - "detail": "emoji", - "info": "🕒", - "apply": "🕒" - }, - { - "label": ":three-thirty:", - "detail": "emoji", - "info": "🕞", - "apply": "🕞" - }, - { - "label": ":four-oclock:", - "detail": "emoji", - "info": "🕓", - "apply": "🕓" - }, - { - "label": ":four-thirty:", - "detail": "emoji", - "info": "🕟", - "apply": "🕟" - }, - { - "label": ":five-oclock:", - "detail": "emoji", - "info": "🕔", - "apply": "🕔" - }, - { - "label": ":five-thirty:", - "detail": "emoji", - "info": "🕠", - "apply": "🕠" - }, - { - "label": ":six-oclock:", - "detail": "emoji", - "info": "🕕", - "apply": "🕕" - }, - { - "label": ":six-thirty:", - "detail": "emoji", - "info": "🕡", - "apply": "🕡" - }, - { - "label": ":seven-oclock:", - "detail": "emoji", - "info": "🕖", - "apply": "🕖" - }, - { - "label": ":seven-thirty:", - "detail": "emoji", - "info": "🕢", - "apply": "🕢" - }, - { - "label": ":eight-oclock:", - "detail": "emoji", - "info": "🕗", - "apply": "🕗" - }, - { - "label": ":eight-thirty:", - "detail": "emoji", - "info": "🕣", - "apply": "🕣" - }, - { - "label": ":nine-oclock:", - "detail": "emoji", - "info": "🕘", - "apply": "🕘" - }, - { - "label": ":nine-thirty:", - "detail": "emoji", - "info": "🕤", - "apply": "🕤" - }, - { - "label": ":ten-oclock:", - "detail": "emoji", - "info": "🕙", - "apply": "🕙" - }, - { - "label": ":ten-thirty:", - "detail": "emoji", - "info": "🕥", - "apply": "🕥" - }, - { - "label": ":eleven-oclock:", - "detail": "emoji", - "info": "🕚", - "apply": "🕚" - }, - { - "label": ":eleven-thirty:", - "detail": "emoji", - "info": "🕦", - "apply": "🕦" - }, - { - "label": ":new-moon:", - "detail": "emoji", - "info": "🌑", - "apply": "🌑" - }, - { - "label": ":waxing-crescent-moon:", - "detail": "emoji", - "info": "🌒", - "apply": "🌒" - }, - { - "label": ":first-quarter-moon:", - "detail": "emoji", - "info": "🌓", - "apply": "🌓" - }, - { - "label": ":waxing-gibbous-moon:", - "detail": "emoji", - "info": "🌔", - "apply": "🌔" - }, - { - "label": ":full-moon:", - "detail": "emoji", - "info": "🌕", - "apply": "🌕" - }, - { - "label": ":waning-gibbous-moon:", - "detail": "emoji", - "info": "🌖", - "apply": "🌖" - }, - { - "label": ":last-quarter-moon:", - "detail": "emoji", - "info": "🌗", - "apply": "🌗" - }, - { - "label": ":waning-crescent-moon:", - "detail": "emoji", - "info": "🌘", - "apply": "🌘" - }, - { - "label": ":crescent-moon:", - "detail": "emoji", - "info": "🌙", - "apply": "🌙" - }, - { - "label": ":new-moon-face:", - "detail": "emoji", - "info": "🌚", - "apply": "🌚" - }, - { - "label": ":first-quarter-moon-face:", - "detail": "emoji", - "info": "🌛", - "apply": "🌛" - }, - { - "label": ":last-quarter-moon-face:", - "detail": "emoji", - "info": "🌜", - "apply": "🌜" - }, - { - "label": ":thermometer:", - "detail": "emoji", - "info": "🌡️", - "apply": "🌡️" - }, - { - "label": ":sun:", - "detail": "emoji", - "info": "☀️", - "apply": "☀️" - }, - { - "label": ":full-moon-face:", - "detail": "emoji", - "info": "🌝", - "apply": "🌝" - }, - { - "label": ":sun-with-face:", - "detail": "emoji", - "info": "🌞", - "apply": "🌞" - }, - { - "label": ":ringed-planet:", - "detail": "emoji", - "info": "🪐", - "apply": "🪐" - }, - { - "label": ":star:", - "detail": "emoji", - "info": "⭐", - "apply": "⭐" - }, - { - "label": ":glowing-star:", - "detail": "emoji", - "info": "🌟", - "apply": "🌟" - }, - { - "label": ":shooting-star:", - "detail": "emoji", - "info": "🌠", - "apply": "🌠" - }, - { - "label": ":milky-way:", - "detail": "emoji", - "info": "🌌", - "apply": "🌌" - }, - { - "label": ":cloud:", - "detail": "emoji", - "info": "☁️", - "apply": "☁️" - }, - { - "label": ":sun-behind-cloud:", - "detail": "emoji", - "info": "⛅", - "apply": "⛅" - }, - { - "label": ":cloud-with-lightning-and-rain:", - "detail": "emoji", - "info": "⛈️", - "apply": "⛈️" - }, - { - "label": ":sun-behind-small-cloud:", - "detail": "emoji", - "info": "🌤️", - "apply": "🌤️" - }, - { - "label": ":sun-behind-large-cloud:", - "detail": "emoji", - "info": "🌥️", - "apply": "🌥️" - }, - { - "label": ":sun-behind-rain-cloud:", - "detail": "emoji", - "info": "🌦️", - "apply": "🌦️" - }, - { - "label": ":cloud-with-rain:", - "detail": "emoji", - "info": "🌧️", - "apply": "🌧️" - }, - { - "label": ":cloud-with-snow:", - "detail": "emoji", - "info": "🌨️", - "apply": "🌨️" - }, - { - "label": ":cloud-with-lightning:", - "detail": "emoji", - "info": "🌩️", - "apply": "🌩️" - }, - { - "label": ":tornado:", - "detail": "emoji", - "info": "🌪️", - "apply": "🌪️" - }, - { - "label": ":fog:", - "detail": "emoji", - "info": "🌫️", - "apply": "🌫️" - }, - { - "label": ":wind-face:", - "detail": "emoji", - "info": "🌬️", - "apply": "🌬️" - }, - { - "label": ":cyclone:", - "detail": "emoji", - "info": "🌀", - "apply": "🌀" - }, - { - "label": ":rainbow:", - "detail": "emoji", - "info": "🌈", - "apply": "🌈" - }, - { - "label": ":closed-umbrella:", - "detail": "emoji", - "info": "🌂", - "apply": "🌂" - }, - { - "label": ":umbrella:", - "detail": "emoji", - "info": "☂️", - "apply": "☂️" - }, - { - "label": ":umbrella-with-rain-drops:", - "detail": "emoji", - "info": "☔", - "apply": "☔" - }, - { - "label": ":umbrella-on-ground:", - "detail": "emoji", - "info": "⛱️", - "apply": "⛱️" - }, - { - "label": ":high-voltage:", - "detail": "emoji", - "info": "⚡", - "apply": "⚡" - }, - { - "label": ":snowflake:", - "detail": "emoji", - "info": "❄️", - "apply": "❄️" - }, - { - "label": ":snowman:", - "detail": "emoji", - "info": "☃️", - "apply": "☃️" - }, - { - "label": ":snowman-without-snow:", - "detail": "emoji", - "info": "⛄", - "apply": "⛄" - }, - { - "label": ":comet:", - "detail": "emoji", - "info": "☄️", - "apply": "☄️" - }, - { - "label": ":fire:", - "detail": "emoji", - "info": "🔥", - "apply": "🔥" - }, - { - "label": ":droplet:", - "detail": "emoji", - "info": "💧", - "apply": "💧" - }, - { - "label": ":water-wave:", - "detail": "emoji", - "info": "🌊", - "apply": "🌊" - }, - { - "label": ":jack-o-lantern:", - "detail": "emoji", - "info": "🎃", - "apply": "🎃" - }, - { - "label": ":christmas-tree:", - "detail": "emoji", - "info": "🎄", - "apply": "🎄" - }, - { - "label": ":fireworks:", - "detail": "emoji", - "info": "🎆", - "apply": "🎆" - }, - { - "label": ":sparkler:", - "detail": "emoji", - "info": "🎇", - "apply": "🎇" - }, - { - "label": ":firecracker:", - "detail": "emoji", - "info": "🧨", - "apply": "🧨" - }, - { - "label": ":sparkles:", - "detail": "emoji", - "info": "✨", - "apply": "✨" - }, - { - "label": ":balloon:", - "detail": "emoji", - "info": "🎈", - "apply": "🎈" - }, - { - "label": ":party-popper:", - "detail": "emoji", - "info": "🎉", - "apply": "🎉" - }, - { - "label": ":confetti-ball:", - "detail": "emoji", - "info": "🎊", - "apply": "🎊" - }, - { - "label": ":tanabata-tree:", - "detail": "emoji", - "info": "🎋", - "apply": "🎋" - }, - { - "label": ":pine-decoration:", - "detail": "emoji", - "info": "🎍", - "apply": "🎍" - }, - { - "label": ":japanese-dolls:", - "detail": "emoji", - "info": "🎎", - "apply": "🎎" - }, - { - "label": ":carp-streamer:", - "detail": "emoji", - "info": "🎏", - "apply": "🎏" - }, - { - "label": ":wind-chime:", - "detail": "emoji", - "info": "🎐", - "apply": "🎐" - }, - { - "label": ":moon-viewing-ceremony:", - "detail": "emoji", - "info": "🎑", - "apply": "🎑" - }, - { - "label": ":red-envelope:", - "detail": "emoji", - "info": "🧧", - "apply": "🧧" - }, - { - "label": ":ribbon:", - "detail": "emoji", - "info": "🎀", - "apply": "🎀" - }, - { - "label": ":wrapped-gift:", - "detail": "emoji", - "info": "🎁", - "apply": "🎁" - }, - { - "label": ":reminder-ribbon:", - "detail": "emoji", - "info": "🎗️", - "apply": "🎗️" - }, - { - "label": ":admission-tickets:", - "detail": "emoji", - "info": "🎟️", - "apply": "🎟️" - }, - { - "label": ":ticket:", - "detail": "emoji", - "info": "🎫", - "apply": "🎫" - }, - { - "label": ":military-medal:", - "detail": "emoji", - "info": "🎖️", - "apply": "🎖️" - }, - { - "label": ":trophy:", - "detail": "emoji", - "info": "🏆", - "apply": "🏆" - }, - { - "label": ":sports-medal:", - "detail": "emoji", - "info": "🏅", - "apply": "🏅" - }, - { - "label": ":1st-place-medal:", - "detail": "emoji", - "info": "🥇", - "apply": "🥇" - }, - { - "label": ":2nd-place-medal:", - "detail": "emoji", - "info": "🥈", - "apply": "🥈" - }, - { - "label": ":3rd-place-medal:", - "detail": "emoji", - "info": "🥉", - "apply": "🥉" - }, - { - "label": ":soccer-ball:", - "detail": "emoji", - "info": "⚽", - "apply": "⚽" - }, - { - "label": ":baseball:", - "detail": "emoji", - "info": "⚾", - "apply": "⚾" - }, - { - "label": ":softball:", - "detail": "emoji", - "info": "🥎", - "apply": "🥎" - }, - { - "label": ":basketball:", - "detail": "emoji", - "info": "🏀", - "apply": "🏀" - }, - { - "label": ":volleyball:", - "detail": "emoji", - "info": "🏐", - "apply": "🏐" - }, - { - "label": ":american-football:", - "detail": "emoji", - "info": "🏈", - "apply": "🏈" - }, - { - "label": ":rugby-football:", - "detail": "emoji", - "info": "🏉", - "apply": "🏉" - }, - { - "label": ":tennis:", - "detail": "emoji", - "info": "🎾", - "apply": "🎾" - }, - { - "label": ":flying-disc:", - "detail": "emoji", - "info": "🥏", - "apply": "🥏" - }, - { - "label": ":bowling:", - "detail": "emoji", - "info": "🎳", - "apply": "🎳" - }, - { - "label": ":cricket-game:", - "detail": "emoji", - "info": "🏏", - "apply": "🏏" - }, - { - "label": ":field-hockey:", - "detail": "emoji", - "info": "🏑", - "apply": "🏑" - }, - { - "label": ":ice-hockey:", - "detail": "emoji", - "info": "🏒", - "apply": "🏒" - }, - { - "label": ":lacrosse:", - "detail": "emoji", - "info": "🥍", - "apply": "🥍" - }, - { - "label": ":ping-pong:", - "detail": "emoji", - "info": "🏓", - "apply": "🏓" - }, - { - "label": ":badminton:", - "detail": "emoji", - "info": "🏸", - "apply": "🏸" - }, - { - "label": ":boxing-glove:", - "detail": "emoji", - "info": "🥊", - "apply": "🥊" - }, - { - "label": ":martial-arts-uniform:", - "detail": "emoji", - "info": "🥋", - "apply": "🥋" - }, - { - "label": ":goal-net:", - "detail": "emoji", - "info": "🥅", - "apply": "🥅" - }, - { - "label": ":flag-in-hole:", - "detail": "emoji", - "info": "⛳", - "apply": "⛳" - }, - { - "label": ":ice-skate:", - "detail": "emoji", - "info": "⛸️", - "apply": "⛸️" - }, - { - "label": ":fishing-pole:", - "detail": "emoji", - "info": "🎣", - "apply": "🎣" - }, - { - "label": ":diving-mask:", - "detail": "emoji", - "info": "🤿", - "apply": "🤿" - }, - { - "label": ":running-shirt:", - "detail": "emoji", - "info": "🎽", - "apply": "🎽" - }, - { - "label": ":skis:", - "detail": "emoji", - "info": "🎿", - "apply": "🎿" - }, - { - "label": ":sled:", - "detail": "emoji", - "info": "🛷", - "apply": "🛷" - }, - { - "label": ":curling-stone:", - "detail": "emoji", - "info": "🥌", - "apply": "🥌" - }, - { - "label": ":bullseye:", - "detail": "emoji", - "info": "🎯", - "apply": "🎯" - }, - { - "label": ":yo-yo:", - "detail": "emoji", - "info": "🪀", - "apply": "🪀" - }, - { - "label": ":kite:", - "detail": "emoji", - "info": "🪁", - "apply": "🪁" - }, - { - "label": ":water-pistol:", - "detail": "emoji", - "info": "🔫", - "apply": "🔫" - }, - { - "label": ":pool-8-ball:", - "detail": "emoji", - "info": "🎱", - "apply": "🎱" - }, - { - "label": ":crystal-ball:", - "detail": "emoji", - "info": "🔮", - "apply": "🔮" - }, - { - "label": ":magic-wand:", - "detail": "emoji", - "info": "🪄", - "apply": "🪄" - }, - { - "label": ":video-game:", - "detail": "emoji", - "info": "🎮", - "apply": "🎮" - }, - { - "label": ":joystick:", - "detail": "emoji", - "info": "🕹️", - "apply": "🕹️" - }, - { - "label": ":slot-machine:", - "detail": "emoji", - "info": "🎰", - "apply": "🎰" - }, - { - "label": ":game-die:", - "detail": "emoji", - "info": "🎲", - "apply": "🎲" - }, - { - "label": ":puzzle-piece:", - "detail": "emoji", - "info": "🧩", - "apply": "🧩" - }, - { - "label": ":teddy-bear:", - "detail": "emoji", - "info": "🧸", - "apply": "🧸" - }, - { - "label": ":piñata:", - "detail": "emoji", - "info": "🪅", - "apply": "🪅" - }, - { - "label": ":mirror-ball:", - "detail": "emoji", - "info": "🪩", - "apply": "🪩" - }, - { - "label": ":nesting-dolls:", - "detail": "emoji", - "info": "🪆", - "apply": "🪆" - }, - { - "label": ":spade-suit:", - "detail": "emoji", - "info": "♠️", - "apply": "♠️" - }, - { - "label": ":heart-suit:", - "detail": "emoji", - "info": "♥️", - "apply": "♥️" - }, - { - "label": ":diamond-suit:", - "detail": "emoji", - "info": "♦️", - "apply": "♦️" - }, - { - "label": ":club-suit:", - "detail": "emoji", - "info": "♣️", - "apply": "♣️" - }, - { - "label": ":chess-pawn:", - "detail": "emoji", - "info": "♟️", - "apply": "♟️" - }, - { - "label": ":joker:", - "detail": "emoji", - "info": "🃏", - "apply": "🃏" - }, - { - "label": ":mahjong-red-dragon:", - "detail": "emoji", - "info": "🀄", - "apply": "🀄" - }, - { - "label": ":flower-playing-cards:", - "detail": "emoji", - "info": "🎴", - "apply": "🎴" - }, - { - "label": ":performing-arts:", - "detail": "emoji", - "info": "🎭", - "apply": "🎭" - }, - { - "label": ":framed-picture:", - "detail": "emoji", - "info": "🖼️", - "apply": "🖼️" - }, - { - "label": ":artist-palette:", - "detail": "emoji", - "info": "🎨", - "apply": "🎨" - }, - { - "label": ":thread:", - "detail": "emoji", - "info": "🧵", - "apply": "🧵" - }, - { - "label": ":sewing-needle:", - "detail": "emoji", - "info": "🪡", - "apply": "🪡" - }, - { - "label": ":yarn:", - "detail": "emoji", - "info": "🧶", - "apply": "🧶" - }, - { - "label": ":knot:", - "detail": "emoji", - "info": "🪢", - "apply": "🪢" - }, - { - "label": ":glasses:", - "detail": "emoji", - "info": "👓", - "apply": "👓" - }, - { - "label": ":sunglasses:", - "detail": "emoji", - "info": "🕶️", - "apply": "🕶️" - }, - { - "label": ":goggles:", - "detail": "emoji", - "info": "🥽", - "apply": "🥽" - }, - { - "label": ":lab-coat:", - "detail": "emoji", - "info": "🥼", - "apply": "🥼" - }, - { - "label": ":safety-vest:", - "detail": "emoji", - "info": "🦺", - "apply": "🦺" - }, - { - "label": ":necktie:", - "detail": "emoji", - "info": "👔", - "apply": "👔" - }, - { - "label": ":t-shirt:", - "detail": "emoji", - "info": "👕", - "apply": "👕" - }, - { - "label": ":jeans:", - "detail": "emoji", - "info": "👖", - "apply": "👖" - }, - { - "label": ":scarf:", - "detail": "emoji", - "info": "🧣", - "apply": "🧣" - }, - { - "label": ":gloves:", - "detail": "emoji", - "info": "🧤", - "apply": "🧤" - }, - { - "label": ":coat:", - "detail": "emoji", - "info": "🧥", - "apply": "🧥" - }, - { - "label": ":socks:", - "detail": "emoji", - "info": "🧦", - "apply": "🧦" - }, - { - "label": ":dress:", - "detail": "emoji", - "info": "👗", - "apply": "👗" - }, - { - "label": ":kimono:", - "detail": "emoji", - "info": "👘", - "apply": "👘" - }, - { - "label": ":sari:", - "detail": "emoji", - "info": "🥻", - "apply": "🥻" - }, - { - "label": ":one-piece-swimsuit:", - "detail": "emoji", - "info": "🩱", - "apply": "🩱" - }, - { - "label": ":briefs:", - "detail": "emoji", - "info": "🩲", - "apply": "🩲" - }, - { - "label": ":shorts:", - "detail": "emoji", - "info": "🩳", - "apply": "🩳" - }, - { - "label": ":bikini:", - "detail": "emoji", - "info": "👙", - "apply": "👙" - }, - { - "label": ":womans-clothes:", - "detail": "emoji", - "info": "👚", - "apply": "👚" - }, - { - "label": ":folding-hand-fan:", - "detail": "emoji", - "info": "🪭", - "apply": "🪭" - }, - { - "label": ":purse:", - "detail": "emoji", - "info": "👛", - "apply": "👛" - }, - { - "label": ":handbag:", - "detail": "emoji", - "info": "👜", - "apply": "👜" - }, - { - "label": ":clutch-bag:", - "detail": "emoji", - "info": "👝", - "apply": "👝" - }, - { - "label": ":shopping-bags:", - "detail": "emoji", - "info": "🛍️", - "apply": "🛍️" - }, - { - "label": ":backpack:", - "detail": "emoji", - "info": "🎒", - "apply": "🎒" - }, - { - "label": ":thong-sandal:", - "detail": "emoji", - "info": "🩴", - "apply": "🩴" - }, - { - "label": ":mans-shoe:", - "detail": "emoji", - "info": "👞", - "apply": "👞" - }, - { - "label": ":running-shoe:", - "detail": "emoji", - "info": "👟", - "apply": "👟" - }, - { - "label": ":hiking-boot:", - "detail": "emoji", - "info": "🥾", - "apply": "🥾" - }, - { - "label": ":flat-shoe:", - "detail": "emoji", - "info": "🥿", - "apply": "🥿" - }, - { - "label": ":high-heeled-shoe:", - "detail": "emoji", - "info": "👠", - "apply": "👠" - }, - { - "label": ":womans-sandal:", - "detail": "emoji", - "info": "👡", - "apply": "👡" - }, - { - "label": ":ballet-shoes:", - "detail": "emoji", - "info": "🩰", - "apply": "🩰" - }, - { - "label": ":womans-boot:", - "detail": "emoji", - "info": "👢", - "apply": "👢" - }, - { - "label": ":hair-pick:", - "detail": "emoji", - "info": "🪮", - "apply": "🪮" - }, - { - "label": ":crown:", - "detail": "emoji", - "info": "👑", - "apply": "👑" - }, - { - "label": ":womans-hat:", - "detail": "emoji", - "info": "👒", - "apply": "👒" - }, - { - "label": ":top-hat:", - "detail": "emoji", - "info": "🎩", - "apply": "🎩" - }, - { - "label": ":graduation-cap:", - "detail": "emoji", - "info": "🎓", - "apply": "🎓" - }, - { - "label": ":billed-cap:", - "detail": "emoji", - "info": "🧢", - "apply": "🧢" - }, - { - "label": ":military-helmet:", - "detail": "emoji", - "info": "🪖", - "apply": "🪖" - }, - { - "label": ":rescue-workers-helmet:", - "detail": "emoji", - "info": "⛑️", - "apply": "⛑️" - }, - { - "label": ":prayer-beads:", - "detail": "emoji", - "info": "📿", - "apply": "📿" - }, - { - "label": ":lipstick:", - "detail": "emoji", - "info": "💄", - "apply": "💄" - }, - { - "label": ":ring:", - "detail": "emoji", - "info": "💍", - "apply": "💍" - }, - { - "label": ":gem-stone:", - "detail": "emoji", - "info": "💎", - "apply": "💎" - }, - { - "label": ":muted-speaker:", - "detail": "emoji", - "info": "🔇", - "apply": "🔇" - }, - { - "label": ":speaker-low-volume:", - "detail": "emoji", - "info": "🔈", - "apply": "🔈" - }, - { - "label": ":speaker-medium-volume:", - "detail": "emoji", - "info": "🔉", - "apply": "🔉" - }, - { - "label": ":speaker-high-volume:", - "detail": "emoji", - "info": "🔊", - "apply": "🔊" - }, - { - "label": ":loudspeaker:", - "detail": "emoji", - "info": "📢", - "apply": "📢" - }, - { - "label": ":megaphone:", - "detail": "emoji", - "info": "📣", - "apply": "📣" - }, - { - "label": ":postal-horn:", - "detail": "emoji", - "info": "📯", - "apply": "📯" - }, - { - "label": ":bell:", - "detail": "emoji", - "info": "🔔", - "apply": "🔔" - }, - { - "label": ":bell-with-slash:", - "detail": "emoji", - "info": "🔕", - "apply": "🔕" - }, - { - "label": ":musical-score:", - "detail": "emoji", - "info": "🎼", - "apply": "🎼" - }, - { - "label": ":musical-note:", - "detail": "emoji", - "info": "🎵", - "apply": "🎵" - }, - { - "label": ":musical-notes:", - "detail": "emoji", - "info": "🎶", - "apply": "🎶" - }, - { - "label": ":studio-microphone:", - "detail": "emoji", - "info": "🎙️", - "apply": "🎙️" - }, - { - "label": ":level-slider:", - "detail": "emoji", - "info": "🎚️", - "apply": "🎚️" - }, - { - "label": ":control-knobs:", - "detail": "emoji", - "info": "🎛️", - "apply": "🎛️" - }, - { - "label": ":microphone:", - "detail": "emoji", - "info": "🎤", - "apply": "🎤" - }, - { - "label": ":headphone:", - "detail": "emoji", - "info": "🎧", - "apply": "🎧" - }, - { - "label": ":radio:", - "detail": "emoji", - "info": "📻", - "apply": "📻" - }, - { - "label": ":saxophone:", - "detail": "emoji", - "info": "🎷", - "apply": "🎷" - }, - { - "label": ":accordion:", - "detail": "emoji", - "info": "🪗", - "apply": "🪗" - }, - { - "label": ":guitar:", - "detail": "emoji", - "info": "🎸", - "apply": "🎸" - }, - { - "label": ":musical-keyboard:", - "detail": "emoji", - "info": "🎹", - "apply": "🎹" - }, - { - "label": ":trumpet:", - "detail": "emoji", - "info": "🎺", - "apply": "🎺" - }, - { - "label": ":violin:", - "detail": "emoji", - "info": "🎻", - "apply": "🎻" - }, - { - "label": ":banjo:", - "detail": "emoji", - "info": "🪕", - "apply": "🪕" - }, - { - "label": ":drum:", - "detail": "emoji", - "info": "🥁", - "apply": "🥁" - }, - { - "label": ":long-drum:", - "detail": "emoji", - "info": "🪘", - "apply": "🪘" - }, - { - "label": ":maracas:", - "detail": "emoji", - "info": "🪇", - "apply": "🪇" - }, - { - "label": ":flute:", - "detail": "emoji", - "info": "🪈", - "apply": "🪈" - }, - { - "label": ":mobile-phone:", - "detail": "emoji", - "info": "📱", - "apply": "📱" - }, - { - "label": ":mobile-phone-with-arrow:", - "detail": "emoji", - "info": "📲", - "apply": "📲" - }, - { - "label": ":telephone:", - "detail": "emoji", - "info": "☎️", - "apply": "☎️" - }, - { - "label": ":telephone-receiver:", - "detail": "emoji", - "info": "📞", - "apply": "📞" - }, - { - "label": ":pager:", - "detail": "emoji", - "info": "📟", - "apply": "📟" - }, - { - "label": ":fax-machine:", - "detail": "emoji", - "info": "📠", - "apply": "📠" - }, - { - "label": ":battery:", - "detail": "emoji", - "info": "🔋", - "apply": "🔋" - }, - { - "label": ":low-battery:", - "detail": "emoji", - "info": "🪫", - "apply": "🪫" - }, - { - "label": ":electric-plug:", - "detail": "emoji", - "info": "🔌", - "apply": "🔌" - }, - { - "label": ":laptop:", - "detail": "emoji", - "info": "💻", - "apply": "💻" - }, - { - "label": ":desktop-computer:", - "detail": "emoji", - "info": "🖥️", - "apply": "🖥️" - }, - { - "label": ":printer:", - "detail": "emoji", - "info": "🖨️", - "apply": "🖨️" - }, - { - "label": ":keyboard:", - "detail": "emoji", - "info": "⌨️", - "apply": "⌨️" - }, - { - "label": ":computer-mouse:", - "detail": "emoji", - "info": "🖱️", - "apply": "🖱️" - }, - { - "label": ":trackball:", - "detail": "emoji", - "info": "🖲️", - "apply": "🖲️" - }, - { - "label": ":computer-disk:", - "detail": "emoji", - "info": "💽", - "apply": "💽" - }, - { - "label": ":floppy-disk:", - "detail": "emoji", - "info": "💾", - "apply": "💾" - }, - { - "label": ":optical-disk:", - "detail": "emoji", - "info": "💿", - "apply": "💿" - }, - { - "label": ":dvd:", - "detail": "emoji", - "info": "📀", - "apply": "📀" - }, - { - "label": ":abacus:", - "detail": "emoji", - "info": "🧮", - "apply": "🧮" - }, - { - "label": ":movie-camera:", - "detail": "emoji", - "info": "🎥", - "apply": "🎥" - }, - { - "label": ":film-frames:", - "detail": "emoji", - "info": "🎞️", - "apply": "🎞️" - }, - { - "label": ":film-projector:", - "detail": "emoji", - "info": "📽️", - "apply": "📽️" - }, - { - "label": ":clapper-board:", - "detail": "emoji", - "info": "🎬", - "apply": "🎬" - }, - { - "label": ":television:", - "detail": "emoji", - "info": "📺", - "apply": "📺" - }, - { - "label": ":camera:", - "detail": "emoji", - "info": "📷", - "apply": "📷" - }, - { - "label": ":camera-with-flash:", - "detail": "emoji", - "info": "📸", - "apply": "📸" - }, - { - "label": ":video-camera:", - "detail": "emoji", - "info": "📹", - "apply": "📹" - }, - { - "label": ":videocassette:", - "detail": "emoji", - "info": "📼", - "apply": "📼" - }, - { - "label": ":magnifying-glass-tilted-left:", - "detail": "emoji", - "info": "🔍", - "apply": "🔍" - }, - { - "label": ":magnifying-glass-tilted-right:", - "detail": "emoji", - "info": "🔎", - "apply": "🔎" - }, - { - "label": ":candle:", - "detail": "emoji", - "info": "🕯️", - "apply": "🕯️" - }, - { - "label": ":light-bulb:", - "detail": "emoji", - "info": "💡", - "apply": "💡" - }, - { - "label": ":flashlight:", - "detail": "emoji", - "info": "🔦", - "apply": "🔦" - }, - { - "label": ":red-paper-lantern:", - "detail": "emoji", - "info": "🏮", - "apply": "🏮" - }, - { - "label": ":diya-lamp:", - "detail": "emoji", - "info": "🪔", - "apply": "🪔" - }, - { - "label": ":notebook-with-decorative-cover:", - "detail": "emoji", - "info": "📔", - "apply": "📔" - }, - { - "label": ":closed-book:", - "detail": "emoji", - "info": "📕", - "apply": "📕" - }, - { - "label": ":open-book:", - "detail": "emoji", - "info": "📖", - "apply": "📖" - }, - { - "label": ":green-book:", - "detail": "emoji", - "info": "📗", - "apply": "📗" - }, - { - "label": ":blue-book:", - "detail": "emoji", - "info": "📘", - "apply": "📘" - }, - { - "label": ":orange-book:", - "detail": "emoji", - "info": "📙", - "apply": "📙" - }, - { - "label": ":books:", - "detail": "emoji", - "info": "📚", - "apply": "📚" - }, - { - "label": ":notebook:", - "detail": "emoji", - "info": "📓", - "apply": "📓" - }, - { - "label": ":ledger:", - "detail": "emoji", - "info": "📒", - "apply": "📒" - }, - { - "label": ":page-with-curl:", - "detail": "emoji", - "info": "📃", - "apply": "📃" - }, - { - "label": ":scroll:", - "detail": "emoji", - "info": "📜", - "apply": "📜" - }, - { - "label": ":page-facing-up:", - "detail": "emoji", - "info": "📄", - "apply": "📄" - }, - { - "label": ":newspaper:", - "detail": "emoji", - "info": "📰", - "apply": "📰" - }, - { - "label": ":rolled-up-newspaper:", - "detail": "emoji", - "info": "🗞️", - "apply": "🗞️" - }, - { - "label": ":bookmark-tabs:", - "detail": "emoji", - "info": "📑", - "apply": "📑" - }, - { - "label": ":bookmark:", - "detail": "emoji", - "info": "🔖", - "apply": "🔖" - }, - { - "label": ":label:", - "detail": "emoji", - "info": "🏷️", - "apply": "🏷️" - }, - { - "label": ":money-bag:", - "detail": "emoji", - "info": "💰", - "apply": "💰" - }, - { - "label": ":coin:", - "detail": "emoji", - "info": "🪙", - "apply": "🪙" - }, - { - "label": ":yen-banknote:", - "detail": "emoji", - "info": "💴", - "apply": "💴" - }, - { - "label": ":dollar-banknote:", - "detail": "emoji", - "info": "💵", - "apply": "💵" - }, - { - "label": ":euro-banknote:", - "detail": "emoji", - "info": "💶", - "apply": "💶" - }, - { - "label": ":pound-banknote:", - "detail": "emoji", - "info": "💷", - "apply": "💷" - }, - { - "label": ":money-with-wings:", - "detail": "emoji", - "info": "💸", - "apply": "💸" - }, - { - "label": ":credit-card:", - "detail": "emoji", - "info": "💳", - "apply": "💳" - }, - { - "label": ":receipt:", - "detail": "emoji", - "info": "🧾", - "apply": "🧾" - }, - { - "label": ":chart-increasing-with-yen:", - "detail": "emoji", - "info": "💹", - "apply": "💹" - }, - { - "label": ":envelope:", - "detail": "emoji", - "info": "✉️", - "apply": "✉️" - }, - { - "label": ":e-mail:", - "detail": "emoji", - "info": "📧", - "apply": "📧" - }, - { - "label": ":incoming-envelope:", - "detail": "emoji", - "info": "📨", - "apply": "📨" - }, - { - "label": ":envelope-with-arrow:", - "detail": "emoji", - "info": "📩", - "apply": "📩" - }, - { - "label": ":outbox-tray:", - "detail": "emoji", - "info": "📤", - "apply": "📤" - }, - { - "label": ":inbox-tray:", - "detail": "emoji", - "info": "📥", - "apply": "📥" - }, - { - "label": ":package:", - "detail": "emoji", - "info": "📦", - "apply": "📦" - }, - { - "label": ":closed-mailbox-with-raised-flag:", - "detail": "emoji", - "info": "📫", - "apply": "📫" - }, - { - "label": ":closed-mailbox-with-lowered-flag:", - "detail": "emoji", - "info": "📪", - "apply": "📪" - }, - { - "label": ":open-mailbox-with-raised-flag:", - "detail": "emoji", - "info": "📬", - "apply": "📬" - }, - { - "label": ":open-mailbox-with-lowered-flag:", - "detail": "emoji", - "info": "📭", - "apply": "📭" - }, - { - "label": ":postbox:", - "detail": "emoji", - "info": "📮", - "apply": "📮" - }, - { - "label": ":ballot-box-with-ballot:", - "detail": "emoji", - "info": "🗳️", - "apply": "🗳️" - }, - { - "label": ":pencil:", - "detail": "emoji", - "info": "✏️", - "apply": "✏️" - }, - { - "label": ":black-nib:", - "detail": "emoji", - "info": "✒️", - "apply": "✒️" - }, - { - "label": ":fountain-pen:", - "detail": "emoji", - "info": "🖋️", - "apply": "🖋️" - }, - { - "label": ":pen:", - "detail": "emoji", - "info": "🖊️", - "apply": "🖊️" - }, - { - "label": ":paintbrush:", - "detail": "emoji", - "info": "🖌️", - "apply": "🖌️" - }, - { - "label": ":crayon:", - "detail": "emoji", - "info": "🖍️", - "apply": "🖍️" - }, - { - "label": ":memo:", - "detail": "emoji", - "info": "📝", - "apply": "📝" - }, - { - "label": ":briefcase:", - "detail": "emoji", - "info": "💼", - "apply": "💼" - }, - { - "label": ":file-folder:", - "detail": "emoji", - "info": "📁", - "apply": "📁" - }, - { - "label": ":open-file-folder:", - "detail": "emoji", - "info": "📂", - "apply": "📂" - }, - { - "label": ":card-index-dividers:", - "detail": "emoji", - "info": "🗂️", - "apply": "🗂️" - }, - { - "label": ":calendar:", - "detail": "emoji", - "info": "📅", - "apply": "📅" - }, - { - "label": ":tear-off-calendar:", - "detail": "emoji", - "info": "📆", - "apply": "📆" - }, - { - "label": ":spiral-notepad:", - "detail": "emoji", - "info": "🗒️", - "apply": "🗒️" - }, - { - "label": ":spiral-calendar:", - "detail": "emoji", - "info": "🗓️", - "apply": "🗓️" - }, - { - "label": ":card-index:", - "detail": "emoji", - "info": "📇", - "apply": "📇" - }, - { - "label": ":chart-increasing:", - "detail": "emoji", - "info": "📈", - "apply": "📈" - }, - { - "label": ":chart-decreasing:", - "detail": "emoji", - "info": "📉", - "apply": "📉" - }, - { - "label": ":bar-chart:", - "detail": "emoji", - "info": "📊", - "apply": "📊" - }, - { - "label": ":clipboard:", - "detail": "emoji", - "info": "📋", - "apply": "📋" - }, - { - "label": ":pushpin:", - "detail": "emoji", - "info": "📌", - "apply": "📌" - }, - { - "label": ":round-pushpin:", - "detail": "emoji", - "info": "📍", - "apply": "📍" - }, - { - "label": ":paperclip:", - "detail": "emoji", - "info": "📎", - "apply": "📎" - }, - { - "label": ":linked-paperclips:", - "detail": "emoji", - "info": "🖇️", - "apply": "🖇️" - }, - { - "label": ":straight-ruler:", - "detail": "emoji", - "info": "📏", - "apply": "📏" - }, - { - "label": ":triangular-ruler:", - "detail": "emoji", - "info": "📐", - "apply": "📐" - }, - { - "label": ":scissors:", - "detail": "emoji", - "info": "✂️", - "apply": "✂️" - }, - { - "label": ":card-file-box:", - "detail": "emoji", - "info": "🗃️", - "apply": "🗃️" - }, - { - "label": ":file-cabinet:", - "detail": "emoji", - "info": "🗄️", - "apply": "🗄️" - }, - { - "label": ":wastebasket:", - "detail": "emoji", - "info": "🗑️", - "apply": "🗑️" - }, - { - "label": ":locked:", - "detail": "emoji", - "info": "🔒", - "apply": "🔒" - }, - { - "label": ":unlocked:", - "detail": "emoji", - "info": "🔓", - "apply": "🔓" - }, - { - "label": ":locked-with-pen:", - "detail": "emoji", - "info": "🔏", - "apply": "🔏" - }, - { - "label": ":locked-with-key:", - "detail": "emoji", - "info": "🔐", - "apply": "🔐" - }, - { - "label": ":key:", - "detail": "emoji", - "info": "🔑", - "apply": "🔑" - }, - { - "label": ":old-key:", - "detail": "emoji", - "info": "🗝️", - "apply": "🗝️" - }, - { - "label": ":hammer:", - "detail": "emoji", - "info": "🔨", - "apply": "🔨" - }, - { - "label": ":axe:", - "detail": "emoji", - "info": "🪓", - "apply": "🪓" - }, - { - "label": ":pick:", - "detail": "emoji", - "info": "⛏️", - "apply": "⛏️" - }, - { - "label": ":hammer-and-pick:", - "detail": "emoji", - "info": "⚒️", - "apply": "⚒️" - }, - { - "label": ":hammer-and-wrench:", - "detail": "emoji", - "info": "🛠️", - "apply": "🛠️" - }, - { - "label": ":dagger:", - "detail": "emoji", - "info": "🗡️", - "apply": "🗡️" - }, - { - "label": ":crossed-swords:", - "detail": "emoji", - "info": "⚔️", - "apply": "⚔️" - }, - { - "label": ":bomb:", - "detail": "emoji", - "info": "💣", - "apply": "💣" - }, - { - "label": ":boomerang:", - "detail": "emoji", - "info": "🪃", - "apply": "🪃" - }, - { - "label": ":bow-and-arrow:", - "detail": "emoji", - "info": "🏹", - "apply": "🏹" - }, - { - "label": ":shield:", - "detail": "emoji", - "info": "🛡️", - "apply": "🛡️" - }, - { - "label": ":carpentry-saw:", - "detail": "emoji", - "info": "🪚", - "apply": "🪚" - }, - { - "label": ":wrench:", - "detail": "emoji", - "info": "🔧", - "apply": "🔧" - }, - { - "label": ":screwdriver:", - "detail": "emoji", - "info": "🪛", - "apply": "🪛" - }, - { - "label": ":nut-and-bolt:", - "detail": "emoji", - "info": "🔩", - "apply": "🔩" - }, - { - "label": ":gear:", - "detail": "emoji", - "info": "⚙️", - "apply": "⚙️" - }, - { - "label": ":clamp:", - "detail": "emoji", - "info": "🗜️", - "apply": "🗜️" - }, - { - "label": ":balance-scale:", - "detail": "emoji", - "info": "⚖️", - "apply": "⚖️" - }, - { - "label": ":white-cane:", - "detail": "emoji", - "info": "🦯", - "apply": "🦯" - }, - { - "label": ":link:", - "detail": "emoji", - "info": "🔗", - "apply": "🔗" - }, - { - "label": ":chains:", - "detail": "emoji", - "info": "⛓️", - "apply": "⛓️" - }, - { - "label": ":hook:", - "detail": "emoji", - "info": "🪝", - "apply": "🪝" - }, - { - "label": ":toolbox:", - "detail": "emoji", - "info": "🧰", - "apply": "🧰" - }, - { - "label": ":magnet:", - "detail": "emoji", - "info": "🧲", - "apply": "🧲" - }, - { - "label": ":ladder:", - "detail": "emoji", - "info": "🪜", - "apply": "🪜" - }, - { - "label": ":alembic:", - "detail": "emoji", - "info": "⚗️", - "apply": "⚗️" - }, - { - "label": ":test-tube:", - "detail": "emoji", - "info": "🧪", - "apply": "🧪" - }, - { - "label": ":petri-dish:", - "detail": "emoji", - "info": "🧫", - "apply": "🧫" - }, - { - "label": ":dna:", - "detail": "emoji", - "info": "🧬", - "apply": "🧬" - }, - { - "label": ":microscope:", - "detail": "emoji", - "info": "🔬", - "apply": "🔬" - }, - { - "label": ":telescope:", - "detail": "emoji", - "info": "🔭", - "apply": "🔭" - }, - { - "label": ":satellite-antenna:", - "detail": "emoji", - "info": "📡", - "apply": "📡" - }, - { - "label": ":syringe:", - "detail": "emoji", - "info": "💉", - "apply": "💉" - }, - { - "label": ":drop-of-blood:", - "detail": "emoji", - "info": "🩸", - "apply": "🩸" - }, - { - "label": ":pill:", - "detail": "emoji", - "info": "💊", - "apply": "💊" - }, - { - "label": ":adhesive-bandage:", - "detail": "emoji", - "info": "🩹", - "apply": "🩹" - }, - { - "label": ":crutch:", - "detail": "emoji", - "info": "🩼", - "apply": "🩼" - }, - { - "label": ":stethoscope:", - "detail": "emoji", - "info": "🩺", - "apply": "🩺" - }, - { - "label": ":x-ray:", - "detail": "emoji", - "info": "🩻", - "apply": "🩻" - }, - { - "label": ":door:", - "detail": "emoji", - "info": "🚪", - "apply": "🚪" - }, - { - "label": ":elevator:", - "detail": "emoji", - "info": "🛗", - "apply": "🛗" - }, - { - "label": ":mirror:", - "detail": "emoji", - "info": "🪞", - "apply": "🪞" - }, - { - "label": ":window:", - "detail": "emoji", - "info": "🪟", - "apply": "🪟" - }, - { - "label": ":bed:", - "detail": "emoji", - "info": "🛏️", - "apply": "🛏️" - }, - { - "label": ":couch-and-lamp:", - "detail": "emoji", - "info": "🛋️", - "apply": "🛋️" - }, - { - "label": ":chair:", - "detail": "emoji", - "info": "🪑", - "apply": "🪑" - }, - { - "label": ":toilet:", - "detail": "emoji", - "info": "🚽", - "apply": "🚽" - }, - { - "label": ":plunger:", - "detail": "emoji", - "info": "🪠", - "apply": "🪠" - }, - { - "label": ":shower:", - "detail": "emoji", - "info": "🚿", - "apply": "🚿" - }, - { - "label": ":bathtub:", - "detail": "emoji", - "info": "🛁", - "apply": "🛁" - }, - { - "label": ":mouse-trap:", - "detail": "emoji", - "info": "🪤", - "apply": "🪤" - }, - { - "label": ":razor:", - "detail": "emoji", - "info": "🪒", - "apply": "🪒" - }, - { - "label": ":lotion-bottle:", - "detail": "emoji", - "info": "🧴", - "apply": "🧴" - }, - { - "label": ":safety-pin:", - "detail": "emoji", - "info": "🧷", - "apply": "🧷" - }, - { - "label": ":broom:", - "detail": "emoji", - "info": "🧹", - "apply": "🧹" - }, - { - "label": ":basket:", - "detail": "emoji", - "info": "🧺", - "apply": "🧺" - }, - { - "label": ":roll-of-paper:", - "detail": "emoji", - "info": "🧻", - "apply": "🧻" - }, - { - "label": ":bucket:", - "detail": "emoji", - "info": "🪣", - "apply": "🪣" - }, - { - "label": ":soap:", - "detail": "emoji", - "info": "🧼", - "apply": "🧼" - }, - { - "label": ":bubbles:", - "detail": "emoji", - "info": "🫧", - "apply": "🫧" - }, - { - "label": ":toothbrush:", - "detail": "emoji", - "info": "🪥", - "apply": "🪥" - }, - { - "label": ":sponge:", - "detail": "emoji", - "info": "🧽", - "apply": "🧽" - }, - { - "label": ":fire-extinguisher:", - "detail": "emoji", - "info": "🧯", - "apply": "🧯" - }, - { - "label": ":shopping-cart:", - "detail": "emoji", - "info": "🛒", - "apply": "🛒" - }, - { - "label": ":cigarette:", - "detail": "emoji", - "info": "🚬", - "apply": "🚬" - }, - { - "label": ":coffin:", - "detail": "emoji", - "info": "⚰️", - "apply": "⚰️" - }, - { - "label": ":headstone:", - "detail": "emoji", - "info": "🪦", - "apply": "🪦" - }, - { - "label": ":funeral-urn:", - "detail": "emoji", - "info": "⚱️", - "apply": "⚱️" - }, - { - "label": ":nazar-amulet:", - "detail": "emoji", - "info": "🧿", - "apply": "🧿" - }, - { - "label": ":hamsa:", - "detail": "emoji", - "info": "🪬", - "apply": "🪬" - }, - { - "label": ":moai:", - "detail": "emoji", - "info": "🗿", - "apply": "🗿" - }, - { - "label": ":placard:", - "detail": "emoji", - "info": "🪧", - "apply": "🪧" - }, - { - "label": ":identification-card:", - "detail": "emoji", - "info": "🪪", - "apply": "🪪" - }, - { - "label": ":atm-sign:", - "detail": "emoji", - "info": "🏧", - "apply": "🏧" - }, - { - "label": ":litter-in-bin-sign:", - "detail": "emoji", - "info": "🚮", - "apply": "🚮" - }, - { - "label": ":potable-water:", - "detail": "emoji", - "info": "🚰", - "apply": "🚰" - }, - { - "label": ":wheelchair-symbol:", - "detail": "emoji", - "info": "♿", - "apply": "♿" - }, - { - "label": ":mens-room:", - "detail": "emoji", - "info": "🚹", - "apply": "🚹" - }, - { - "label": ":womens-room:", - "detail": "emoji", - "info": "🚺", - "apply": "🚺" - }, - { - "label": ":restroom:", - "detail": "emoji", - "info": "🚻", - "apply": "🚻" - }, - { - "label": ":baby-symbol:", - "detail": "emoji", - "info": "🚼", - "apply": "🚼" - }, - { - "label": ":water-closet:", - "detail": "emoji", - "info": "🚾", - "apply": "🚾" - }, - { - "label": ":passport-control:", - "detail": "emoji", - "info": "🛂", - "apply": "🛂" - }, - { - "label": ":customs:", - "detail": "emoji", - "info": "🛃", - "apply": "🛃" - }, - { - "label": ":baggage-claim:", - "detail": "emoji", - "info": "🛄", - "apply": "🛄" - }, - { - "label": ":left-luggage:", - "detail": "emoji", - "info": "🛅", - "apply": "🛅" - }, - { - "label": ":warning:", - "detail": "emoji", - "info": "⚠️", - "apply": "⚠️" - }, - { - "label": ":children-crossing:", - "detail": "emoji", - "info": "🚸", - "apply": "🚸" - }, - { - "label": ":no-entry:", - "detail": "emoji", - "info": "⛔", - "apply": "⛔" - }, - { - "label": ":prohibited:", - "detail": "emoji", - "info": "🚫", - "apply": "🚫" - }, - { - "label": ":no-bicycles:", - "detail": "emoji", - "info": "🚳", - "apply": "🚳" - }, - { - "label": ":no-smoking:", - "detail": "emoji", - "info": "🚭", - "apply": "🚭" - }, - { - "label": ":no-littering:", - "detail": "emoji", - "info": "🚯", - "apply": "🚯" - }, - { - "label": ":non-potable-water:", - "detail": "emoji", - "info": "🚱", - "apply": "🚱" - }, - { - "label": ":no-pedestrians:", - "detail": "emoji", - "info": "🚷", - "apply": "🚷" - }, - { - "label": ":no-mobile-phones:", - "detail": "emoji", - "info": "📵", - "apply": "📵" - }, - { - "label": ":no-one-under-eighteen:", - "detail": "emoji", - "info": "🔞", - "apply": "🔞" - }, - { - "label": ":radioactive:", - "detail": "emoji", - "info": "☢️", - "apply": "☢️" - }, - { - "label": ":biohazard:", - "detail": "emoji", - "info": "☣️", - "apply": "☣️" - }, - { - "label": ":up-arrow:", - "detail": "emoji", - "info": "⬆️", - "apply": "⬆️" - }, - { - "label": ":up-right-arrow:", - "detail": "emoji", - "info": "↗️", - "apply": "↗️" - }, - { - "label": ":right-arrow:", - "detail": "emoji", - "info": "➡️", - "apply": "➡️" - }, - { - "label": ":down-right-arrow:", - "detail": "emoji", - "info": "↘️", - "apply": "↘️" - }, - { - "label": ":down-arrow:", - "detail": "emoji", - "info": "⬇️", - "apply": "⬇️" - }, - { - "label": ":down-left-arrow:", - "detail": "emoji", - "info": "↙️", - "apply": "↙️" - }, - { - "label": ":left-arrow:", - "detail": "emoji", - "info": "⬅️", - "apply": "⬅️" - }, - { - "label": ":up-left-arrow:", - "detail": "emoji", - "info": "↖️", - "apply": "↖️" - }, - { - "label": ":up-down-arrow:", - "detail": "emoji", - "info": "↕️", - "apply": "↕️" - }, - { - "label": ":left-right-arrow:", - "detail": "emoji", - "info": "↔️", - "apply": "↔️" - }, - { - "label": ":right-arrow-curving-left:", - "detail": "emoji", - "info": "↩️", - "apply": "↩️" - }, - { - "label": ":left-arrow-curving-right:", - "detail": "emoji", - "info": "↪️", - "apply": "↪️" - }, - { - "label": ":right-arrow-curving-up:", - "detail": "emoji", - "info": "⤴️", - "apply": "⤴️" - }, - { - "label": ":right-arrow-curving-down:", - "detail": "emoji", - "info": "⤵️", - "apply": "⤵️" - }, - { - "label": ":clockwise-vertical-arrows:", - "detail": "emoji", - "info": "🔃", - "apply": "🔃" - }, - { - "label": ":counterclockwise-arrows-button:", - "detail": "emoji", - "info": "🔄", - "apply": "🔄" - }, - { - "label": ":back-arrow:", - "detail": "emoji", - "info": "🔙", - "apply": "🔙" - }, - { - "label": ":end-arrow:", - "detail": "emoji", - "info": "🔚", - "apply": "🔚" - }, - { - "label": ":on!-arrow:", - "detail": "emoji", - "info": "🔛", - "apply": "🔛" - }, - { - "label": ":soon-arrow:", - "detail": "emoji", - "info": "🔜", - "apply": "🔜" - }, - { - "label": ":top-arrow:", - "detail": "emoji", - "info": "🔝", - "apply": "🔝" - }, - { - "label": ":place-of-worship:", - "detail": "emoji", - "info": "🛐", - "apply": "🛐" - }, - { - "label": ":atom-symbol:", - "detail": "emoji", - "info": "⚛️", - "apply": "⚛️" - }, - { - "label": ":om:", - "detail": "emoji", - "info": "🕉️", - "apply": "🕉️" - }, - { - "label": ":star-of-david:", - "detail": "emoji", - "info": "✡️", - "apply": "✡️" - }, - { - "label": ":wheel-of-dharma:", - "detail": "emoji", - "info": "☸️", - "apply": "☸️" - }, - { - "label": ":yin-yang:", - "detail": "emoji", - "info": "☯️", - "apply": "☯️" - }, - { - "label": ":latin-cross:", - "detail": "emoji", - "info": "✝️", - "apply": "✝️" - }, - { - "label": ":orthodox-cross:", - "detail": "emoji", - "info": "☦️", - "apply": "☦️" - }, - { - "label": ":star-and-crescent:", - "detail": "emoji", - "info": "☪️", - "apply": "☪️" - }, - { - "label": ":peace-symbol:", - "detail": "emoji", - "info": "☮️", - "apply": "☮️" - }, - { - "label": ":menorah:", - "detail": "emoji", - "info": "🕎", - "apply": "🕎" - }, - { - "label": ":dotted-six-pointed-star:", - "detail": "emoji", - "info": "🔯", - "apply": "🔯" - }, - { - "label": ":khanda:", - "detail": "emoji", - "info": "🪯", - "apply": "🪯" - }, - { - "label": ":aries:", - "detail": "emoji", - "info": "♈", - "apply": "♈" - }, - { - "label": ":taurus:", - "detail": "emoji", - "info": "♉", - "apply": "♉" - }, - { - "label": ":gemini:", - "detail": "emoji", - "info": "♊", - "apply": "♊" - }, - { - "label": ":cancer:", - "detail": "emoji", - "info": "♋", - "apply": "♋" - }, - { - "label": ":leo:", - "detail": "emoji", - "info": "♌", - "apply": "♌" - }, - { - "label": ":virgo:", - "detail": "emoji", - "info": "♍", - "apply": "♍" - }, - { - "label": ":libra:", - "detail": "emoji", - "info": "♎", - "apply": "♎" - }, - { - "label": ":scorpio:", - "detail": "emoji", - "info": "♏", - "apply": "♏" - }, - { - "label": ":sagittarius:", - "detail": "emoji", - "info": "♐", - "apply": "♐" - }, - { - "label": ":capricorn:", - "detail": "emoji", - "info": "♑", - "apply": "♑" - }, - { - "label": ":aquarius:", - "detail": "emoji", - "info": "♒", - "apply": "♒" - }, - { - "label": ":pisces:", - "detail": "emoji", - "info": "♓", - "apply": "♓" - }, - { - "label": ":ophiuchus:", - "detail": "emoji", - "info": "⛎", - "apply": "⛎" - }, - { - "label": ":shuffle-tracks-button:", - "detail": "emoji", - "info": "🔀", - "apply": "🔀" - }, - { - "label": ":repeat-button:", - "detail": "emoji", - "info": "🔁", - "apply": "🔁" - }, - { - "label": ":repeat-single-button:", - "detail": "emoji", - "info": "🔂", - "apply": "🔂" - }, - { - "label": ":play-button:", - "detail": "emoji", - "info": "▶️", - "apply": "▶️" - }, - { - "label": ":fast-forward-button:", - "detail": "emoji", - "info": "⏩", - "apply": "⏩" - }, - { - "label": ":next-track-button:", - "detail": "emoji", - "info": "⏭️", - "apply": "⏭️" - }, - { - "label": ":play-or-pause-button:", - "detail": "emoji", - "info": "⏯️", - "apply": "⏯️" - }, - { - "label": ":reverse-button:", - "detail": "emoji", - "info": "◀️", - "apply": "◀️" - }, - { - "label": ":fast-reverse-button:", - "detail": "emoji", - "info": "⏪", - "apply": "⏪" - }, - { - "label": ":last-track-button:", - "detail": "emoji", - "info": "⏮️", - "apply": "⏮️" - }, - { - "label": ":upwards-button:", - "detail": "emoji", - "info": "🔼", - "apply": "🔼" - }, - { - "label": ":fast-up-button:", - "detail": "emoji", - "info": "⏫", - "apply": "⏫" - }, - { - "label": ":downwards-button:", - "detail": "emoji", - "info": "🔽", - "apply": "🔽" - }, - { - "label": ":fast-down-button:", - "detail": "emoji", - "info": "⏬", - "apply": "⏬" - }, - { - "label": ":pause-button:", - "detail": "emoji", - "info": "⏸️", - "apply": "⏸️" - }, - { - "label": ":stop-button:", - "detail": "emoji", - "info": "⏹️", - "apply": "⏹️" - }, - { - "label": ":record-button:", - "detail": "emoji", - "info": "⏺️", - "apply": "⏺️" - }, - { - "label": ":eject-button:", - "detail": "emoji", - "info": "⏏️", - "apply": "⏏️" - }, - { - "label": ":cinema:", - "detail": "emoji", - "info": "🎦", - "apply": "🎦" - }, - { - "label": ":dim-button:", - "detail": "emoji", - "info": "🔅", - "apply": "🔅" - }, - { - "label": ":bright-button:", - "detail": "emoji", - "info": "🔆", - "apply": "🔆" - }, - { - "label": ":antenna-bars:", - "detail": "emoji", - "info": "📶", - "apply": "📶" - }, - { - "label": ":wireless:", - "detail": "emoji", - "info": "🛜", - "apply": "🛜" - }, - { - "label": ":vibration-mode:", - "detail": "emoji", - "info": "📳", - "apply": "📳" - }, - { - "label": ":mobile-phone-off:", - "detail": "emoji", - "info": "📴", - "apply": "📴" - }, - { - "label": ":female-sign:", - "detail": "emoji", - "info": "♀️", - "apply": "♀️" - }, - { - "label": ":male-sign:", - "detail": "emoji", - "info": "♂️", - "apply": "♂️" - }, - { - "label": ":transgender-symbol:", - "detail": "emoji", - "info": "⚧️", - "apply": "⚧️" - }, - { - "label": ":multiply:", - "detail": "emoji", - "info": "✖️", - "apply": "✖️" - }, - { - "label": ":plus:", - "detail": "emoji", - "info": "➕", - "apply": "➕" - }, - { - "label": ":minus:", - "detail": "emoji", - "info": "➖", - "apply": "➖" - }, - { - "label": ":divide:", - "detail": "emoji", - "info": "➗", - "apply": "➗" - }, - { - "label": ":heavy-equals-sign:", - "detail": "emoji", - "info": "🟰", - "apply": "🟰" - }, - { - "label": ":infinity:", - "detail": "emoji", - "info": "♾️", - "apply": "♾️" - }, - { - "label": ":double-exclamation-mark:", - "detail": "emoji", - "info": "‼️", - "apply": "‼️" - }, - { - "label": ":exclamation-question-mark:", - "detail": "emoji", - "info": "⁉️", - "apply": "⁉️" - }, - { - "label": ":red-question-mark:", - "detail": "emoji", - "info": "❓", - "apply": "❓" - }, - { - "label": ":white-question-mark:", - "detail": "emoji", - "info": "❔", - "apply": "❔" - }, - { - "label": ":white-exclamation-mark:", - "detail": "emoji", - "info": "❕", - "apply": "❕" - }, - { - "label": ":red-exclamation-mark:", - "detail": "emoji", - "info": "❗", - "apply": "❗" - }, - { - "label": ":wavy-dash:", - "detail": "emoji", - "info": "〰️", - "apply": "〰️" - }, - { - "label": ":currency-exchange:", - "detail": "emoji", - "info": "💱", - "apply": "💱" - }, - { - "label": ":heavy-dollar-sign:", - "detail": "emoji", - "info": "💲", - "apply": "💲" - }, - { - "label": ":medical-symbol:", - "detail": "emoji", - "info": "⚕️", - "apply": "⚕️" - }, - { - "label": ":recycling-symbol:", - "detail": "emoji", - "info": "♻️", - "apply": "♻️" - }, - { - "label": ":fleur-de-lis:", - "detail": "emoji", - "info": "⚜️", - "apply": "⚜️" - }, - { - "label": ":trident-emblem:", - "detail": "emoji", - "info": "🔱", - "apply": "🔱" - }, - { - "label": ":name-badge:", - "detail": "emoji", - "info": "📛", - "apply": "📛" - }, - { - "label": ":japanese-symbol-for-beginner:", - "detail": "emoji", - "info": "🔰", - "apply": "🔰" - }, - { - "label": ":hollow-red-circle:", - "detail": "emoji", - "info": "⭕", - "apply": "⭕" - }, - { - "label": ":check-mark-button:", - "detail": "emoji", - "info": "✅", - "apply": "✅" - }, - { - "label": ":check-box-with-check:", - "detail": "emoji", - "info": "☑️", - "apply": "☑️" - }, - { - "label": ":check-mark:", - "detail": "emoji", - "info": "✔️", - "apply": "✔️" - }, - { - "label": ":cross-mark:", - "detail": "emoji", - "info": "❌", - "apply": "❌" - }, - { - "label": ":cross-mark-button:", - "detail": "emoji", - "info": "❎", - "apply": "❎" - }, - { - "label": ":curly-loop:", - "detail": "emoji", - "info": "➰", - "apply": "➰" - }, - { - "label": ":double-curly-loop:", - "detail": "emoji", - "info": "➿", - "apply": "➿" - }, - { - "label": ":part-alternation-mark:", - "detail": "emoji", - "info": "〽️", - "apply": "〽️" - }, - { - "label": ":eight-spoked-asterisk:", - "detail": "emoji", - "info": "✳️", - "apply": "✳️" - }, - { - "label": ":eight-pointed-star:", - "detail": "emoji", - "info": "✴️", - "apply": "✴️" - }, - { - "label": ":sparkle:", - "detail": "emoji", - "info": "❇️", - "apply": "❇️" - }, - { - "label": ":copyright:", - "detail": "emoji", - "info": "©️", - "apply": "©️" - }, - { - "label": ":registered:", - "detail": "emoji", - "info": "®️", - "apply": "®️" - }, - { - "label": ":trade-mark:", - "detail": "emoji", - "info": "™️", - "apply": "™️" - }, - { - "label": ":keycap-#:", - "detail": "emoji", - "info": "#️⃣", - "apply": "#️⃣" - }, - { - "label": ":keycap-*:", - "detail": "emoji", - "info": "*️⃣", - "apply": "*️⃣" - }, - { - "label": ":keycap-0:", - "detail": "emoji", - "info": "0️⃣", - "apply": "0️⃣" - }, - { - "label": ":keycap-1:", - "detail": "emoji", - "info": "1️⃣", - "apply": "1️⃣" - }, - { - "label": ":keycap-2:", - "detail": "emoji", - "info": "2️⃣", - "apply": "2️⃣" - }, - { - "label": ":keycap-3:", - "detail": "emoji", - "info": "3️⃣", - "apply": "3️⃣" - }, - { - "label": ":keycap-4:", - "detail": "emoji", - "info": "4️⃣", - "apply": "4️⃣" - }, - { - "label": ":keycap-5:", - "detail": "emoji", - "info": "5️⃣", - "apply": "5️⃣" - }, - { - "label": ":keycap-6:", - "detail": "emoji", - "info": "6️⃣", - "apply": "6️⃣" - }, - { - "label": ":keycap-7:", - "detail": "emoji", - "info": "7️⃣", - "apply": "7️⃣" - }, - { - "label": ":keycap-8:", - "detail": "emoji", - "info": "8️⃣", - "apply": "8️⃣" - }, - { - "label": ":keycap-9:", - "detail": "emoji", - "info": "9️⃣", - "apply": "9️⃣" - }, - { - "label": ":keycap-10:", - "detail": "emoji", - "info": "🔟", - "apply": "🔟" - }, - { - "label": ":input-latin-uppercase:", - "detail": "emoji", - "info": "🔠", - "apply": "🔠" - }, - { - "label": ":input-latin-lowercase:", - "detail": "emoji", - "info": "🔡", - "apply": "🔡" - }, - { - "label": ":input-numbers:", - "detail": "emoji", - "info": "🔢", - "apply": "🔢" - }, - { - "label": ":input-symbols:", - "detail": "emoji", - "info": "🔣", - "apply": "🔣" - }, - { - "label": ":input-latin-letters:", - "detail": "emoji", - "info": "🔤", - "apply": "🔤" - }, - { - "label": ":a-button-(blood-type):", - "detail": "emoji", - "info": "🅰️", - "apply": "🅰️" - }, - { - "label": ":ab-button-(blood-type):", - "detail": "emoji", - "info": "🆎", - "apply": "🆎" - }, - { - "label": ":b-button-(blood-type):", - "detail": "emoji", - "info": "🅱️", - "apply": "🅱️" - }, - { - "label": ":cl-button:", - "detail": "emoji", - "info": "🆑", - "apply": "🆑" - }, - { - "label": ":cool-button:", - "detail": "emoji", - "info": "🆒", - "apply": "🆒" - }, - { - "label": ":free-button:", - "detail": "emoji", - "info": "🆓", - "apply": "🆓" - }, - { - "label": ":information:", - "detail": "emoji", - "info": "ℹ️", - "apply": "ℹ️" - }, - { - "label": ":id-button:", - "detail": "emoji", - "info": "🆔", - "apply": "🆔" - }, - { - "label": ":circled-m:", - "detail": "emoji", - "info": "Ⓜ️", - "apply": "Ⓜ️" - }, - { - "label": ":new-button:", - "detail": "emoji", - "info": "🆕", - "apply": "🆕" - }, - { - "label": ":ng-button:", - "detail": "emoji", - "info": "🆖", - "apply": "🆖" - }, - { - "label": ":o-button-(blood-type):", - "detail": "emoji", - "info": "🅾️", - "apply": "🅾️" - }, - { - "label": ":ok-button:", - "detail": "emoji", - "info": "🆗", - "apply": "🆗" - }, - { - "label": ":p-button:", - "detail": "emoji", - "info": "🅿️", - "apply": "🅿️" - }, - { - "label": ":sos-button:", - "detail": "emoji", - "info": "🆘", - "apply": "🆘" - }, - { - "label": ":up!-button:", - "detail": "emoji", - "info": "🆙", - "apply": "🆙" - }, - { - "label": ":vs-button:", - "detail": "emoji", - "info": "🆚", - "apply": "🆚" - }, - { - "label": ":japanese-“here”-button:", - "detail": "emoji", - "info": "🈁", - "apply": "🈁" - }, - { - "label": ":japanese-“service-charge”-button:", - "detail": "emoji", - "info": "🈂️", - "apply": "🈂️" - }, - { - "label": ":japanese-“monthly-amount”-button:", - "detail": "emoji", - "info": "🈷️", - "apply": "🈷️" - }, - { - "label": ":japanese-“not-free-of-charge”-button:", - "detail": "emoji", - "info": "🈶", - "apply": "🈶" - }, - { - "label": ":japanese-“reserved”-button:", - "detail": "emoji", - "info": "🈯", - "apply": "🈯" - }, - { - "label": ":japanese-“bargain”-button:", - "detail": "emoji", - "info": "🉐", - "apply": "🉐" - }, - { - "label": ":japanese-“discount”-button:", - "detail": "emoji", - "info": "🈹", - "apply": "🈹" - }, - { - "label": ":japanese-“free-of-charge”-button:", - "detail": "emoji", - "info": "🈚", - "apply": "🈚" - }, - { - "label": ":japanese-“prohibited”-button:", - "detail": "emoji", - "info": "🈲", - "apply": "🈲" - }, - { - "label": ":japanese-“acceptable”-button:", - "detail": "emoji", - "info": "🉑", - "apply": "🉑" - }, - { - "label": ":japanese-“application”-button:", - "detail": "emoji", - "info": "🈸", - "apply": "🈸" - }, - { - "label": ":japanese-“passing-grade”-button:", - "detail": "emoji", - "info": "🈴", - "apply": "🈴" - }, - { - "label": ":japanese-“vacancy”-button:", - "detail": "emoji", - "info": "🈳", - "apply": "🈳" - }, - { - "label": ":japanese-“congratulations”-button:", - "detail": "emoji", - "info": "㊗️", - "apply": "㊗️" - }, - { - "label": ":japanese-“secret”-button:", - "detail": "emoji", - "info": "㊙️", - "apply": "㊙️" - }, - { - "label": ":japanese-“open-for-business”-button:", - "detail": "emoji", - "info": "🈺", - "apply": "🈺" - }, - { - "label": ":japanese-“no-vacancy”-button:", - "detail": "emoji", - "info": "🈵", - "apply": "🈵" - }, - { - "label": ":red-circle:", - "detail": "emoji", - "info": "🔴", - "apply": "🔴" - }, - { - "label": ":orange-circle:", - "detail": "emoji", - "info": "🟠", - "apply": "🟠" - }, - { - "label": ":yellow-circle:", - "detail": "emoji", - "info": "🟡", - "apply": "🟡" - }, - { - "label": ":green-circle:", - "detail": "emoji", - "info": "🟢", - "apply": "🟢" - }, - { - "label": ":blue-circle:", - "detail": "emoji", - "info": "🔵", - "apply": "🔵" - }, - { - "label": ":purple-circle:", - "detail": "emoji", - "info": "🟣", - "apply": "🟣" - }, - { - "label": ":brown-circle:", - "detail": "emoji", - "info": "🟤", - "apply": "🟤" - }, - { - "label": ":black-circle:", - "detail": "emoji", - "info": "⚫", - "apply": "⚫" - }, - { - "label": ":white-circle:", - "detail": "emoji", - "info": "⚪", - "apply": "⚪" - }, - { - "label": ":red-square:", - "detail": "emoji", - "info": "🟥", - "apply": "🟥" - }, - { - "label": ":orange-square:", - "detail": "emoji", - "info": "🟧", - "apply": "🟧" - }, - { - "label": ":yellow-square:", - "detail": "emoji", - "info": "🟨", - "apply": "🟨" - }, - { - "label": ":green-square:", - "detail": "emoji", - "info": "🟩", - "apply": "🟩" - }, - { - "label": ":blue-square:", - "detail": "emoji", - "info": "🟦", - "apply": "🟦" - }, - { - "label": ":purple-square:", - "detail": "emoji", - "info": "🟪", - "apply": "🟪" - }, - { - "label": ":brown-square:", - "detail": "emoji", - "info": "🟫", - "apply": "🟫" - }, - { - "label": ":black-large-square:", - "detail": "emoji", - "info": "⬛", - "apply": "⬛" - }, - { - "label": ":white-large-square:", - "detail": "emoji", - "info": "⬜", - "apply": "⬜" - }, - { - "label": ":black-medium-square:", - "detail": "emoji", - "info": "◼️", - "apply": "◼️" - }, - { - "label": ":white-medium-square:", - "detail": "emoji", - "info": "◻️", - "apply": "◻️" - }, - { - "label": ":black-medium-small-square:", - "detail": "emoji", - "info": "◾", - "apply": "◾" - }, - { - "label": ":white-medium-small-square:", - "detail": "emoji", - "info": "◽", - "apply": "◽" - }, - { - "label": ":black-small-square:", - "detail": "emoji", - "info": "▪️", - "apply": "▪️" - }, - { - "label": ":white-small-square:", - "detail": "emoji", - "info": "▫️", - "apply": "▫️" - }, - { - "label": ":large-orange-diamond:", - "detail": "emoji", - "info": "🔶", - "apply": "🔶" - }, - { - "label": ":large-blue-diamond:", - "detail": "emoji", - "info": "🔷", - "apply": "🔷" - }, - { - "label": ":small-orange-diamond:", - "detail": "emoji", - "info": "🔸", - "apply": "🔸" - }, - { - "label": ":small-blue-diamond:", - "detail": "emoji", - "info": "🔹", - "apply": "🔹" - }, - { - "label": ":red-triangle-pointed-up:", - "detail": "emoji", - "info": "🔺", - "apply": "🔺" - }, - { - "label": ":red-triangle-pointed-down:", - "detail": "emoji", - "info": "🔻", - "apply": "🔻" - }, - { - "label": ":diamond-with-a-dot:", - "detail": "emoji", - "info": "💠", - "apply": "💠" - }, - { - "label": ":radio-button:", - "detail": "emoji", - "info": "🔘", - "apply": "🔘" - }, - { - "label": ":white-square-button:", - "detail": "emoji", - "info": "🔳", - "apply": "🔳" - }, - { - "label": ":black-square-button:", - "detail": "emoji", - "info": "🔲", - "apply": "🔲" - }, - { - "label": ":chequered-flag:", - "detail": "emoji", - "info": "🏁", - "apply": "🏁" - }, - { - "label": ":triangular-flag:", - "detail": "emoji", - "info": "🚩", - "apply": "🚩" - }, - { - "label": ":crossed-flags:", - "detail": "emoji", - "info": "🎌", - "apply": "🎌" - }, - { - "label": ":black-flag:", - "detail": "emoji", - "info": "🏴", - "apply": "🏴" - }, - { - "label": ":white-flag:", - "detail": "emoji", - "info": "🏳️", - "apply": "🏳️" - }, - { - "label": ":rainbow-flag:", - "detail": "emoji", - "info": "🏳️‍🌈", - "apply": "🏳️‍🌈" - }, - { - "label": ":transgender-flag:", - "detail": "emoji", - "info": "🏳️‍⚧️", - "apply": "🏳️‍⚧️" - }, - { - "label": ":pirate-flag:", - "detail": "emoji", - "info": "🏴‍☠️", - "apply": "🏴‍☠️" - }, - { - "label": ":flag-ascension-island:", - "detail": "emoji", - "info": "🇦🇨", - "apply": "🇦🇨" - }, - { - "label": ":flag-andorra:", - "detail": "emoji", - "info": "🇦🇩", - "apply": "🇦🇩" - }, - { - "label": ":flag-united-arab-emirates:", - "detail": "emoji", - "info": "🇦🇪", - "apply": "🇦🇪" - }, - { - "label": ":flag-afghanistan:", - "detail": "emoji", - "info": "🇦🇫", - "apply": "🇦🇫" - }, - { - "label": ":flag-antigua-&-barbuda:", - "detail": "emoji", - "info": "🇦🇬", - "apply": "🇦🇬" - }, - { - "label": ":flag-anguilla:", - "detail": "emoji", - "info": "🇦🇮", - "apply": "🇦🇮" - }, - { - "label": ":flag-albania:", - "detail": "emoji", - "info": "🇦🇱", - "apply": "🇦🇱" - }, - { - "label": ":flag-armenia:", - "detail": "emoji", - "info": "🇦🇲", - "apply": "🇦🇲" - }, - { - "label": ":flag-angola:", - "detail": "emoji", - "info": "🇦🇴", - "apply": "🇦🇴" - }, - { - "label": ":flag-antarctica:", - "detail": "emoji", - "info": "🇦🇶", - "apply": "🇦🇶" - }, - { - "label": ":flag-argentina:", - "detail": "emoji", - "info": "🇦🇷", - "apply": "🇦🇷" - }, - { - "label": ":flag-american-samoa:", - "detail": "emoji", - "info": "🇦🇸", - "apply": "🇦🇸" - }, - { - "label": ":flag-austria:", - "detail": "emoji", - "info": "🇦🇹", - "apply": "🇦🇹" - }, - { - "label": ":flag-australia:", - "detail": "emoji", - "info": "🇦🇺", - "apply": "🇦🇺" - }, - { - "label": ":flag-aruba:", - "detail": "emoji", - "info": "🇦🇼", - "apply": "🇦🇼" - }, - { - "label": ":flag-åland-islands:", - "detail": "emoji", - "info": "🇦🇽", - "apply": "🇦🇽" - }, - { - "label": ":flag-azerbaijan:", - "detail": "emoji", - "info": "🇦🇿", - "apply": "🇦🇿" - }, - { - "label": ":flag-bosnia-&-herzegovina:", - "detail": "emoji", - "info": "🇧🇦", - "apply": "🇧🇦" - }, - { - "label": ":flag-barbados:", - "detail": "emoji", - "info": "🇧🇧", - "apply": "🇧🇧" - }, - { - "label": ":flag-bangladesh:", - "detail": "emoji", - "info": "🇧🇩", - "apply": "🇧🇩" - }, - { - "label": ":flag-belgium:", - "detail": "emoji", - "info": "🇧🇪", - "apply": "🇧🇪" - }, - { - "label": ":flag-burkina-faso:", - "detail": "emoji", - "info": "🇧🇫", - "apply": "🇧🇫" - }, - { - "label": ":flag-bulgaria:", - "detail": "emoji", - "info": "🇧🇬", - "apply": "🇧🇬" - }, - { - "label": ":flag-bahrain:", - "detail": "emoji", - "info": "🇧🇭", - "apply": "🇧🇭" - }, - { - "label": ":flag-burundi:", - "detail": "emoji", - "info": "🇧🇮", - "apply": "🇧🇮" - }, - { - "label": ":flag-benin:", - "detail": "emoji", - "info": "🇧🇯", - "apply": "🇧🇯" - }, - { - "label": ":flag-st.-barthélemy:", - "detail": "emoji", - "info": "🇧🇱", - "apply": "🇧🇱" - }, - { - "label": ":flag-bermuda:", - "detail": "emoji", - "info": "🇧🇲", - "apply": "🇧🇲" - }, - { - "label": ":flag-brunei:", - "detail": "emoji", - "info": "🇧🇳", - "apply": "🇧🇳" - }, - { - "label": ":flag-bolivia:", - "detail": "emoji", - "info": "🇧🇴", - "apply": "🇧🇴" - }, - { - "label": ":flag-caribbean-netherlands:", - "detail": "emoji", - "info": "🇧🇶", - "apply": "🇧🇶" - }, - { - "label": ":flag-brazil:", - "detail": "emoji", - "info": "🇧🇷", - "apply": "🇧🇷" - }, - { - "label": ":flag-bahamas:", - "detail": "emoji", - "info": "🇧🇸", - "apply": "🇧🇸" - }, - { - "label": ":flag-bhutan:", - "detail": "emoji", - "info": "🇧🇹", - "apply": "🇧🇹" - }, - { - "label": ":flag-bouvet-island:", - "detail": "emoji", - "info": "🇧🇻", - "apply": "🇧🇻" - }, - { - "label": ":flag-botswana:", - "detail": "emoji", - "info": "🇧🇼", - "apply": "🇧🇼" - }, - { - "label": ":flag-belarus:", - "detail": "emoji", - "info": "🇧🇾", - "apply": "🇧🇾" - }, - { - "label": ":flag-belize:", - "detail": "emoji", - "info": "🇧🇿", - "apply": "🇧🇿" - }, - { - "label": ":flag-canada:", - "detail": "emoji", - "info": "🇨🇦", - "apply": "🇨🇦" - }, - { - "label": ":flag-cocos-(keeling)-islands:", - "detail": "emoji", - "info": "🇨🇨", - "apply": "🇨🇨" - }, - { - "label": ":flag-congo---kinshasa:", - "detail": "emoji", - "info": "🇨🇩", - "apply": "🇨🇩" - }, - { - "label": ":flag-central-african-republic:", - "detail": "emoji", - "info": "🇨🇫", - "apply": "🇨🇫" - }, - { - "label": ":flag-congo---brazzaville:", - "detail": "emoji", - "info": "🇨🇬", - "apply": "🇨🇬" - }, - { - "label": ":flag-switzerland:", - "detail": "emoji", - "info": "🇨🇭", - "apply": "🇨🇭" - }, - { - "label": ":flag-côte-divoire:", - "detail": "emoji", - "info": "🇨🇮", - "apply": "🇨🇮" - }, - { - "label": ":flag-cook-islands:", - "detail": "emoji", - "info": "🇨🇰", - "apply": "🇨🇰" - }, - { - "label": ":flag-chile:", - "detail": "emoji", - "info": "🇨🇱", - "apply": "🇨🇱" - }, - { - "label": ":flag-cameroon:", - "detail": "emoji", - "info": "🇨🇲", - "apply": "🇨🇲" - }, - { - "label": ":flag-china:", - "detail": "emoji", - "info": "🇨🇳", - "apply": "🇨🇳" - }, - { - "label": ":flag-colombia:", - "detail": "emoji", - "info": "🇨🇴", - "apply": "🇨🇴" - }, - { - "label": ":flag-clipperton-island:", - "detail": "emoji", - "info": "🇨🇵", - "apply": "🇨🇵" - }, - { - "label": ":flag-costa-rica:", - "detail": "emoji", - "info": "🇨🇷", - "apply": "🇨🇷" - }, - { - "label": ":flag-cuba:", - "detail": "emoji", - "info": "🇨🇺", - "apply": "🇨🇺" - }, - { - "label": ":flag-cape-verde:", - "detail": "emoji", - "info": "🇨🇻", - "apply": "🇨🇻" - }, - { - "label": ":flag-curaçao:", - "detail": "emoji", - "info": "🇨🇼", - "apply": "🇨🇼" - }, - { - "label": ":flag-christmas-island:", - "detail": "emoji", - "info": "🇨🇽", - "apply": "🇨🇽" - }, - { - "label": ":flag-cyprus:", - "detail": "emoji", - "info": "🇨🇾", - "apply": "🇨🇾" - }, - { - "label": ":flag-czechia:", - "detail": "emoji", - "info": "🇨🇿", - "apply": "🇨🇿" - }, - { - "label": ":flag-germany:", - "detail": "emoji", - "info": "🇩🇪", - "apply": "🇩🇪" - }, - { - "label": ":flag-diego-garcia:", - "detail": "emoji", - "info": "🇩🇬", - "apply": "🇩🇬" - }, - { - "label": ":flag-djibouti:", - "detail": "emoji", - "info": "🇩🇯", - "apply": "🇩🇯" - }, - { - "label": ":flag-denmark:", - "detail": "emoji", - "info": "🇩🇰", - "apply": "🇩🇰" - }, - { - "label": ":flag-dominica:", - "detail": "emoji", - "info": "🇩🇲", - "apply": "🇩🇲" - }, - { - "label": ":flag-dominican-republic:", - "detail": "emoji", - "info": "🇩🇴", - "apply": "🇩🇴" - }, - { - "label": ":flag-algeria:", - "detail": "emoji", - "info": "🇩🇿", - "apply": "🇩🇿" - }, - { - "label": ":flag-ceuta-&-melilla:", - "detail": "emoji", - "info": "🇪🇦", - "apply": "🇪🇦" - }, - { - "label": ":flag-ecuador:", - "detail": "emoji", - "info": "🇪🇨", - "apply": "🇪🇨" - }, - { - "label": ":flag-estonia:", - "detail": "emoji", - "info": "🇪🇪", - "apply": "🇪🇪" - }, - { - "label": ":flag-egypt:", - "detail": "emoji", - "info": "🇪🇬", - "apply": "🇪🇬" - }, - { - "label": ":flag-western-sahara:", - "detail": "emoji", - "info": "🇪🇭", - "apply": "🇪🇭" - }, - { - "label": ":flag-eritrea:", - "detail": "emoji", - "info": "🇪🇷", - "apply": "🇪🇷" - }, - { - "label": ":flag-spain:", - "detail": "emoji", - "info": "🇪🇸", - "apply": "🇪🇸" - }, - { - "label": ":flag-ethiopia:", - "detail": "emoji", - "info": "🇪🇹", - "apply": "🇪🇹" - }, - { - "label": ":flag-european-union:", - "detail": "emoji", - "info": "🇪🇺", - "apply": "🇪🇺" - }, - { - "label": ":flag-finland:", - "detail": "emoji", - "info": "🇫🇮", - "apply": "🇫🇮" - }, - { - "label": ":flag-fiji:", - "detail": "emoji", - "info": "🇫🇯", - "apply": "🇫🇯" - }, - { - "label": ":flag-falkland-islands:", - "detail": "emoji", - "info": "🇫🇰", - "apply": "🇫🇰" - }, - { - "label": ":flag-micronesia:", - "detail": "emoji", - "info": "🇫🇲", - "apply": "🇫🇲" - }, - { - "label": ":flag-faroe-islands:", - "detail": "emoji", - "info": "🇫🇴", - "apply": "🇫🇴" - }, - { - "label": ":flag-france:", - "detail": "emoji", - "info": "🇫🇷", - "apply": "🇫🇷" - }, - { - "label": ":flag-gabon:", - "detail": "emoji", - "info": "🇬🇦", - "apply": "🇬🇦" - }, - { - "label": ":flag-united-kingdom:", - "detail": "emoji", - "info": "🇬🇧", - "apply": "🇬🇧" - }, - { - "label": ":flag-grenada:", - "detail": "emoji", - "info": "🇬🇩", - "apply": "🇬🇩" - }, - { - "label": ":flag-georgia:", - "detail": "emoji", - "info": "🇬🇪", - "apply": "🇬🇪" - }, - { - "label": ":flag-french-guiana:", - "detail": "emoji", - "info": "🇬🇫", - "apply": "🇬🇫" - }, - { - "label": ":flag-guernsey:", - "detail": "emoji", - "info": "🇬🇬", - "apply": "🇬🇬" - }, - { - "label": ":flag-ghana:", - "detail": "emoji", - "info": "🇬🇭", - "apply": "🇬🇭" - }, - { - "label": ":flag-gibraltar:", - "detail": "emoji", - "info": "🇬🇮", - "apply": "🇬🇮" - }, - { - "label": ":flag-greenland:", - "detail": "emoji", - "info": "🇬🇱", - "apply": "🇬🇱" - }, - { - "label": ":flag-gambia:", - "detail": "emoji", - "info": "🇬🇲", - "apply": "🇬🇲" - }, - { - "label": ":flag-guinea:", - "detail": "emoji", - "info": "🇬🇳", - "apply": "🇬🇳" - }, - { - "label": ":flag-guadeloupe:", - "detail": "emoji", - "info": "🇬🇵", - "apply": "🇬🇵" - }, - { - "label": ":flag-equatorial-guinea:", - "detail": "emoji", - "info": "🇬🇶", - "apply": "🇬🇶" - }, - { - "label": ":flag-greece:", - "detail": "emoji", - "info": "🇬🇷", - "apply": "🇬🇷" - }, - { - "label": ":flag-south-georgia-&-south-sandwich-islands:", - "detail": "emoji", - "info": "🇬🇸", - "apply": "🇬🇸" - }, - { - "label": ":flag-guatemala:", - "detail": "emoji", - "info": "🇬🇹", - "apply": "🇬🇹" - }, - { - "label": ":flag-guam:", - "detail": "emoji", - "info": "🇬🇺", - "apply": "🇬🇺" - }, - { - "label": ":flag-guinea-bissau:", - "detail": "emoji", - "info": "🇬🇼", - "apply": "🇬🇼" - }, - { - "label": ":flag-guyana:", - "detail": "emoji", - "info": "🇬🇾", - "apply": "🇬🇾" - }, - { - "label": ":flag-hong-kong-sar-china:", - "detail": "emoji", - "info": "🇭🇰", - "apply": "🇭🇰" - }, - { - "label": ":flag-heard-&-mcdonald-islands:", - "detail": "emoji", - "info": "🇭🇲", - "apply": "🇭🇲" - }, - { - "label": ":flag-honduras:", - "detail": "emoji", - "info": "🇭🇳", - "apply": "🇭🇳" - }, - { - "label": ":flag-croatia:", - "detail": "emoji", - "info": "🇭🇷", - "apply": "🇭🇷" - }, - { - "label": ":flag-haiti:", - "detail": "emoji", - "info": "🇭🇹", - "apply": "🇭🇹" - }, - { - "label": ":flag-hungary:", - "detail": "emoji", - "info": "🇭🇺", - "apply": "🇭🇺" - }, - { - "label": ":flag-canary-islands:", - "detail": "emoji", - "info": "🇮🇨", - "apply": "🇮🇨" - }, - { - "label": ":flag-indonesia:", - "detail": "emoji", - "info": "🇮🇩", - "apply": "🇮🇩" - }, - { - "label": ":flag-ireland:", - "detail": "emoji", - "info": "🇮🇪", - "apply": "🇮🇪" - }, - { - "label": ":flag-israel:", - "detail": "emoji", - "info": "🇮🇱", - "apply": "🇮🇱" - }, - { - "label": ":flag-isle-of-man:", - "detail": "emoji", - "info": "🇮🇲", - "apply": "🇮🇲" - }, - { - "label": ":flag-india:", - "detail": "emoji", - "info": "🇮🇳", - "apply": "🇮🇳" - }, - { - "label": ":flag-british-indian-ocean-territory:", - "detail": "emoji", - "info": "🇮🇴", - "apply": "🇮🇴" - }, - { - "label": ":flag-iraq:", - "detail": "emoji", - "info": "🇮🇶", - "apply": "🇮🇶" - }, - { - "label": ":flag-iran:", - "detail": "emoji", - "info": "🇮🇷", - "apply": "🇮🇷" - }, - { - "label": ":flag-iceland:", - "detail": "emoji", - "info": "🇮🇸", - "apply": "🇮🇸" - }, - { - "label": ":flag-italy:", - "detail": "emoji", - "info": "🇮🇹", - "apply": "🇮🇹" - }, - { - "label": ":flag-jersey:", - "detail": "emoji", - "info": "🇯🇪", - "apply": "🇯🇪" - }, - { - "label": ":flag-jamaica:", - "detail": "emoji", - "info": "🇯🇲", - "apply": "🇯🇲" - }, - { - "label": ":flag-jordan:", - "detail": "emoji", - "info": "🇯🇴", - "apply": "🇯🇴" - }, - { - "label": ":flag-japan:", - "detail": "emoji", - "info": "🇯🇵", - "apply": "🇯🇵" - }, - { - "label": ":flag-kenya:", - "detail": "emoji", - "info": "🇰🇪", - "apply": "🇰🇪" - }, - { - "label": ":flag-kyrgyzstan:", - "detail": "emoji", - "info": "🇰🇬", - "apply": "🇰🇬" - }, - { - "label": ":flag-cambodia:", - "detail": "emoji", - "info": "🇰🇭", - "apply": "🇰🇭" - }, - { - "label": ":flag-kiribati:", - "detail": "emoji", - "info": "🇰🇮", - "apply": "🇰🇮" - }, - { - "label": ":flag-comoros:", - "detail": "emoji", - "info": "🇰🇲", - "apply": "🇰🇲" - }, - { - "label": ":flag-st.-kitts-&-nevis:", - "detail": "emoji", - "info": "🇰🇳", - "apply": "🇰🇳" - }, - { - "label": ":flag-north-korea:", - "detail": "emoji", - "info": "🇰🇵", - "apply": "🇰🇵" - }, - { - "label": ":flag-south-korea:", - "detail": "emoji", - "info": "🇰🇷", - "apply": "🇰🇷" - }, - { - "label": ":flag-kuwait:", - "detail": "emoji", - "info": "🇰🇼", - "apply": "🇰🇼" - }, - { - "label": ":flag-cayman-islands:", - "detail": "emoji", - "info": "🇰🇾", - "apply": "🇰🇾" - }, - { - "label": ":flag-kazakhstan:", - "detail": "emoji", - "info": "🇰🇿", - "apply": "🇰🇿" - }, - { - "label": ":flag-laos:", - "detail": "emoji", - "info": "🇱🇦", - "apply": "🇱🇦" - }, - { - "label": ":flag-lebanon:", - "detail": "emoji", - "info": "🇱🇧", - "apply": "🇱🇧" - }, - { - "label": ":flag-st.-lucia:", - "detail": "emoji", - "info": "🇱🇨", - "apply": "🇱🇨" - }, - { - "label": ":flag-liechtenstein:", - "detail": "emoji", - "info": "🇱🇮", - "apply": "🇱🇮" - }, - { - "label": ":flag-sri-lanka:", - "detail": "emoji", - "info": "🇱🇰", - "apply": "🇱🇰" - }, - { - "label": ":flag-liberia:", - "detail": "emoji", - "info": "🇱🇷", - "apply": "🇱🇷" - }, - { - "label": ":flag-lesotho:", - "detail": "emoji", - "info": "🇱🇸", - "apply": "🇱🇸" - }, - { - "label": ":flag-lithuania:", - "detail": "emoji", - "info": "🇱🇹", - "apply": "🇱🇹" - }, - { - "label": ":flag-luxembourg:", - "detail": "emoji", - "info": "🇱🇺", - "apply": "🇱🇺" - }, - { - "label": ":flag-latvia:", - "detail": "emoji", - "info": "🇱🇻", - "apply": "🇱🇻" - }, - { - "label": ":flag-libya:", - "detail": "emoji", - "info": "🇱🇾", - "apply": "🇱🇾" - }, - { - "label": ":flag-morocco:", - "detail": "emoji", - "info": "🇲🇦", - "apply": "🇲🇦" - }, - { - "label": ":flag-monaco:", - "detail": "emoji", - "info": "🇲🇨", - "apply": "🇲🇨" - }, - { - "label": ":flag-moldova:", - "detail": "emoji", - "info": "🇲🇩", - "apply": "🇲🇩" - }, - { - "label": ":flag-montenegro:", - "detail": "emoji", - "info": "🇲🇪", - "apply": "🇲🇪" - }, - { - "label": ":flag-st.-martin:", - "detail": "emoji", - "info": "🇲🇫", - "apply": "🇲🇫" - }, - { - "label": ":flag-madagascar:", - "detail": "emoji", - "info": "🇲🇬", - "apply": "🇲🇬" - }, - { - "label": ":flag-marshall-islands:", - "detail": "emoji", - "info": "🇲🇭", - "apply": "🇲🇭" - }, - { - "label": ":flag-north-macedonia:", - "detail": "emoji", - "info": "🇲🇰", - "apply": "🇲🇰" - }, - { - "label": ":flag-mali:", - "detail": "emoji", - "info": "🇲🇱", - "apply": "🇲🇱" - }, - { - "label": ":flag-myanmar-(burma):", - "detail": "emoji", - "info": "🇲🇲", - "apply": "🇲🇲" - }, - { - "label": ":flag-mongolia:", - "detail": "emoji", - "info": "🇲🇳", - "apply": "🇲🇳" - }, - { - "label": ":flag-macao-sar-china:", - "detail": "emoji", - "info": "🇲🇴", - "apply": "🇲🇴" - }, - { - "label": ":flag-northern-mariana-islands:", - "detail": "emoji", - "info": "🇲🇵", - "apply": "🇲🇵" - }, - { - "label": ":flag-martinique:", - "detail": "emoji", - "info": "🇲🇶", - "apply": "🇲🇶" - }, - { - "label": ":flag-mauritania:", - "detail": "emoji", - "info": "🇲🇷", - "apply": "🇲🇷" - }, - { - "label": ":flag-montserrat:", - "detail": "emoji", - "info": "🇲🇸", - "apply": "🇲🇸" - }, - { - "label": ":flag-malta:", - "detail": "emoji", - "info": "🇲🇹", - "apply": "🇲🇹" - }, - { - "label": ":flag-mauritius:", - "detail": "emoji", - "info": "🇲🇺", - "apply": "🇲🇺" - }, - { - "label": ":flag-maldives:", - "detail": "emoji", - "info": "🇲🇻", - "apply": "🇲🇻" - }, - { - "label": ":flag-malawi:", - "detail": "emoji", - "info": "🇲🇼", - "apply": "🇲🇼" - }, - { - "label": ":flag-mexico:", - "detail": "emoji", - "info": "🇲🇽", - "apply": "🇲🇽" - }, - { - "label": ":flag-malaysia:", - "detail": "emoji", - "info": "🇲🇾", - "apply": "🇲🇾" - }, - { - "label": ":flag-mozambique:", - "detail": "emoji", - "info": "🇲🇿", - "apply": "🇲🇿" - }, - { - "label": ":flag-namibia:", - "detail": "emoji", - "info": "🇳🇦", - "apply": "🇳🇦" - }, - { - "label": ":flag-new-caledonia:", - "detail": "emoji", - "info": "🇳🇨", - "apply": "🇳🇨" - }, - { - "label": ":flag-niger:", - "detail": "emoji", - "info": "🇳🇪", - "apply": "🇳🇪" - }, - { - "label": ":flag-norfolk-island:", - "detail": "emoji", - "info": "🇳🇫", - "apply": "🇳🇫" - }, - { - "label": ":flag-nigeria:", - "detail": "emoji", - "info": "🇳🇬", - "apply": "🇳🇬" - }, - { - "label": ":flag-nicaragua:", - "detail": "emoji", - "info": "🇳🇮", - "apply": "🇳🇮" - }, - { - "label": ":flag-netherlands:", - "detail": "emoji", - "info": "🇳🇱", - "apply": "🇳🇱" - }, - { - "label": ":flag-norway:", - "detail": "emoji", - "info": "🇳🇴", - "apply": "🇳🇴" - }, - { - "label": ":flag-nepal:", - "detail": "emoji", - "info": "🇳🇵", - "apply": "🇳🇵" - }, - { - "label": ":flag-nauru:", - "detail": "emoji", - "info": "🇳🇷", - "apply": "🇳🇷" - }, - { - "label": ":flag-niue:", - "detail": "emoji", - "info": "🇳🇺", - "apply": "🇳🇺" - }, - { - "label": ":flag-new-zealand:", - "detail": "emoji", - "info": "🇳🇿", - "apply": "🇳🇿" - }, - { - "label": ":flag-oman:", - "detail": "emoji", - "info": "🇴🇲", - "apply": "🇴🇲" - }, - { - "label": ":flag-panama:", - "detail": "emoji", - "info": "🇵🇦", - "apply": "🇵🇦" - }, - { - "label": ":flag-peru:", - "detail": "emoji", - "info": "🇵🇪", - "apply": "🇵🇪" - }, - { - "label": ":flag-french-polynesia:", - "detail": "emoji", - "info": "🇵🇫", - "apply": "🇵🇫" - }, - { - "label": ":flag-papua-new-guinea:", - "detail": "emoji", - "info": "🇵🇬", - "apply": "🇵🇬" - }, - { - "label": ":flag-philippines:", - "detail": "emoji", - "info": "🇵🇭", - "apply": "🇵🇭" - }, - { - "label": ":flag-pakistan:", - "detail": "emoji", - "info": "🇵🇰", - "apply": "🇵🇰" - }, - { - "label": ":flag-poland:", - "detail": "emoji", - "info": "🇵🇱", - "apply": "🇵🇱" - }, - { - "label": ":flag-st.-pierre-&-miquelon:", - "detail": "emoji", - "info": "🇵🇲", - "apply": "🇵🇲" - }, - { - "label": ":flag-pitcairn-islands:", - "detail": "emoji", - "info": "🇵🇳", - "apply": "🇵🇳" - }, - { - "label": ":flag-puerto-rico:", - "detail": "emoji", - "info": "🇵🇷", - "apply": "🇵🇷" - }, - { - "label": ":flag-palestinian-territories:", - "detail": "emoji", - "info": "🇵🇸", - "apply": "🇵🇸" - }, - { - "label": ":flag-portugal:", - "detail": "emoji", - "info": "🇵🇹", - "apply": "🇵🇹" - }, - { - "label": ":flag-palau:", - "detail": "emoji", - "info": "🇵🇼", - "apply": "🇵🇼" - }, - { - "label": ":flag-paraguay:", - "detail": "emoji", - "info": "🇵🇾", - "apply": "🇵🇾" - }, - { - "label": ":flag-qatar:", - "detail": "emoji", - "info": "🇶🇦", - "apply": "🇶🇦" - }, - { - "label": ":flag-réunion:", - "detail": "emoji", - "info": "🇷🇪", - "apply": "🇷🇪" - }, - { - "label": ":flag-romania:", - "detail": "emoji", - "info": "🇷🇴", - "apply": "🇷🇴" - }, - { - "label": ":flag-serbia:", - "detail": "emoji", - "info": "🇷🇸", - "apply": "🇷🇸" - }, - { - "label": ":flag-russia:", - "detail": "emoji", - "info": "🇷🇺", - "apply": "🇷🇺" - }, - { - "label": ":flag-rwanda:", - "detail": "emoji", - "info": "🇷🇼", - "apply": "🇷🇼" - }, - { - "label": ":flag-saudi-arabia:", - "detail": "emoji", - "info": "🇸🇦", - "apply": "🇸🇦" - }, - { - "label": ":flag-solomon-islands:", - "detail": "emoji", - "info": "🇸🇧", - "apply": "🇸🇧" - }, - { - "label": ":flag-seychelles:", - "detail": "emoji", - "info": "🇸🇨", - "apply": "🇸🇨" - }, - { - "label": ":flag-sudan:", - "detail": "emoji", - "info": "🇸🇩", - "apply": "🇸🇩" - }, - { - "label": ":flag-sweden:", - "detail": "emoji", - "info": "🇸🇪", - "apply": "🇸🇪" - }, - { - "label": ":flag-singapore:", - "detail": "emoji", - "info": "🇸🇬", - "apply": "🇸🇬" - }, - { - "label": ":flag-st.-helena:", - "detail": "emoji", - "info": "🇸🇭", - "apply": "🇸🇭" - }, - { - "label": ":flag-slovenia:", - "detail": "emoji", - "info": "🇸🇮", - "apply": "🇸🇮" - }, - { - "label": ":flag-svalbard-&-jan-mayen:", - "detail": "emoji", - "info": "🇸🇯", - "apply": "🇸🇯" - }, - { - "label": ":flag-slovakia:", - "detail": "emoji", - "info": "🇸🇰", - "apply": "🇸🇰" - }, - { - "label": ":flag-sierra-leone:", - "detail": "emoji", - "info": "🇸🇱", - "apply": "🇸🇱" - }, - { - "label": ":flag-san-marino:", - "detail": "emoji", - "info": "🇸🇲", - "apply": "🇸🇲" - }, - { - "label": ":flag-senegal:", - "detail": "emoji", - "info": "🇸🇳", - "apply": "🇸🇳" - }, - { - "label": ":flag-somalia:", - "detail": "emoji", - "info": "🇸🇴", - "apply": "🇸🇴" - }, - { - "label": ":flag-suriname:", - "detail": "emoji", - "info": "🇸🇷", - "apply": "🇸🇷" - }, - { - "label": ":flag-south-sudan:", - "detail": "emoji", - "info": "🇸🇸", - "apply": "🇸🇸" - }, - { - "label": ":flag-são-tomé-&-príncipe:", - "detail": "emoji", - "info": "🇸🇹", - "apply": "🇸🇹" - }, - { - "label": ":flag-el-salvador:", - "detail": "emoji", - "info": "🇸🇻", - "apply": "🇸🇻" - }, - { - "label": ":flag-sint-maarten:", - "detail": "emoji", - "info": "🇸🇽", - "apply": "🇸🇽" - }, - { - "label": ":flag-syria:", - "detail": "emoji", - "info": "🇸🇾", - "apply": "🇸🇾" - }, - { - "label": ":flag-eswatini:", - "detail": "emoji", - "info": "🇸🇿", - "apply": "🇸🇿" - }, - { - "label": ":flag-tristan-da-cunha:", - "detail": "emoji", - "info": "🇹🇦", - "apply": "🇹🇦" - }, - { - "label": ":flag-turks-&-caicos-islands:", - "detail": "emoji", - "info": "🇹🇨", - "apply": "🇹🇨" - }, - { - "label": ":flag-chad:", - "detail": "emoji", - "info": "🇹🇩", - "apply": "🇹🇩" - }, - { - "label": ":flag-french-southern-territories:", - "detail": "emoji", - "info": "🇹🇫", - "apply": "🇹🇫" - }, - { - "label": ":flag-togo:", - "detail": "emoji", - "info": "🇹🇬", - "apply": "🇹🇬" - }, - { - "label": ":flag-thailand:", - "detail": "emoji", - "info": "🇹🇭", - "apply": "🇹🇭" - }, - { - "label": ":flag-tajikistan:", - "detail": "emoji", - "info": "🇹🇯", - "apply": "🇹🇯" - }, - { - "label": ":flag-tokelau:", - "detail": "emoji", - "info": "🇹🇰", - "apply": "🇹🇰" - }, - { - "label": ":flag-timor-leste:", - "detail": "emoji", - "info": "🇹🇱", - "apply": "🇹🇱" - }, - { - "label": ":flag-turkmenistan:", - "detail": "emoji", - "info": "🇹🇲", - "apply": "🇹🇲" - }, - { - "label": ":flag-tunisia:", - "detail": "emoji", - "info": "🇹🇳", - "apply": "🇹🇳" - }, - { - "label": ":flag-tonga:", - "detail": "emoji", - "info": "🇹🇴", - "apply": "🇹🇴" - }, - { - "label": ":flag-turkey:", - "detail": "emoji", - "info": "🇹🇷", - "apply": "🇹🇷" - }, - { - "label": ":flag-trinidad-&-tobago:", - "detail": "emoji", - "info": "🇹🇹", - "apply": "🇹🇹" - }, - { - "label": ":flag-tuvalu:", - "detail": "emoji", - "info": "🇹🇻", - "apply": "🇹🇻" - }, - { - "label": ":flag-taiwan:", - "detail": "emoji", - "info": "🇹🇼", - "apply": "🇹🇼" - }, - { - "label": ":flag-tanzania:", - "detail": "emoji", - "info": "🇹🇿", - "apply": "🇹🇿" - }, - { - "label": ":flag-ukraine:", - "detail": "emoji", - "info": "🇺🇦", - "apply": "🇺🇦" - }, - { - "label": ":flag-uganda:", - "detail": "emoji", - "info": "🇺🇬", - "apply": "🇺🇬" - }, - { - "label": ":flag-u.s.-outlying-islands:", - "detail": "emoji", - "info": "🇺🇲", - "apply": "🇺🇲" - }, - { - "label": ":flag-united-nations:", - "detail": "emoji", - "info": "🇺🇳", - "apply": "🇺🇳" - }, - { - "label": ":flag-united-states:", - "detail": "emoji", - "info": "🇺🇸", - "apply": "🇺🇸" - }, - { - "label": ":flag-uruguay:", - "detail": "emoji", - "info": "🇺🇾", - "apply": "🇺🇾" - }, - { - "label": ":flag-uzbekistan:", - "detail": "emoji", - "info": "🇺🇿", - "apply": "🇺🇿" - }, - { - "label": ":flag-vatican-city:", - "detail": "emoji", - "info": "🇻🇦", - "apply": "🇻🇦" - }, - { - "label": ":flag-st.-vincent-&-grenadines:", - "detail": "emoji", - "info": "🇻🇨", - "apply": "🇻🇨" - }, - { - "label": ":flag-venezuela:", - "detail": "emoji", - "info": "🇻🇪", - "apply": "🇻🇪" - }, - { - "label": ":flag-british-virgin-islands:", - "detail": "emoji", - "info": "🇻🇬", - "apply": "🇻🇬" - }, - { - "label": ":flag-u.s.-virgin-islands:", - "detail": "emoji", - "info": "🇻🇮", - "apply": "🇻🇮" - }, - { - "label": ":flag-vietnam:", - "detail": "emoji", - "info": "🇻🇳", - "apply": "🇻🇳" - }, - { - "label": ":flag-vanuatu:", - "detail": "emoji", - "info": "🇻🇺", - "apply": "🇻🇺" - }, - { - "label": ":flag-wallis-&-futuna:", - "detail": "emoji", - "info": "🇼🇫", - "apply": "🇼🇫" - }, - { - "label": ":flag-samoa:", - "detail": "emoji", - "info": "🇼🇸", - "apply": "🇼🇸" - }, - { - "label": ":flag-kosovo:", - "detail": "emoji", - "info": "🇽🇰", - "apply": "🇽🇰" - }, - { - "label": ":flag-yemen:", - "detail": "emoji", - "info": "🇾🇪", - "apply": "🇾🇪" - }, - { - "label": ":flag-mayotte:", - "detail": "emoji", - "info": "🇾🇹", - "apply": "🇾🇹" - }, - { - "label": ":flag-south-africa:", - "detail": "emoji", - "info": "🇿🇦", - "apply": "🇿🇦" - }, - { - "label": ":flag-zambia:", - "detail": "emoji", - "info": "🇿🇲", - "apply": "🇿🇲" - }, - { - "label": ":flag-zimbabwe:", - "detail": "emoji", - "info": "🇿🇼", - "apply": "🇿🇼" - }, - { - "label": ":flag-england:", - "detail": "emoji", - "info": "🏴󠁧󠁢󠁥󠁮󠁧󠁿", - "apply": "🏴󠁧󠁢󠁥󠁮󠁧󠁿" - }, - { - "label": ":flag-scotland:", - "detail": "emoji", - "info": "🏴󠁧󠁢󠁳󠁣󠁴󠁿", - "apply": "🏴󠁧󠁢󠁳󠁣󠁴󠁿" - }, - { - "label": ":flag-wales:", - "detail": "emoji", - "info": "🏴󠁧󠁢󠁷󠁬󠁳󠁿", - "apply": "🏴󠁧󠁢󠁷󠁬󠁳󠁿" - } -] \ No newline at end of file diff --git a/src/autocomplete/emojis.ts b/src/autocomplete/emojis.ts deleted file mode 100644 index 7c20be4..0000000 --- a/src/autocomplete/emojis.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Completion, CompletionContext, CompletionSource } from "@codemirror/autocomplete"; -import emojis from "./emojis.json"; - -const emojiCompletions: Completion[] = emojis; - -/** - * Function that creates the `emojiCompletionSource`. - * This function can be used in the editor as a completion source. - * - * Emoji list has been generated by using https://github.com/github/gemoji/blob/master/db/emoji.json - * from https://github.com/github/gemoji - */ -export const emojiCompletionSource: CompletionSource = function(context: CompletionContext) { - const before = context.matchBefore(/:.*/); - // If completion wasn't explicitly started and there - // is no word before the cursor, don't open completions. - if (!context.explicit && !before) return null; - return { - from: before ? before.from : context.pos, - options: emojiCompletions, - validFor: /^.*$/ - } -} \ No newline at end of file diff --git a/src/autocomplete/symbols.json b/src/autocomplete/symbols.json deleted file mode 100644 index ffe196a..0000000 --- a/src/autocomplete/symbols.json +++ /dev/null @@ -1,656 +0,0 @@ -[ - { - "label": "\\alpha", - "type": "symbol", - "apply": "α", - "symbolPanelCategory": 0 - }, - { - "label": "\\beta", - "type": "symbol", - "apply": "β", - "symbolPanelCategory": 0 - }, - { - "label": "\\gamma", - "type": "symbol", - "apply": "γ", - "symbolPanelCategory": 0 - }, - { - "label": "\\delta", - "type": "symbol", - "apply": "δ", - "symbolPanelCategory": 0 - }, - { - "label": "\\epsilon", - "type": "symbol", - "apply": "ε", - "symbolPanelCategory": 0 - }, - { - "label": "\\zeta", - "type": "symbol", - "apply": "ζ", - "symbolPanelCategory": 0 - }, - { - "label": "\\eta", - "type": "symbol", - "apply": "η", - "symbolPanelCategory": 0 - }, - { - "label": "\\theta", - "type": "symbol", - "apply": "θ", - "symbolPanelCategory": 0 - }, - { - "label": "\\iota", - "type": "symbol", - "apply": "ι", - "symbolPanelCategory": 0 - }, - { - "label": "\\kappa", - "type": "symbol", - "apply": "κ", - "symbolPanelCategory": 0 - }, - { - "label": "\\lambda", - "type": "symbol", - "apply": "λ", - "symbolPanelCategory": 0 - }, - { - "label": "\\mu", - "type": "symbol", - "apply": "μ", - "symbolPanelCategory": 0 - }, - { - "label": "\\nu", - "type": "symbol", - "apply": "ν", - "symbolPanelCategory": 0 - }, - { - "label": "\\ksi", - "type": "symbol", - "apply": "ξ", - "symbolPanelCategory": 0 - }, - { - "label": "\\pi", - "type": "symbol", - "apply": "π", - "symbolPanelCategory": 0 - }, - { - "label": "\\omikron", - "type": "symbol", - "apply": "ο", - "symbolPanelCategory": 0 - }, - { - "label": "\\rho", - "type": "symbol", - "apply": "ρ", - "symbolPanelCategory": 0 - }, - { - "label": "\\sigma1", - "type": "symbol", - "apply": "ς", - "symbolPanelCategory": 0 - }, - { - "label": "\\sigma2", - "type": "symbol", - "apply": "σ", - "symbolPanelCategory": 0 - }, - { - "label": "\\tau", - "type": "symbol", - "apply": "τ", - "symbolPanelCategory": 0 - }, - { - "label": "\\upsilon", - "type": "symbol", - "apply": "υ", - "symbolPanelCategory": 0 - }, - { - "label": "\\phi1", - "type": "symbol", - "apply": "ϕ", - "symbolPanelCategory": 0 - }, - { - "label": "\\phi2", - "type": "symbol", - "apply": "φ", - "symbolPanelCategory": 0 - }, - { - "label": "\\chi", - "type": "symbol", - "apply": "χ", - "symbolPanelCategory": 0 - }, - { - "label": "\\psi", - "type": "symbol", - "apply": "ψ", - "symbolPanelCategory": 0 - }, - { - "label": "\\omega", - "type": "symbol", - "apply": "ω", - "symbolPanelCategory": 0 - }, - { - "label": "\\Alpha", - "type": "symbol", - "apply": "Α", - "symbolPanelCategory": 1 - }, - { - "label": "\\Beta", - "type": "symbol", - "apply": "Β", - "symbolPanelCategory": 1 - }, - { - "label": "\\Gamma", - "type": "symbol", - "apply": "Γ", - "symbolPanelCategory": 1 - }, - { - "label": "\\Delta", - "type": "symbol", - "apply": "Δ", - "symbolPanelCategory": 1 - }, - { - "label": "\\Epsilon", - "type": "symbol", - "apply": "Ε", - "symbolPanelCategory": 1 - }, - { - "label": "\\Zeta", - "type": "symbol", - "apply": "Ζ", - "symbolPanelCategory": 1 - }, - { - "label": "\\Eta", - "type": "symbol", - "apply": "Η", - "symbolPanelCategory": 1 - }, - { - "label": "\\Theta", - "type": "symbol", - "apply": "Θ", - "symbolPanelCategory": 1 - }, - { - "label": "\\Iota", - "type": "symbol", - "apply": "Ι", - "symbolPanelCategory": 1 - }, - { - "label": "\\Kappa", - "type": "symbol", - "apply": "Κ", - "symbolPanelCategory": 1 - }, - { - "label": "\\Lambda", - "type": "symbol", - "apply": "Λ", - "symbolPanelCategory": 1 - }, - { - "label": "\\Mu", - "type": "symbol", - "apply": "Μ", - "symbolPanelCategory": 1 - }, - { - "label": "\\Nu", - "type": "symbol", - "apply": "Ν", - "symbolPanelCategory": 1 - }, - { - "label": "\\Ksi", - "type": "symbol", - "apply": "Ξ", - "symbolPanelCategory": 1 - }, - { - "label": "\\Omicron", - "type": "symbol", - "apply": "Ο", - "symbolPanelCategory": 1 - }, - { - "label": "\\Pi", - "type": "symbol", - "apply": "Π", - "symbolPanelCategory": 1 - }, - { - "label": "\\Rho", - "type": "symbol", - "apply": "Ρ", - "symbolPanelCategory": 1 - }, - { - "label": "\\Sigma", - "type": "symbol", - "apply": "Σ", - "symbolPanelCategory": 1 - }, - { - "label": "\\Tau", - "type": "symbol", - "apply": "Τ", - "symbolPanelCategory": 1 - }, - { - "label": "\\Upsilon", - "type": "symbol", - "apply": "Υ", - "symbolPanelCategory": 1 - }, - { - "label": "\\Phi", - "type": "symbol", - "apply": "Φ", - "symbolPanelCategory": 1 - }, - { - "label": "\\Chi", - "type": "symbol", - "apply": "Χ", - "symbolPanelCategory": 1 - }, - { - "label": "\\Psi", - "type": "symbol", - "apply": "Ψ", - "symbolPanelCategory": 1 - }, - { - "label": "\\Omega", - "type": "symbol", - "apply": "Ω", - "symbolPanelCategory": 1 - }, - { - "label": "\\forall", - "type": "symbol", - "apply": "∀", - "symbolPanelCategory": 2 - }, - { - "label": "\\exists", - "type": "symbol", - "apply": "∃", - "symbolPanelCategory": 2 - }, - { - "label": "\\in", - "type": "symbol", - "apply": "∈", - "symbolPanelCategory": 2 - }, - { - "label": "\\QED", - "type": "symbol", - "apply": "∎", - "symbolPanelCategory": 2 - }, - { - "label": "\\infty", - "type": "symbol", - "apply": "∞", - "symbolPanelCategory": 2 - }, - { - "label": "\\and", - "type": "symbol", - "apply": "∧", - "symbolPanelCategory": 2 - }, - { - "label": "\\or", - "type": "symbol", - "apply": "∨", - "symbolPanelCategory": 2 - }, - { - "label": "\\abs", - "type": "symbol", - "apply": "∣", - "symbolPanelCategory": 2 - }, - { - "label": "\\intersection", - "type": "symbol", - "apply": "∩", - "symbolPanelCategory": 2 - }, - { - "label": "\\union", - "type": "symbol", - "apply": "∪", - "symbolPanelCategory": 2 - }, - { - "label": "\\empty-set", - "type": "symbol", - "apply": "∅", - "symbolPanelCategory": 2 - }, - { - "label": "\\less-or-equal", - "type": "symbol", - "apply": "≤", - "symbolPanelCategory": 2 - }, - { - "label": "\\leq", - "type": "symbol", - "apply": "≤", - "symbolPanelCategory": 99 - }, - { - "label": "\\greater-or-equal", - "type": "symbol", - "apply": "≥", - "symbolPanelCategory": 2 - }, - { - "label": "\\geq", - "type": "symbol", - "apply": "≥", - "symbolPanelCategory": 99 - }, - { - "label": "\\not-equal", - "type": "symbol", - "apply": "≠", - "symbolPanelCategory": 2 - }, - { - "label": "\\neq", - "type": "symbol", - "apply": "≠", - "symbolPanelCategory": 99 - }, - { - "label": "\\not", - "type": "symbol", - "apply": "¬", - "symbolPanelCategory": 2 - }, - { - "label": "\\circle-plus", - "type": "symbol", - "apply": "⊕", - "symbolPanelCategory": 2 - }, - { - "label": "\\circle-times", - "type": "symbol", - "apply": "⊗", - "symbolPanelCategory": 2 - }, - { - "label": "\\arrow-left", - "type": "symbol", - "apply": "←", - "symbolPanelCategory": 3 - }, - { - "label": "\\arrow-up", - "type": "symbol", - "apply": "⬆", - "symbolPanelCategory": 3 - }, - { - "label": "\\arrow-right", - "type": "symbol", - "apply": "→", - "symbolPanelCategory": 3 - }, - { - "label": "\\to", - "type": "symbol", - "apply": "→", - "symbolPanelCategory": 99 - }, - { - "label": "\\arrow-down", - "type": "symbol", - "apply": "↓", - "symbolPanelCategory": 3 - }, - { - "label": "\\arrow-map", - "type": "symbol", - "apply": "↦", - "symbolPanelCategory": 3 - }, - { - "label": "\\arrow-left-right", - "type": "symbol", - "apply": "↔", - "symbolPanelCategory": 3 - }, - { - "label": "\\implies", - "type": "symbol", - "apply": "⇒", - "symbolPanelCategory": 3 - }, - { - "label": "\\implies-left", - "type": "symbol", - "apply": "⇐", - "symbolPanelCategory": 3 - }, - { - "label": "\\implies-right", - "type": "symbol", - "apply": "⇒", - "symbolPanelCategory": 3 - }, - { - "label": "\\implies-left-right", - "type": "symbol", - "apply": "⇔", - "symbolPanelCategory": 3 - }, - { - "label": "\\converges", - "type": "symbol", - "apply": "⟶", - "symbolPanelCategory": 3 - }, - { - "label": "\\naturals", - "type": "symbol", - "apply": "ℕ", - "symbolPanelCategory": 4 - }, - { - "label": "\\integers", - "type": "symbol", - "apply": "ℤ", - "symbolPanelCategory": 4 - }, - { - "label": "\\rationals", - "type": "symbol", - "apply": "ℚ", - "symbolPanelCategory": 4 - }, - { - "label": "\\reals", - "type": "symbol", - "apply": "ℝ", - "symbolPanelCategory": 4 - }, - { - "label": "\\complex-numbers", - "type": "symbol", - "apply": "ℂ", - "symbolPanelCategory": 4 - }, - { - "label": "\\contradiction", - "type": "symbol", - "apply": "↯", - "symbolPanelCategory": 2 - }, - { - "label": "\\degree", - "type": "symbol", - "apply": "°", - "symbolPanelCategory": 2 - }, - { - "label": "\\^1", - "type": "symbol", - "apply": "¹", - "symbolPanelCategory": 5 - }, - { - "label": "\\^2", - "type": "symbol", - "apply": "²", - "symbolPanelCategory": 5 - }, - { - "label": "\\^3", - "type": "symbol", - "apply": "³", - "symbolPanelCategory": 5 - }, - { - "label": "\\^4", - "type": "symbol", - "apply": "⁴", - "symbolPanelCategory": 5 - }, - { - "label": "\\^5", - "type": "symbol", - "apply": "⁵", - "symbolPanelCategory": 5 - }, - { - "label": "\\^6", - "type": "symbol", - "apply": "⁶", - "symbolPanelCategory": 5 - }, - { - "label": "\\^7", - "type": "symbol", - "apply": "⁷", - "symbolPanelCategory": 5 - }, - { - "label": "\\^8", - "type": "symbol", - "apply": "⁸", - "symbolPanelCategory": 5 - }, - { - "label": "\\^9", - "type": "symbol", - "apply": "⁹", - "symbolPanelCategory": 5 - }, - { - "label": "\\^0", - "type": "symbol", - "apply": "⁰", - "symbolPanelCategory": 5 - }, - { - "label": "\\_1", - "type": "symbol", - "apply": "₁", - "symbolPanelCategory": 5 - }, - { - "label": "\\_2", - "type": "symbol", - "apply": "₂", - "symbolPanelCategory": 5 - }, - { - "label": "\\_3", - "type": "symbol", - "apply": "₃", - "symbolPanelCategory": 5 - }, - { - "label": "\\_4", - "type": "symbol", - "apply": "₄", - "symbolPanelCategory": 5 - }, - { - "label": "\\_5", - "type": "symbol", - "apply": "₅", - "symbolPanelCategory": 5 - }, - { - "label": "\\_6", - "type": "symbol", - "apply": "₆", - "symbolPanelCategory": 5 - }, - { - "label": "\\_7", - "type": "symbol", - "apply": "₇", - "symbolPanelCategory": 5 - }, - { - "label": "\\_8", - "type": "symbol", - "apply": "₈", - "symbolPanelCategory": 5 - }, - { - "label": "\\_9", - "type": "symbol", - "apply": "₉", - "symbolPanelCategory": 5 - }, - { - "label": "\\_0", - "type": "symbol", - "apply": "₀", - "symbolPanelCategory": 5 - } -] diff --git a/src/autocomplete/symbols.ts b/src/autocomplete/symbols.ts deleted file mode 100644 index 1c868f5..0000000 --- a/src/autocomplete/symbols.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Completion, CompletionContext, CompletionResult, CompletionSource } from "@codemirror/autocomplete"; -import symbols from "./symbols.json"; - -// Completions for common mathematical symbols. -const symbolCompletions: Completion[] = symbols; - -/** - * Function that creates the `symbolCompletionSource`. - * This function can be used in the editor as a completion source. - */ -export const symbolCompletionSource: CompletionSource = (context: CompletionContext): Promise => { - return new Promise((resolve, _reject) => { - const before = context.matchBefore(/\\/); - // If completion wasn't explicitly started and there - // is no word before the cursor, don't open completions. - if (!context.explicit && !before) resolve(null); - resolve({ - from: before ? before.from : context.pos, - options: symbolCompletions, - validFor: /\\[^ ]*/ - }); - }); - -} \ No newline at end of file diff --git a/src/editor.ts b/src/editor.ts index c27b912..685399c 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -13,12 +13,11 @@ import { CODE_PLUGIN_KEY, codePlugin } from "./codeview"; import { createHintPlugin } from "./hinting"; import { INPUT_AREA_PLUGIN_KEY, inputAreaPlugin } from "./inputArea"; import { WaterproofSchema } from "./schema"; -import { REAL_MARKDOWN_PLUGIN_KEY, coqdocPlugin, realMarkdownPlugin } from "./markup-views"; +import { SWITCHABLE_VIEW_PLUGIN_KEY, switchableViewPlugin } from "./markup-views"; import { menuPlugin } from "./menubar"; import { MENU_PLUGIN_KEY } from "./menubar/menubar"; import { PROGRESS_PLUGIN_KEY, progressBarPlugin } from "./progressBar"; import { DOCUMENT_PROGRESS_DECORATOR_KEY, documentProgressDecoratorPlugin } from "./documentProgressDecorator"; -import { FileTranslator } from "./translation"; import { createContextMenuHTML } from "./context-menu"; // CSS imports @@ -53,9 +52,6 @@ export class WaterproofEditor { // The prosemirror view private _view: EditorView | undefined; - // The file translator in use. - private _translator: FileTranslator | undefined; - // The file document mapping private _mapping: WaterproofMapping | undefined; @@ -121,8 +117,6 @@ export class WaterproofEditor { this._view.dom.remove(); } - this._translator = new FileTranslator(); - let resultingDocument = content; let documentChange: DocChange | WrappingDocChange | undefined = undefined; @@ -198,9 +192,9 @@ export class WaterproofEditor { } if (tr.selectionSet && tr.selection instanceof TextSelection) { this.updateCursor(tr.selection); - } else if (tr.getMeta(REAL_MARKDOWN_PLUGIN_KEY)) { + } else if (tr.getMeta(SWITCHABLE_VIEW_PLUGIN_KEY)) { // Set the cursor position from a markdown cell - this.updateCursor(tr.getMeta(REAL_MARKDOWN_PLUGIN_KEY)); + this.updateCursor(tr.getMeta(SWITCHABLE_VIEW_PLUGIN_KEY)); } if (step !== undefined) this.sendLineNumbers(); @@ -253,8 +247,7 @@ export class WaterproofEditor { inputAreaPlugin, updateStatusPlugin(this), mathPlugin, - realMarkdownPlugin(this._schema), - coqdocPlugin(this._schema), + switchableViewPlugin(this._editorConfig), codePlugin(this._editorConfig.completions, this._editorConfig.symbols), progressBarPlugin, documentProgressDecoratorPlugin, @@ -513,7 +506,7 @@ export class WaterproofEditor { let state = this._view.state; let from = state.selection.from; let to = state.selection.to; - if (REAL_MARKDOWN_PLUGIN_KEY.getState(state)?.cursor) { + if (SWITCHABLE_VIEW_PLUGIN_KEY.getState(state)?.cursor) { // @ts-expect-error TODO: Fix me from = REAL_MARKDOWN_PLUGIN_KEY.getState(state)?.cursor?.from; // @ts-expect-error TODO: Fix me diff --git a/src/index.ts b/src/index.ts index 8c8dfa0..c6cc65d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,4 +3,4 @@ export { WaterproofEditor } from "./editor"; export { WaterproofSchema } from "./schema"; export * from "./document"; export * from "./api"; -export { FileTranslator } from "./translation"; \ No newline at end of file +export { defaultToMarkdown } from "./translation"; \ No newline at end of file diff --git a/src/markup-views/CoqdocPlugin.ts b/src/markup-views/CoqdocPlugin.ts deleted file mode 100644 index 1a82dd2..0000000 --- a/src/markup-views/CoqdocPlugin.ts +++ /dev/null @@ -1,69 +0,0 @@ -/*--------------------------------------------------------- - * Adapted from https://github.com/benrbray/prosemirror-math/blob/master/src/math-plugin.ts - *--------------------------------------------------------*/ - -// prosemirror imports -import { Schema, Node as ProseNode } from "prosemirror-model"; -import { Plugin as ProsePlugin, PluginKey, PluginSpec } from "prosemirror-state"; -import { EditorView } from "prosemirror-view"; -import { CoqdocView } from "./CoqdocView"; - -//////////////////////////////////////////////////////////// - -export interface ICoqdocPluginState { - macros: { [cmd:string] : string }; - /** A list of currently active `NodeView`s, in insertion order. */ - activeNodeViews: CoqdocView[]; -} - -export const COQDOC_PLUGIN_KEY = new PluginKey("prosemirror-coqdoc-rendering"); - -/** - * Returns a function suitable for passing as a field in `EditorProps.nodeViews`. - * @see https://prosemirror.net/docs/ref/#view.EditorProps.nodeViews - */ -export function createCoqdocView(schema: Schema){ - return (node: ProseNode, view: EditorView, getPos: () => number | undefined): CoqdocView => { - /** @todo is this necessary? - * Docs says that for any function proprs, the current plugin instance - * will be bound to `this`. However, the typings don't reflect this. - */ - const pluginState = COQDOC_PLUGIN_KEY.getState(view.state); - if(!pluginState){ throw new Error("no coqdoc plugin!"); } - const nodeViews = pluginState.activeNodeViews; - - // set up NodeView - const nodeView = new CoqdocView(getPos, view, node.textContent, node, schema, COQDOC_PLUGIN_KEY, "coqdoc"); - nodeViews.push(nodeView); - return nodeView; - } -} - -const coqdocPluginSpec = (schema: Schema):PluginSpec => { - return { - key: COQDOC_PLUGIN_KEY, - state: { - init(_config, _instance){ - return { - macros: {}, - activeNodeViews: [] - }; - }, - apply(tr, value, _oldState, _newState){ - // produce updated state field for this plugin - return { - // these values are left unchanged - activeNodeViews : value.activeNodeViews, - macros : value.macros - } - } - }, - props: { - nodeViews: { - "coqdown" : createCoqdocView(schema) - } - } - } -}; - -export const coqdocPlugin = (schema: Schema) => new ProsePlugin(coqdocPluginSpec(schema)); \ No newline at end of file diff --git a/src/markup-views/CoqdocView.ts b/src/markup-views/CoqdocView.ts deleted file mode 100644 index 8a4b4bd..0000000 --- a/src/markup-views/CoqdocView.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { PluginKey } from "prosemirror-state"; -import { SwitchableView } from "./switchable-view"; -import { EditorView } from "prosemirror-view"; -import { Node as PNode, Schema } from "prosemirror-model"; -import { translateCoqDoc, toMathInline } from "../translation/toProsemirror/parser"; - -/** - * CoqdocView class extends the SwitchableView class. - * - * Used to edit and render coqdoc syntax within the prosemirror editor. - */ -export class CoqdocView extends SwitchableView { - - constructor( - getPos: (() => number | undefined), outerView: EditorView, - content: string, node: PNode, schema: Schema, - pluginKey: PluginKey, viewName: string - ) { - // Call the super constructor. - super(getPos, outerView, content, node, schema, pluginKey, viewName, true); - } - - preprocessContentForEditing(input: string): string { - // We don't preprocess the input content for editing. - return input; - } - preprocessContentForRendering(input: string): string { - // We convert the coqdoc to markdown using a custom translator (`translationToMarkdown.ts`) - // and convert the %'s to math-inline nodes. - return toMathInline("coqdoc", translateCoqDoc(input)); - } -} \ No newline at end of file diff --git a/src/markup-views/MarkdownView.ts b/src/markup-views/MarkdownView.ts deleted file mode 100644 index 01b5930..0000000 --- a/src/markup-views/MarkdownView.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { EditorView } from "prosemirror-view"; -import { SwitchableView } from "./switchable-view"; -import { Node as PNode, Schema } from "prosemirror-model"; -import { PluginKey } from "prosemirror-state"; -import { toMathInline } from "../translation/toProsemirror/parser"; - -/** - * MarkdownView class extends the SwitchableView class. - * - * Used to edit and render markdown within the prosemirror editor. - */ -export class MarkdownView extends SwitchableView{ - - constructor( - getPos: (() => number | undefined), outerView: EditorView, - content: string, node: PNode, schema: Schema, - pluginKey: PluginKey, viewName: string - ) { - // Call the super constructor. - super(getPos, outerView, content, node, schema, pluginKey, viewName, false); - } - - preprocessContentForEditing(input: string): string { - // We don't preprocess the content for editing. - return input; - } - - preprocessContentForRendering(input: string): string { - // The only preprocessing we do here is converting $ dollar signs to math-inline nodes. - return toMathInline("markdown", input); - } - -} \ No newline at end of file diff --git a/src/markup-views/MarkdownPlugin.ts b/src/markup-views/SwitchableViewPlugin.ts similarity index 61% rename from src/markup-views/MarkdownPlugin.ts rename to src/markup-views/SwitchableViewPlugin.ts index 9d5afdd..4a88119 100644 --- a/src/markup-views/MarkdownPlugin.ts +++ b/src/markup-views/SwitchableViewPlugin.ts @@ -3,40 +3,42 @@ *--------------------------------------------------------*/ // prosemirror imports -import { Schema, Node as ProseNode } from "prosemirror-model"; +import { Node as ProseNode } from "prosemirror-model"; import { Plugin as ProsePlugin, PluginKey, PluginSpec, TextSelection } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; -import { MarkdownView } from "./MarkdownView"; +import { SwitchableView } from "./switchable-view"; +import { WaterproofEditorConfig } from "../api"; +import { toMathInline } from "../translation"; //////////////////////////////////////////////////////////// -export interface IRealMarkdownPluginState { +export interface ISwitchableViewPluginState { macros: { [cmd:string] : string }; /** A list of currently active `NodeView`s, in insertion order. */ - activeNodeViews: MarkdownView[]; + activeNodeViews: SwitchableView[]; /** The selection of the current cursor position */ cursor: TextSelection | undefined; /** Last cursor position in view, so that it can be displayed */ } -export const REAL_MARKDOWN_PLUGIN_KEY = new PluginKey("prosemirror-realtime-markdown"); +export const SWITCHABLE_VIEW_PLUGIN_KEY = new PluginKey("prosemirror-realtime-markdown"); /** * Returns a function suitable for passing as a field in `EditorProps.nodeViews`. * @see https://prosemirror.net/docs/ref/#view.EditorProps.nodeViews */ -export function createRealMarkdownView(schema: Schema){ - return (node: ProseNode, view: EditorView, getPos: (() => number | undefined)): MarkdownView => { +export function createRealMarkdownView(editorConfig: WaterproofEditorConfig){ + return (node: ProseNode, view: EditorView, getPos: (() => number | undefined)): SwitchableView => { /** @todo is this necessary? * Docs says that for any function proprs, the current plugin instance * will be bound to `this`. However, the typings don't reflect this. */ - const pluginState = REAL_MARKDOWN_PLUGIN_KEY.getState(view.state); + const pluginState = SWITCHABLE_VIEW_PLUGIN_KEY.getState(view.state); if(!pluginState){ throw new Error("no realtime markdown plugin!"); } const nodeViews = pluginState.activeNodeViews; // set up NodeView - const nodeView = new MarkdownView(getPos, view, node.textContent, node, schema, REAL_MARKDOWN_PLUGIN_KEY, "markdown"); + const nodeView = new SwitchableView(getPos, view, node.textContent, node, SWITCHABLE_VIEW_PLUGIN_KEY, editorConfig.markdownName ?? "markdown", editorConfig.toMarkdown ?? ((input) => toMathInline(input))); nodeViews.push(nodeView); return nodeView; @@ -44,9 +46,9 @@ export function createRealMarkdownView(schema: Schema){ } -const RealMarkdownPluginSpec = (schema: Schema): PluginSpec => { +const RealMarkdownPluginSpec = (editorConfig: WaterproofEditorConfig): PluginSpec => { return { - key: REAL_MARKDOWN_PLUGIN_KEY, + key: SWITCHABLE_VIEW_PLUGIN_KEY, state: { init(_config, _instance){ return { @@ -58,7 +60,7 @@ const RealMarkdownPluginSpec = (schema: Schema): PluginSpec new ProsePlugin(RealMarkdownPluginSpec(schema)); \ No newline at end of file +export const switchableViewPlugin = (editorConfig: WaterproofEditorConfig) => new ProsePlugin(RealMarkdownPluginSpec(editorConfig)); \ No newline at end of file diff --git a/src/markup-views/index.ts b/src/markup-views/index.ts index 29a6de6..22b4973 100644 --- a/src/markup-views/index.ts +++ b/src/markup-views/index.ts @@ -1,9 +1,2 @@ // Export the CoqdocView editor view. -export { CoqdocView } from "./CoqdocView"; -// Export coqdocPlugin (for registering) and the coqdoc plugin key (for getting the state) -export { coqdocPlugin, COQDOC_PLUGIN_KEY } from "./CoqdocPlugin"; - -// Export the MarkdownView editor view. -export { MarkdownView } from "./MarkdownView"; -// Export the markdown plugin and the associated plugin key. -export { realMarkdownPlugin, REAL_MARKDOWN_PLUGIN_KEY } from "./MarkdownPlugin"; \ No newline at end of file +export { switchableViewPlugin, SWITCHABLE_VIEW_PLUGIN_KEY } from "./SwitchableViewPlugin"; \ No newline at end of file diff --git a/src/markup-views/switchable-view/RenderedView.ts b/src/markup-views/switchable-view/RenderedView.ts index 8dc2812..8516419 100644 --- a/src/markup-views/switchable-view/RenderedView.ts +++ b/src/markup-views/switchable-view/RenderedView.ts @@ -15,17 +15,13 @@ export class RenderedView { target: HTMLElement, content: string, outerView: EditorView, - parent: SwitchableView, - usingCoqdocSyntax: boolean, + parent: SwitchableView, _getPos: (() => number | undefined), ) { // Create a new MarkdownIt renderer with support for html (this allows // for the math-inline nodes to be passed through) - const mdit = usingCoqdocSyntax - // Note: We disable 'code' (ie. four space) because this does not work nicely in the .v files. - ? new MarkdownIt({html: true}).disable("code") - : new MarkdownIt({html: true}); + const mdit = new MarkdownIt({html: true}); // Render the markdown (converts it into a HTML string) const mditOutput = mdit.render(content); // Create a container element. diff --git a/src/markup-views/switchable-view/SwitchableView.ts b/src/markup-views/switchable-view/SwitchableView.ts index 5db86fd..6fd5514 100644 --- a/src/markup-views/switchable-view/SwitchableView.ts +++ b/src/markup-views/switchable-view/SwitchableView.ts @@ -2,13 +2,14 @@ import { Decoration, EditorView, NodeView } from "prosemirror-view"; import { EditableView } from "./EditableView"; import { RenderedView } from "./RenderedView"; import { NodeSelection, PluginKey } from "prosemirror-state"; -import { Node as PNode, Schema } from "prosemirror-model"; +import { Node as PNode } from "prosemirror-model"; +import { WaterproofSchema } from "../../schema"; /** * Abstract class for a switchable view. * Switchable views allow for editing and rendering. */ -export abstract class SwitchableView implements NodeView { +export class SwitchableView implements NodeView { /** The DOM for this nodeview. */ dom: HTMLElement; /** The currently active view. */ @@ -24,11 +25,8 @@ export abstract class SwitchableView implements NodeView { /** Represents whether the view is currently updating */ private _updating : boolean; - private _getPos: (() => number | undefined); - private _outerSchema; + private _getPos: (() => number | undefined); - - private _viewName: string; private _pluginKey: PluginKey; private _emptyClassName: string; @@ -36,26 +34,21 @@ export abstract class SwitchableView implements NodeView { private _editorClassName: string; private _renderedClassName: string; - private _usingCoqdocSyntax: boolean; - public get content() { return this._node.textContent; } constructor( getPos: (() => number | undefined), outerView: EditorView, - content: string, node: PNode, schema: Schema, + content: string, node: PNode, pluginKey: PluginKey, viewName: string, - usingCoqdocSyntax: boolean + private processForRendering: (input: string) => string ) { // Store parameters this._node = node; this._getPos = getPos; this._outerView = outerView; - this._outerSchema = schema; - this._viewName = viewName; this._pluginKey = pluginKey; - this._usingCoqdocSyntax = usingCoqdocSyntax; // Set-up dom related things. const container = document.createElement("div"); @@ -63,7 +56,7 @@ export abstract class SwitchableView implements NodeView { this.dom = container; // Create class names - this._viewClassName = `${viewName}-view`; + this._viewClassName = `markdown-view`; this._emptyClassName = `${this._viewClassName}-empty`; this._renderedClassName = `${this._viewClassName}-rendered`; this._editorClassName = `${this._viewClassName}-editor`; @@ -71,14 +64,15 @@ export abstract class SwitchableView implements NodeView { this.dom.appendChild(this._place); this.dom.classList.add(this._viewClassName); + this.dom.setAttribute("markup-name", viewName); // If the content is an empty string add an empty class to the dom element. if (content === "") { this.dom.classList.add(this._emptyClassName); } // We start with a rendered markdown view. - const processedContent = this.preprocessContentForRendering(this._node.textContent); - this.view = new RenderedView(this._place, processedContent, this._outerView, this, usingCoqdocSyntax, this._getPos); + const processedContent = this.processForRendering(this._node.textContent); + this.view = new RenderedView(this._place, processedContent, this._outerView, this, this._getPos); // eventHandler for the onclick event. // Creates a new node selection that selects 'this' node. @@ -120,7 +114,7 @@ export abstract class SwitchableView implements NodeView { */ makeRenderedView() { this.view.destroy(); - const inputContent = this.preprocessContentForRendering(this._node.textContent); + const inputContent = this.processForRendering(this._node.textContent); if (inputContent === "") { // If it is empty we add the empty class this.dom.classList.add(this._emptyClassName); @@ -129,7 +123,7 @@ export abstract class SwitchableView implements NodeView { this.dom.classList.remove(this._editorClassName); this.dom.classList.add(this._renderedClassName); // Create the new rendered view and set it as the current view - this.view = new RenderedView(this._place, inputContent, this._outerView, this, this._usingCoqdocSyntax, this._getPos); + this.view = new RenderedView(this._place, inputContent, this._outerView, this, this._getPos); } /** @@ -143,13 +137,9 @@ export abstract class SwitchableView implements NodeView { this.dom.classList.remove(this._renderedClassName); this.dom.classList.add(this._editorClassName); // Create a new editable view and it as the current view. - this.view = new EditableView(this._node, this._outerView, this._outerSchema, this._getPos, this._place, this, this._pluginKey); + this.view = new EditableView(this._node, this._outerView, WaterproofSchema, this._getPos, this._place, this, this._pluginKey); } - // Abstract functions that can be overridden to preprocess the content before switching views. - abstract preprocessContentForEditing(input: string): string; - abstract preprocessContentForRendering(input: string): string; - update(node: PNode, decorations: readonly Decoration[]) { if (!node.sameMarkup(this._node)) return false; this._node = node; diff --git a/src/styles/markdown.css b/src/styles/markdown.css index ca3d729..cb1174c 100644 --- a/src/styles/markdown.css +++ b/src/styles/markdown.css @@ -22,7 +22,7 @@ font-size: var(--vscode-font-size); font-weight: var(--vscode-font-weight); border-radius: 0px 0px 5px 0px; - content: "Markdown"; + content: attr(markup-name); padding: 0.2em; } diff --git a/src/translation/Translator.ts b/src/translation/Translator.ts deleted file mode 100644 index 3229136..0000000 --- a/src/translation/Translator.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { translateMvToProsemirror } from "./toProsemirror"; - -/** Class that handles the translation from .mv | .v to prosemirror and vice versa. */ -export class FileTranslator { - - constructor() {} - - /** - * Convert an input file to a prosemirror compatible HTML representation. - * Input format is set by `fileFormat` in the constructor. - * @param inputDocument The input document read from disk. - * @returns A prosemirror compatible HTML document (as string). - */ - public toProsemirror(inputDocument: string): string { - return translateMvToProsemirror(inputDocument); - } -} diff --git a/src/translation/index.ts b/src/translation/index.ts index f2a5822..f9fb256 100644 --- a/src/translation/index.ts +++ b/src/translation/index.ts @@ -1,2 +1,2 @@ // Exports for thjs class -export { FileTranslator } from "./Translator"; \ No newline at end of file +export { toMathInline, defaultToMarkdown } from "./toMarkdownTranslation"; \ No newline at end of file diff --git a/src/translation/toMarkdownTranslation.ts b/src/translation/toMarkdownTranslation.ts new file mode 100644 index 0000000..c955e33 --- /dev/null +++ b/src/translation/toMarkdownTranslation.ts @@ -0,0 +1,5 @@ +export function toMathInline(input: string): string { + return input.replaceAll(/\$(.*?)\$/g, "$1"); +} + +export const defaultToMarkdown = toMathInline; \ No newline at end of file diff --git a/src/translation/toProsemirror/index.ts b/src/translation/toProsemirror/index.ts deleted file mode 100644 index b71141e..0000000 --- a/src/translation/toProsemirror/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Export from folder -export { translateMvToProsemirror } from "./mvFileToProsemirror"; \ No newline at end of file diff --git a/src/translation/toProsemirror/mvFileToProsemirror.ts b/src/translation/toProsemirror/mvFileToProsemirror.ts deleted file mode 100644 index 346ee00..0000000 --- a/src/translation/toProsemirror/mvFileToProsemirror.ts +++ /dev/null @@ -1,300 +0,0 @@ -import { parseAsMv } from "./parseAsMv"; - - - -/** - * Should behave as follows. - * 1: match all coqblocks (```coq ... ```) - * 1.1: run .v translator on blocks. - * 1.2: run .mv translator (using special metadata) on these blocks. - * 2: run .mv translator on all other blocks. - */ - -/** Enum storing the cell type, can either be coqdoc or coq code*/ -enum coqCellType { - CoqDoc, - CoqCode -} - -enum CellType { - Coq, - Text -} - -interface CoqBlock { - start: number; - end: number; - content: string | RegExpMatchArray; - type: CellType; -} - -function getAllCoqBlocks(input: string): CoqBlock[] { - // Regex to find all coq blocks - const coqblockRegExp = /(\r\n|\n)?^```coq(\r\n|\n)([^]*?)(\r\n|\n)?^```$(\r\n|\n)?/gm; - - // Get all the matchings - const matches: RegExpMatchArray[] = Array.from(input.matchAll(coqblockRegExp)); - - // Loop through matches and replace newlines with a string to be used in html tags - for (let i = 0; i < matches.length; i++) { - for (let j = 0; j < matches[i].length; j++) { - if (j != 0 && j != 3) { - if (matches[i][j] == undefined) { - matches[i][j] = "" - } else if (matches[i][j] == "\r\n" || matches[i][j] == "\n") { - matches[i][j] = "newLine" - } - } - } - } - - // Create array for coqblock saving - const coqBlocks: CoqBlock[] = []; - - // Loop through coqblocks and add them to the array appropiately - matches.forEach(match => { - // Compute information about position of the coqblocks - const length = match[0].length; - const start = match.index; - if (start === undefined) { - throw new Error("Index cannot be null"); - } - const end = start + length; - - // Add the coqblock with the information to the array - coqBlocks.push({ - start, - end, - content: match, - type: CellType.Coq - }); - }); - return coqBlocks; -} - -/** - * Extracts text from document based on an array of extracted coqblocks. Takes - * out all the text inbetween the coqblocks and at the start or end of the document. - * - * @param document the document in question - * @param cbs the array of coqblocks - * @returns an array of textblocks with type Text - */ -function extractText(document: string, cbs: CoqBlock[]): CoqBlock[] { - // Initialize array to save text blocks - const textBlocks: CoqBlock[] = []; - let prevEnd = 0; - - // loop through all coqblocks and push the text between the coqblocks - cbs.forEach(cb => { - if (cb.start != prevEnd) { - const substring = document.substring(prevEnd, cb.start); - - // Push a new text block - textBlocks.push({ - start: prevEnd, - end: cb.start, - type: CellType.Text, - content: substring - }); - } - - prevEnd = cb.end; - }); - - // Add final cell after the last coq block if it exists - if (prevEnd != document.length) { - const substring = document.substring(prevEnd, document.length); - - // Push the cell - textBlocks.push({ - start: prevEnd, - end: document.length, - type: CellType.Text, - content: substring - }); - } - - return textBlocks; -} - -export function translateMvToProsemirror(inputDocument: string): string { - - // Get all coq blocks using there tags (```coq and ```) - const allCoqBlocks = getAllCoqBlocks(inputDocument); - // Get all text blocks by looking at what is left - const allTextBlocks = extractText(inputDocument, allCoqBlocks); - - const allBlocks = allCoqBlocks.concat(allTextBlocks); - // sort the blocks on there start in the document (happens in place) - allBlocks.sort((a, b) => { - return a.start - b.start; - }); - - // allBlocks is now an ordered array of all blocks (Text and Code) - - // Store the parsed document contents - let parsedDocument = ""; - - allBlocks.forEach(block => { - if (block.type === CellType.Coq) { - // Coqcode, run .v parser - parsedDocument += handleCoqBlock(block.content as RegExpMatchArray); - - } else if (block.type === CellType.Text) { - // This is a 'markdown' (normal text) block. - parsedDocument += handleTextBlock(block.content as string); - } - }); - return parsedDocument; -} - - -/** - * Deal with a coq block. Translates as follows: coqblock -> .mv format -> prosemirror format. - * @param match RegExpMatchArray of the matching coqblock. - * @returns parsed coq block - */ -function handleCoqBlock(match: RegExpMatchArray) { - const content = match[3]; - - const allCoqDoc = getAllCoqdoc(content); - const coqDoc: Array = allCoqDoc.map((doc) => { - const start = doc.index; - if (start == undefined) { - throw new Error(""); - } - const end = start + doc[0].length; - return {content: doc, start, end, type: coqCellType.CoqDoc}; - }); - - const allCoqBlocks = getAllCoq(content, allCoqDoc); - - let allCells: Array = [...allCoqBlocks, ...coqDoc]; - // Content is now basically its own little .mv file. - // We can run the .mv parser over this. - - // TODO: There may be a more elegant way to do this. We should not add them in the first place. - const markForRemoval: Array = []; - allCells.forEach((item, index) => { - if (item.type == coqCellType.CoqCode && item.content === "") { - markForRemoval.push(index); - } - }); - // We have to loop in reverse order here, otherwise removing - // elements messes up the index of the later ones. - for (let i = markForRemoval.length - 1; i >= 0; i--) { - allCells.splice(markForRemoval[i], 1); - } - - // Sort the cells on there index. - allCells = allCells.sort((item1, item2) => { - return item1.start - item2.start; - }); - - // This makes sure we do not forget any trailing coq code. - if (allCells.length > 0) { - const endEnd = allCells[allCells.length - 1].end; - if (endEnd < content.length) { - const substring = content.substring(endEnd); // TODO: Same as above - allCells.push({content: substring, start: endEnd, end: content.length, type: coqCellType.CoqCode}) - } - } - - if (allCells.length == 0 && content.length > 0) { - // The case where there is no coqdoc but coq - allCells.push({content, start: 0, end: content.length, type: coqCellType.CoqCode}); - } - - let result = "" - allCells.forEach(cell => { - // TODO: What does preWhite, postWhite do? - // console.log(cell); - if (cell.type === coqCellType.CoqCode) { - // Coqcode, run .v parser - result += ``.concat(cell.content as string, ``); - - } else if (cell.type === coqCellType.CoqDoc) { - // This is a 'markdown' (normal text) block. - result += ``.concat(parseAsMv(cell.content[2] as string, "coqdown"),``); - } - }); - - if (result === "") { - result = `` - } else { - result = `` + result + `` - } - - - return result; -} - -/** - * Deal with a text block. - * @param content String containing the markdown of this textblock. - * @returns parsed markdown. - */ -function handleTextBlock(content: string) { - return parseAsMv(content, "markdown"); -} - -/** - * Gets all coqdoc comments from the document. - * @param content The input .v document string. - * @returns Array of `RegExpMatchArray`'s containing all the coqdoc comments. - */ -function getAllCoqdoc(content: string): RegExpMatchArray[] { - /** - * RegExp that matches coqdoc comments. - * Coqdoc comments should be of the form - * `(** comment text here*)` - * https://coq.inria.fr/refman/using/tools/coqdoc.html#principles - */ - const regex = /(\r\n|\n)?^\(\*\* ([^]*?)\*\)$(\r\n|\n)?/gm; - const result = Array.from(content.matchAll(regex)) - for (let i = 0; i < result.length; i++) { - for (let j = 0; j < result[i].length; j++) { - if (result[i][j] == undefined) { - result[i][j] = "" - } else if (result[i][j] == "\r\n" || result[i][j] == "\n") { - result[i][j] = "newLine" - } - } - } - return result; -} - - - -/** Basic interface for a coq file entry. */ -interface coqFileEntry{ - content: string | RegExpMatchArray; - start: number; - end: number; - type: coqCellType; -} - -/** - * Retrieves all coq code blocks from the document. - * @param content The document content - * @param matches Array of matches for coqdoc strings - * @returns All coq code blocks - */ -function getAllCoq(content: string, matches: RegExpMatchArray[]) { - const coqBlocks = new Array(); - let prevEnd = 0; - matches.forEach((match) => { - const start = match.index; - if (start == undefined) { - throw new Error("Start is undefined"); - } - const end = start + match[0].length; - if (prevEnd != start) { - const substring = content.substring(prevEnd, start); - coqBlocks.push({content: substring, start: prevEnd, end: start, type: coqCellType.CoqCode}); - } - prevEnd = end; - }); - return coqBlocks; -} \ No newline at end of file diff --git a/src/translation/toProsemirror/parseAsMv.ts b/src/translation/toProsemirror/parseAsMv.ts deleted file mode 100644 index 80e052e..0000000 --- a/src/translation/toProsemirror/parseAsMv.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Parses `content` as .mv file content. - * @param content The content that should be parsed. - * @returns The parsed content. - */ -export function parseAsMv(input: string, markDownType: string) { - - // Add pre-markdown tag - input = `<${markDownType}>`.concat(input) - // Math-display replacement for markdown - if (markDownType === "markdown") { - - // This is for markdown replacement with text - const mathdisplayRegEx = /(?$2<${markDownType}>`) - - // This is for empty cells - const mathdisplayRegEx2 = /(?<${markDownType}>`) - - // Math-display replacement for coqdown - } else if (markDownType === "coqdown") { - - // This is for markdown replacement with text - const mathdisplayRegEx = /(?$2<${markDownType}>`) - - // This is for empty cells - const mathdisplayRegEx2 = /(?<${markDownType}>`) - } - - // Input areas - const inputAreaRegEx = /(<\/*?input-area>)/gm - input = input.replaceAll(inputAreaRegEx, `$1<${markDownType}>`) - - // For hints - const hintRegEx = /(<\/*?hint( \w+?="[^"]+?")*?>)/gm; - input = input.replaceAll(hintRegEx, `$1<${markDownType}>`); - - //Closing markdown - input = input.concat(``) - - //Remove all empty markdown blocks (so only those with absolutely no text) - const removeRegEx = new RegExp(`<${markDownType}>()`, "gm") - input = input.replaceAll(removeRegEx, ""); - - return input; -} - diff --git a/src/translation/toProsemirror/parser.ts b/src/translation/toProsemirror/parser.ts deleted file mode 100644 index 537cdda..0000000 --- a/src/translation/toProsemirror/parser.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** The pretty-printing table. */ -const ppTable: Map = new Map(); - -/** - * Populate the pretty printing table according to: - * https://coq.inria.fr/refman/using/tools/coqdoc.html#pretty-printing - */ -function populatePPTable() { - ppTable.set("->", "→"); - ppTable.set("<-", "←"); - ppTable.set("<=", "≤"); - ppTable.set(">=", "≥"); - ppTable.set("=>", "⇒"); - ppTable.set("<>", "≠"); - ppTable.set("<->", "↔"); - ppTable.set("\\/", "∨"); - ppTable.set("/\\", "∧"); - ppTable.set("|-", "⊢"); - ppTable.set("~", "¬"); -} - -export function translateCoqDoc(entry: string) { - populatePPTable(); - - let commentInside = entry; - - /** - * Replace headers according to - * https://coq.inria.fr/refman/using/tools/coqdoc.html#sections - * - * - * I hate this. The order here matters so go from more * to less. - */ - // Replace all H4 headers inside the coqdoc comment with markdown header. - commentInside = commentInside.replaceAll(/(? { - const orig = match[0]; - commentInside = commentInside.replace(orig, `....-`); - }); - const listMatches = Array.from(commentInside.matchAll(/(\.{4})-/g)); - listMatches.forEach((match) => { - const orig = match[0]; - commentInside = commentInside.replace(orig, ` -`); - }); - - /** - * Replace verbatim input according to: - * https://coq.inria.fr/refman/using/tools/coqdoc.html#verbatim - */ - commentInside = commentInside.replaceAll(/<<\s*?\n([\s\S]+?)\n>>\s*?/g, `\`\`\`\n$1\n\`\`\``); - - /** - * Replace "Preformatted vernacular" according to: - * https://coq.inria.fr/doc/v8.12/refman/using/tools/coqdoc.html#coq-material-inside-documentation - */ - commentInside = commentInside.replaceAll(/\[{2}\n([^]+)\n\]{2}/g, `\`\`\`\n$1\n\`\`\``); - - /** - * Replace quoted coq according to: - * https://coq.inria.fr/refman/using/tools/coqdoc.html#coq-material-inside-documentation - */ - commentInside = commentInside.replaceAll(/\[([\s\S]+)\]/g, `\`$1\``); - - // Try to apply every pretty printing rule. - ppTable.forEach((value: string, key: string) => { - commentInside = commentInside.replaceAll(key, value); - }); - - return commentInside -} - -export function toMathInline(from: "coqdoc" | "markdown", input: string): string { - if (from === "coqdoc") { - return input.replaceAll(/%(.*?)%/g, "$1");; - } else if (from === "markdown") { - return input.replaceAll(/\$(.*?)\$/g, "$1"); - } - throw new Error(`Unexpected type '${from}' in toMathInline`); -} \ No newline at end of file diff --git a/src/translation/types.ts b/src/translation/types.ts deleted file mode 100644 index f00e0ea..0000000 --- a/src/translation/types.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Fragment, Node, Slice } from "prosemirror-model"; - -// Exports -export type NodeSerializer = (node: Node) => string; -export type MarkSerializer = (text: string) => string; - - -/** - * Abstract class that describes a serializer for the conversion of the document - */ -export abstract class Serializer { - - // Two abstract methods that require implementation - abstract serializeFragment(fragment: Fragment): string; - abstract serializeSlice(slice: Slice): string; - - // Abstract method which serializes a slice - serialize(input: Fragment | Slice) { - - // Check what format the input is from - if (input instanceof Fragment) { - - // If input is a fragment we run the fragment serializer - return this.serializeFragment(input); - } else if (input instanceof Slice) { - - // If input is a slice we run the slice serializer - return this.serializeSlice(input); - } else { - - // Else we do not convert the input - return null; - } - } -} \ No newline at end of file From ff72e94f924def17f866817a22ba1cc9486abf5a Mon Sep 17 00:00:00 2001 From: raulTUe Date: Wed, 17 Sep 2025 17:17:09 +0200 Subject: [PATCH 07/50] extra libraries necessary for testing nodeupdate added --- src/api/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/api/index.ts b/src/api/index.ts index 7e192bc..e828ce6 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,6 +1,8 @@ // Export Doc Change and Wrapping Doc Change types from "./DocChange" export { DocChange, WrappingDocChange } from "./DocChange"; +export { EditorState, Transaction } from "prosemirror-state"; + // Export QedStatus type export { InputAreaStatus } from "./InputAreaStatus"; export { LineNumber} from "./LineNumber"; From 5bca0b8fc7651da4ae5af20501f66d37e8836e7e Mon Sep 17 00:00:00 2001 From: DikieDick <26772815+DikieDick@users.noreply.github.com> Date: Tue, 23 Sep 2025 11:29:34 +0200 Subject: [PATCH 08/50] Update --- src/api/index.ts | 2 +- src/api/types.ts | 22 +++++++++++++++++++--- src/document/blocks/block.ts | 2 +- src/editor.ts | 4 ++-- 4 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/api/index.ts b/src/api/index.ts index e828ce6..afe1cb1 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -14,6 +14,6 @@ export { WaterproofCompletion, WaterproofSymbol } from "./Completions"; export { Completion } from "@codemirror/autocomplete"; export { Step, ReplaceStep, ReplaceAroundStep } from "prosemirror-transform"; -export { Fragment } from "prosemirror-model"; +export { Fragment, Node } from "prosemirror-model"; export { ServerStatus, Idle, Busy } from "./ServerStatus"; \ No newline at end of file diff --git a/src/api/types.ts b/src/api/types.ts index f1d0edc..7f0f0ed 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -1,5 +1,5 @@ import { Step } from "prosemirror-transform"; -import { DocChange, WrappingDocChange, Severity, WaterproofCompletion, WaterproofSymbol } from "."; +import { DocChange, WrappingDocChange, Severity, WaterproofCompletion, WaterproofSymbol, Node } from "."; import { Block } from "../document"; /** @@ -42,7 +42,20 @@ export abstract class WaterproofMapping { abstract get version(): number; abstract findPosition: (index: number) => number; abstract findInvPosition: (index: number) => number; - abstract update: (step: Step) => DocChange | WrappingDocChange; + abstract update: (step: Step, doc: Node) => DocChange | WrappingDocChange; +} + +export type TagMap = { + markdownOpen: string, + markdownClose: string, + codeOpen: string, + codeClose: string, + hintOpen: (title: string) => string, + hintClose: string, + inputOpen: string, + inputClose: string, + mathOpen: string + mathClose: string } /** @@ -62,7 +75,10 @@ export type WaterproofEditorConfig = { /** Determines how the editor document gets constructed from a string input */ documentConstructor: (document: string) => WaterproofDocument, /** How to construct a mapping for this editor. The mapping is responsible for mapping changes from the underlying ProseMirror instance into changes that can be applied to the underlying document. */ - mapping: new (inputDocument: WaterproofDocument, versionNum: number) => WaterproofMapping, + mapping: new (inputDocument: WaterproofDocument, versionNum: number, tagMap: TagMap) => WaterproofMapping, + + tagConfiguration: TagMap, + /** The name of the markdown node view, defaults to "markdown" */ markdownName?: string, diff --git a/src/document/blocks/block.ts b/src/document/blocks/block.ts index 302f9ef..7557d98 100644 --- a/src/document/blocks/block.ts +++ b/src/document/blocks/block.ts @@ -3,7 +3,7 @@ import { Node as ProseNode } from "prosemirror-model"; // The different types of blocks that can be constructed. export enum BLOCK_NAME { MATH_DISPLAY = "math_display", - INPUT_AREA = "input_area", + INPUT_AREA = "input", HINT = "hint", MARKDOWN = "markdown", CODE = "code", diff --git a/src/editor.ts b/src/editor.ts index 685399c..6c382ed 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -135,7 +135,7 @@ export class WaterproofEditor { const blocks = this._editorConfig.documentConstructor(resultingDocument); const proseDoc = constructDocument(blocks); - this._mapping = new this._editorConfig.mapping(blocks, version); + this._mapping = new this._editorConfig.mapping(blocks, version, this._editorConfig.tagConfiguration); this.createProseMirrorEditor(proseDoc); /** Ask for line numbers */ @@ -168,7 +168,7 @@ export class WaterproofEditor { if (step instanceof ReplaceStep || step instanceof ReplaceAroundStep) { if (this._mapping === undefined) throw new Error(" Mapping is undefined, cannot synchronize with vscode"); try { - const change: DocChange | WrappingDocChange = this._mapping.update(step); // Get text document update + const change: DocChange | WrappingDocChange = this._mapping.update(step, view.state.doc); // Get text document update this._editorConfig.api.documentChange(change); } catch (error) { console.log("Step error: ", step); From 4a9573d4c4fa1d966b2495f6a46bf61cdf22661f Mon Sep 17 00:00:00 2001 From: DikieDick <26772815+DikieDick@users.noreply.github.com> Date: Wed, 24 Sep 2025 08:38:59 +0200 Subject: [PATCH 09/50] Don't apply transaction when step caused error --- src/api/types.ts | 12 ++++++++++++ src/editor.ts | 28 ++++++++++++++++------------ 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/src/api/types.ts b/src/api/types.ts index 7f0f0ed..4470833 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -58,6 +58,18 @@ export type TagMap = { mathClose: string } +export class NodeUpdateError extends Error { + constructor(message: string) { super("[NodeUpdateError]" + message); } +} + +export class TextUpdateError extends Error { + constructor(message: string) { super("[TextUpdateError]" + message); } +} + +export class MappingError extends Error { + constructor(message: string) { super("[MappingError] " + message); } +} + /** * Configuration object for the WaterproofEditor. * diff --git a/src/editor.ts b/src/editor.ts index 6c382ed..f7baf92 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -8,7 +8,7 @@ import { EditorView } from "prosemirror-view"; import { undo, redo, history } from "prosemirror-history"; import { constructDocument } from "./document/construct-document"; -import { DocChange, LineNumber, InputAreaStatus, SimpleProgressParams, WrappingDocChange, HistoryChange, Severity } from "./api"; +import { DocChange, LineNumber, InputAreaStatus, SimpleProgressParams, WrappingDocChange, HistoryChange, Severity, MappingError, NodeUpdateError, TextUpdateError } from "./api"; import { CODE_PLUGIN_KEY, codePlugin } from "./codeview"; import { createHintPlugin } from "./hinting"; import { INPUT_AREA_PLUGIN_KEY, inputAreaPlugin } from "./inputArea"; @@ -160,9 +160,6 @@ export class WaterproofEditor { dispatchTransaction: ((tr) => { // Called on every transaction. - // Why does this happen here? - // Don't we wont to only do this when we know this is a valid transaction? - view.updateState(view.state.apply(tr)); let step : Step | undefined = undefined; for (step of tr.steps) { if (step instanceof ReplaceStep || step instanceof ReplaceAroundStep) { @@ -170,19 +167,22 @@ export class WaterproofEditor { try { const change: DocChange | WrappingDocChange = this._mapping.update(step, view.state.doc); // Get text document update this._editorConfig.api.documentChange(change); - } catch (error) { - console.log("Step error: ", step); - console.error((error as Error).message); + } catch (error: unknown) { + const err = error as MappingError | TextUpdateError | NodeUpdateError; + console.error("Error while applying step to mapping, the edit will **not** be applied!"); + console.error("The step: ", step); + console.error("The error message:", err.message); + console.error("Error originated in:", err.constructor.name); // Send message to VSCode that an error has occured - this._editorConfig.api.applyStepError((error as Error).message); + this._editorConfig.api.applyStepError(err.message); // Set global locking mode - const tr = view.state.tr; - tr.setMeta(INPUT_AREA_PLUGIN_KEY,"ErrorMode"); - tr.setSelection(new AllSelection(view.state.doc)); - view.updateState(view.state.apply(tr)); + // const tr = view.state.tr; + // tr.setMeta(INPUT_AREA_PLUGIN_KEY,"ErrorMode"); + // tr.setSelection(new AllSelection(view.state.doc)); + // view.updateState(view.state.apply(tr)); // We ensure this transaction is not applied return; @@ -190,6 +190,10 @@ export class WaterproofEditor { } } + + // Only update the state when we know that the transaction did not cause an error + view.updateState(view.state.apply(tr)); + if (tr.selectionSet && tr.selection instanceof TextSelection) { this.updateCursor(tr.selection); } else if (tr.getMeta(SWITCHABLE_VIEW_PLUGIN_KEY)) { From 930af448c04ae1665c5f9b7f66c976ed78f00b5f Mon Sep 17 00:00:00 2001 From: DikieDick <26772815+DikieDick@users.noreply.github.com> Date: Wed, 24 Sep 2025 12:49:28 +0200 Subject: [PATCH 10/50] Fix block utils --- src/document/utils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/document/utils.ts b/src/document/utils.ts index bd3cb19..751a133 100644 --- a/src/document/utils.ts +++ b/src/document/utils.ts @@ -40,9 +40,9 @@ export const extractInterBlockRanges = (blocks: Array, inputDocument: str return { from: blockA.range.to, to: blockB.range.from }; }).filter(range => range.from < range.to); // Filter out empty ranges. // Add first range if it exists - if (blocks.length > 0 && blocks[0].range.from > parentOffset) ranges = [{from: 0, to: blocks[0].range.from}, ...ranges]; + if (blocks.length > 0 && blocks[0].range.from > 0) ranges = [{from: 0, to: blocks[0].range.from - parentOffset}, ...ranges]; // Add last range if it exists - if (blocks.length > 0 && blocks[blocks.length - 1].range.to < inputDocument.length) ranges = [...ranges, {from: blocks[blocks.length - 1].range.to, to: inputDocument.length}]; + if (blocks.length > 0 && (blocks[blocks.length - 1].range.to - parentOffset) < inputDocument.length) ranges = [...ranges, {from: blocks[blocks.length - 1].range.to - parentOffset, to: inputDocument.length}]; // If there are no blocks found then we add the rest as a range. if (blocks.length === 0 && inputDocument.length > 0) ranges = [{from: 0, to: inputDocument.length}]; From 81b9378ac0dc4958e9a017ed22443d1f3091200a Mon Sep 17 00:00:00 2001 From: DikieDick <26772815+DikieDick@users.noreply.github.com> Date: Wed, 24 Sep 2025 18:02:49 +0200 Subject: [PATCH 11/50] Get rid off hardcoded node type names, insert commands refactored. The refactoring of the insert commands means that the logic is now way simpler, as our document assumptions have been simplified. There was also a bug that we inserted 3 code blocks at a time (I think because we were inserting on the edge of a node??), there is a TODO there since I don't fully understand the cause of that bug yet. --- src/commands/command-helpers.ts | 97 ++++++++------------------- src/commands/commands.ts | 81 +---------------------- src/commands/delete-command.ts | 8 +-- src/commands/index.ts | 2 +- src/commands/insert-command.ts | 113 +++++++------------------------- src/commands/types.ts | 2 +- src/editor.ts | 19 +++--- src/inputArea.ts | 3 +- src/menubar/menubar.ts | 15 +++-- 9 files changed, 81 insertions(+), 259 deletions(-) diff --git a/src/commands/command-helpers.ts b/src/commands/command-helpers.ts index b1b682c..6badafc 100644 --- a/src/commands/command-helpers.ts +++ b/src/commands/command-helpers.ts @@ -3,36 +3,10 @@ import { NodeType, Node as PNode } from "prosemirror-model"; import { EditorState, TextSelection, Transaction, Selection, NodeSelection } from "prosemirror-state"; import { INPUT_AREA_PLUGIN_KEY } from "../inputArea"; +import { WaterproofSchema } from "../schema"; /////// Helper functions ///////// -/** - * Get a selection type object from a user selection. - * @param sel Input user selection. - * @returns Object that stores booleans whether we have a text or node selection. - */ -export function selectionType(sel: Selection) { - return { - isTextSelection: sel instanceof TextSelection, - isNodeSelection: sel instanceof NodeSelection - } -} - -export function getNearestPosOutsideCoqblock(sel: Selection, _state: EditorState) { - const depth = sel.$from.depth; - let foundDepth = 0; - for (let i = depth; i >= 0; i--) { - if (sel.$from.node(i).type.name === 'coqblock') { - foundDepth = i; - break; - } - } - const node = sel.$from.node(foundDepth); - const start = sel.$from.posAtIndex(0, foundDepth) - 1; - const end = start + node.nodeSize; - return { start, end }; -} - /** * Helper function for inserting a new node above the currently selected one. * @param state The current editor state. @@ -42,30 +16,24 @@ export function getNearestPosOutsideCoqblock(sel: Selection, _state: EditorState * (coqcode outside of a coqblock needs to be enclosed within a new coqblock) * @returns An insertion transaction. */ -export function insertAbove(state: EditorState, tr: Transaction, ...nodeType: NodeType[]): Transaction { +export function insertAbove(state: EditorState, tr: Transaction, nodeType: NodeType): Transaction | undefined { const sel = state.selection; - const {isTextSelection, isNodeSelection} = selectionType(sel); let trans: Transaction = tr; - if (isNodeSelection) { + if (sel instanceof NodeSelection) { // To and from point directly to beginning and end of node. const pos = sel.from; - let counter = pos; - nodeType.forEach(type => { - trans = trans.insert(counter, type.create()); - counter++; - }); - } else if (isTextSelection) { - const textSel = (sel as TextSelection); - const from = sel.from - textSel.$from.parentOffset; - let counter = from; - nodeType.forEach(type => { - trans = trans.insert(counter, type.create()); - counter++; - }); + trans = trans.insert(pos, nodeType.create()); + return trans; + } 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. + const from = sel.from - sel.$from.parentOffset - 1; + trans = trans.insert(from, nodeType.create()); + return trans; } - return trans; + return; } /** @@ -77,36 +45,29 @@ export function insertAbove(state: EditorState, tr: Transaction, ...nodeType: No * (coqcode outside of a coqblock needs to be enclosed within a new coqblock) * @returns An insertion transaction. */ -export function insertUnder(state: EditorState, tr: Transaction, ...nodeType: NodeType[]): Transaction { +export function insertUnder(state: EditorState, tr: Transaction, nodeType: NodeType): Transaction | undefined { const sel = state.selection; - const {isTextSelection, isNodeSelection} = selectionType(sel); let trans: Transaction = tr; - if (isNodeSelection) { + if (sel instanceof NodeSelection) { // To and from point directly to beginning and end of node. const pos = sel.to; - let counter = pos; - nodeType.forEach(type => { - trans = trans.insert(counter, type.create()); - counter++; - }); - } else if (isTextSelection) { - const textSel = (sel as TextSelection); - const to = sel.to + (sel.$from.parent.nodeSize - textSel.$from.parentOffset) - 1; + trans = trans.insert(pos, nodeType.create()); + return trans; + } else if (sel instanceof TextSelection) { + const to = sel.to + (sel.$from.parent.nodeSize - sel.$from.parentOffset) - 1; if (to > state.doc.nodeSize) { - console.log("This is no bueno"); - return trans; + console.log("The computed `to` value lies outside of the document"); + return; } - let counter = to; - nodeType.forEach(type => { - trans = trans.insert(counter, type.create()); - counter++; - }); + + trans = trans.insert(to, nodeType.create()); + return trans; } - return trans; + return; } /** @@ -115,14 +76,14 @@ export function insertUnder(state: EditorState, tr: Transaction, ...nodeType: No * @returns The node containing this selection. Will *not* return text nodes. */ export function getContainingNode(sel: Selection): PNode | undefined { - const {isTextSelection, isNodeSelection} = selectionType(sel); + // const {isTextSelection, isNodeSelection} = selectionType(sel); - if (isTextSelection) { + if (sel instanceof TextSelection) { return sel.$from.node(sel.$from.depth - 1); - } else if (isNodeSelection) { + } else if (sel instanceof NodeSelection) { return sel.$from.parent; } else { - return undefined; + return; } } @@ -147,5 +108,5 @@ export function checkInputArea(sel: Selection): boolean { // An input area can only ever have depth = 1, since it is a // top level node (see TheSchema in `kroqed-schema.ts`) if (depth < 1) return false; - return from.node(1).type.name === "input"; + return from.node(1).type === WaterproofSchema.nodes.input; } \ No newline at end of file diff --git a/src/commands/commands.ts b/src/commands/commands.ts index e46246b..5613f75 100644 --- a/src/commands/commands.ts +++ b/src/commands/commands.ts @@ -1,92 +1,15 @@ import { NodeRange } from "prosemirror-model"; import { Command, EditorState, NodeSelection, Transaction } from "prosemirror-state"; -import { insertAbove, insertUnder } from "./command-helpers"; -import { InsertionPlace } from "./types"; -import { getCodeInsertCommand, getLatexInsertCommand, getMdInsertCommand } from "./insert-command"; import { EditorView } from "prosemirror-view"; import { liftTarget } from "prosemirror-transform"; import { WaterproofSchema } from "../schema"; -/** - * Get the insertion function needed for insertion at `place`. - * @param place The place to insert at: Above or Underneath. - * @returns The insertion function corresponding to `place`. - */ -function getInsertionFunction(place: InsertionPlace) { - return place == InsertionPlace.Above ? insertAbove : insertUnder; -} - -//// Code //// - -/* - A coq cell is always the content of a coqblock (direct child) - Coqblocks are children of - - A cell; - - Containers (input, hints) -*/ - -/** - * Creates a command that creates a new code cell above/underneath the currently selected node. - * @param insertionPlace The place to insert the new node into: Underneath or Above the current node. - * @returns The `Command`. - */ -export function cmdInsertCode(insertionPlace: InsertionPlace): Command { - // Return a command with the correct insertion place and function. - return getCodeInsertCommand(getInsertionFunction(insertionPlace), insertionPlace, WaterproofSchema.nodes.code); -} - -//// MARKDOWN //// - -/* - Markdown cells (or coqdoc syntax) are either the content of - - cells or; - - containers (hints, input, ) - - The working of this command depends on the fileformat of the file that is currently - being edited. - If the file is .mv then we want to open a new markdown block (as a top level node) - Otherwise, if the file is .v, we want to open a new coqdoc (if necessary) and add a new - coqdoc markdown (abbreviated as coqdown). -*/ - -/** - * Creates a command that creates a new markdown cell underneath/above the currently selected node. - * @param insertionPlace The place to insert at: Above or Underneath current node. - * @returns The `Command`. - */ -export function cmdInsertMarkdown(insertionPlace: InsertionPlace): Command { - // Retrieve the node types for both markdown and coqdoc markdown (coqdown) from the schema. - const mdNodeType = WaterproofSchema.nodes.markdown; - // Return a command with the correct insertion command and place. - return getMdInsertCommand(getInsertionFunction(insertionPlace), - insertionPlace, mdNodeType); -} - -//// DISPlAY MATH //// - -/* - Display Math nodes are either cell contents or container contents. - -> Containers are hints and inputs. -*/ - -/** - * Returns a command that inserts a new Display Math cell above/underneath the currently selected cell. - * @param insertionPlace The place to insert the node at Above or Underneath the current node. - * @returns The `Command` - */ -export function cmdInsertLatex(insertionPlace: InsertionPlace): Command { - // Get latex node type from the schema. - const latexNodeType = WaterproofSchema.nodes.math_display; - // Return the command with correct insertion place. - return getLatexInsertCommand(getInsertionFunction(insertionPlace), insertionPlace, latexNodeType); -} - export const liftWrapper: Command = (state: EditorState, dispatch?: ((tr: Transaction) => void), _view?: EditorView): boolean => { const sel = state.selection; if (sel instanceof NodeSelection) { - const name = sel.node.type.name; - if (name === "hint" || name === "input") { + const {type} = sel.node; + if (type === WaterproofSchema.nodes.hint || type === WaterproofSchema.nodes.input) { // Hardcoded +1 and -1 are here to move the selection into the input/hint. // The hardcoded depth 1 is the depth of a hint or an input area node type. const range = new NodeRange(state.doc.resolve(sel.from + 1), state.doc.resolve(sel.to - 1), 1); diff --git a/src/commands/delete-command.ts b/src/commands/delete-command.ts index e3b64e3..64a970e 100644 --- a/src/commands/delete-command.ts +++ b/src/commands/delete-command.ts @@ -1,13 +1,13 @@ import { EditorState, Transaction } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; +import { WaterproofSchema } from "../schema"; export function deleteNodeIfEmpty(state: EditorState, dispatch?: ((tr: Transaction) => void), _view?: EditorView): boolean { if (state.selection.from !== state.selection.to) return false; const parent = state.selection.$from.parent; - const content = parent.textContent; - const nodeName = parent.type.name; - if (content === "" && - (nodeName === "coqcode" || nodeName === "markdown" || nodeName === "coqdown")) { + const {textContent, type} = parent; + if (textContent === "" && + (type === WaterproofSchema.nodes.code || type === WaterproofSchema.nodes.markdown)) { // empty cell // Get the start and end position of the containing cell. diff --git a/src/commands/index.ts b/src/commands/index.ts index 1c98ab8..33bdee4 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,5 +1,5 @@ // Export `deleteNodeIfEmpty` command. export { deleteNodeIfEmpty } from "./delete-command"; // Export all insertion commands for use in the menubar or with keybindings. -export { cmdInsertCode, cmdInsertLatex, cmdInsertMarkdown, liftWrapper } from "./commands"; +export { liftWrapper } 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 d5a7313..dcd3c2c 100644 --- a/src/commands/insert-command.ts +++ b/src/commands/insert-command.ts @@ -1,45 +1,22 @@ -import { Command, EditorState, Transaction } from "prosemirror-state"; -import { InsertionFunction, InsertionPlace } from "./types"; -import { NodeType } from "prosemirror-model"; +import { EditorState, Transaction } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; -import { allowedToInsert, getContainingNode, getNearestPosOutsideCoqblock } from "./command-helpers"; +import { allowedToInsert, insertAbove, insertUnder } from "./command-helpers"; +import { WaterproofSchema } from "../schema"; +import { InsertionPlace } from "./types"; -/** - * Return a Markdown insertion command. - * @param insertionFunction The function used to insert the node into the editor. - * @param place Where to insert the node into the editor. Either Above or Underneath the currently selected node. - * @param nodeType The node type of the markdown node. - * @returns The insertion command. - */ -export function getMdInsertCommand( - insertionFunction: InsertionFunction, - place: InsertionPlace, - nodeType: NodeType -): Command { +export function getCmdInsertMarkdown(place: InsertionPlace) { return (state: EditorState, dispatch?: ((tr: Transaction) => void), _view?: EditorView): boolean => { // Early return when inserting is not allowed if (!allowedToInsert(state)) return false; - // Get the containing node for this selection. - const container = getContainingNode(state.selection); - - let trans: Transaction | undefined; - if (container === undefined) return false; - - // Retrieve the name of the containing node. - const { name } = container.type; - - if (name === "input" || name === "hint" || name === "doc") { - // In the case of having `input`, `hint` or `doc` as parent node, we can insert directly - // above or below the selected node. - trans = insertionFunction(state, state.tr, nodeType); - } else if (name === "coqblock" || name === "coqdoc") { - // In the case that the user has a selection within a coqblock or coqdoc cell we need to do more work and - // figure out where this block `starts` and `ends`. - const { start, end } = getNearestPosOutsideCoqblock(state.selection, state); - trans = state.tr.insert(place == InsertionPlace.Above ? start : end, nodeType.create()); - } + // TODO: Can there be cases where this doesn't work? + // Can we attempt this command in a case where our state and selection is such that + // we can't actually add the node there? + const f = place === InsertionPlace.Above ? insertAbove : insertUnder; + const trans = f(state, state.tr, WaterproofSchema.nodes.markdown); + if (trans === undefined) { return false; } + // If the dispatch is given and transaction is not undefined dispatch it. if (dispatch && trans) dispatch(trans); @@ -48,38 +25,16 @@ export function getMdInsertCommand( } } -/** - * Returns an insertion command for insertion display latex into the editor. - * @param insertionFunction The insertion function to use. - * @param place The place to insert into, either Above or Underneath the currently selected node. - * @param latexNodeType The node type for a 'display latex' node. - * @returns The insertion command. - */ -export function getLatexInsertCommand( - insertionFunction: InsertionFunction, - place: InsertionPlace, - latexNodeType: NodeType, -): Command { +export function getCmdInsertLatex(place: InsertionPlace) { return (state: EditorState, dispatch?: ((tr: Transaction) => void), _view?: EditorView): boolean => { // Early return when inserting is not allowed. if (!allowedToInsert(state)) return false; - // Containing node. - const container = getContainingNode(state.selection); - - let trans: Transaction | undefined; - if (container === undefined) return false; - - const { name } = container.type - - if (name === "input" || name === "hint" || name === "doc") { - // `Easy` insertion since we can just insert directly above or below the selection. - trans = insertionFunction(state, state.tr, latexNodeType); - } else if (name === "coqblock" || name === "coqdoc") { - // More difficult insertion since we have to `escape` the current coqblock. - const { start, end } = getNearestPosOutsideCoqblock(state.selection, state); - trans = state.tr.insert(place == InsertionPlace.Above ? start : end, latexNodeType.create()); - } + + const f = place === InsertionPlace.Above ? insertAbove : insertUnder; + const trans = f(state, state.tr, WaterproofSchema.nodes.math_display); + if (trans === undefined) { return false; } + // Dispatch the transaction when dispatch is given and transaction is not undefined. if (dispatch && trans) dispatch(trans); @@ -88,40 +43,20 @@ export function getLatexInsertCommand( } } -/** - * Returns an insertion command for inserting a new coq code cell. Will create a new coqblock if necessary. - * @param insertionFunction The insertion function to use. - * @param place The place of insertion, either Above or Underneath the currently selected node. - * @param codeNodeType The node type for a code cell. - * @returns The insertion command. - */ -export function getCodeInsertCommand( - insertionFunction: InsertionFunction, - place: InsertionPlace, - codeNodeType: NodeType -): Command { +export function getCmdInsertCode(place: InsertionPlace) { return (state: EditorState, dispatch?: ((tr: Transaction) => void), _view?: EditorView): boolean => { // Again, early return when inserting is not allowed. if (!allowedToInsert(state)) return false; - // Retrieve the name of the containing node of the selection. - const name = getContainingNode(state.selection)?.type.name; - if (name === undefined) return false; - let trans: Transaction | undefined; - if (name === "input" || name === "hint" || name === "doc") { - // Create a new coqblock *and* coqcode cell and insert Above or Underneath the current selection. - trans = insertionFunction(state, state.tr, codeNodeType); - } else if (name === "coqblock" || name === "coqdoc") { - // Find the position outside of the coqblock and insert a new coqblock and coqcode cell above or underneath. - const {start, end} = getNearestPosOutsideCoqblock(state.selection, state); - const pos = place == InsertionPlace.Above ? start : end; - trans = state.tr.insert(pos, codeNodeType.create()); - } + const f = place === InsertionPlace.Above ? insertAbove : insertUnder; + const trans = f(state, state.tr, WaterproofSchema.nodes.code); + if (trans === undefined) { return false; } + // If dispatch is given and transaction is set, dispatch the transaction. if (dispatch && trans) dispatch(trans); // Indicate that this command was successful. - return true; + return true; } } \ No newline at end of file diff --git a/src/commands/types.ts b/src/commands/types.ts index 93ecdd0..6499030 100644 --- a/src/commands/types.ts +++ b/src/commands/types.ts @@ -6,7 +6,7 @@ import { EditorState, Transaction } from "prosemirror-state"; */ export enum InsertionPlace { Above, - Underneath, + Below, } /** diff --git a/src/editor.ts b/src/editor.ts index f7baf92..7d7c393 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -2,7 +2,7 @@ import { mathPlugin, mathSerializer } from "@benrbray/prosemirror-math"; import { deleteSelection, selectParentNode } from "prosemirror-commands"; import { keymap } from "prosemirror-keymap"; import { ResolvedPos, Schema, Node as ProseNode } from "prosemirror-model"; -import { AllSelection, EditorState, NodeSelection, Plugin, Selection, TextSelection, Transaction } from "prosemirror-state"; +import { 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"; @@ -26,12 +26,13 @@ import "prosemirror-view/style/prosemirror.css"; import "./styles"; import { UPDATE_STATUS_PLUGIN_KEY, updateStatusPlugin } from "./qedStatus"; import { CodeBlockView } from "./codeview/nodeview"; -import { InsertionPlace, cmdInsertCode, cmdInsertLatex, cmdInsertMarkdown } from "./commands"; import { OS } from "./osType"; import { Positioned, WaterproofMapping, WaterproofEditorConfig, DiagnosticMessage, ThemeStyle } from "./api"; import { Completion } from "@codemirror/autocomplete"; import { setCurrentTheme } from "./themeStore"; import { ServerStatus } from "./api"; +import { getCmdInsertCode, getCmdInsertLatex, getCmdInsertMarkdown } from "./commands/insert-command"; +import { InsertionPlace } from "./commands"; /** Type that contains a coq diagnostics object fit for use in the ProseMirror editor context. */ type DiagnosticObjectProse = {message: string, start: number, end: number, $start: ResolvedPos, $end: ResolvedPos, severity: Severity}; @@ -263,12 +264,12 @@ export class WaterproofEditor { }, "Backspace": deleteSelection, "Delete": deleteSelection, - "Mod-m": cmdInsertMarkdown(InsertionPlace.Underneath), - "Mod-M": cmdInsertMarkdown(InsertionPlace.Above), - "Mod-q": cmdInsertCode(InsertionPlace.Underneath), - "Mod-Q": cmdInsertCode(InsertionPlace.Above), - "Mod-l": cmdInsertLatex(InsertionPlace.Underneath), - "Mod-L": cmdInsertLatex(InsertionPlace.Above), + "Mod-m": getCmdInsertMarkdown(InsertionPlace.Below), + "Mod-M": getCmdInsertMarkdown(InsertionPlace.Above), + "Mod-q": getCmdInsertCode(InsertionPlace.Below), + "Mod-Q": getCmdInsertCode(InsertionPlace.Above), + "Mod-l": getCmdInsertLatex(InsertionPlace.Below), + "Mod-L": getCmdInsertLatex(InsertionPlace.Above), // We bind Ctrl/Cmd+. to selecting the parent node of the currently selected node. "Mod-.": selectParentNode }) @@ -543,7 +544,7 @@ export class WaterproofEditor { let isEditable = false; state.doc.nodesBetween($from.pos, $from.pos, (node) => { - if (node.type.name === "input") { + if (node.type === WaterproofSchema.nodes.input) { isEditable = true; } }); diff --git a/src/inputArea.ts b/src/inputArea.ts index 72cdadc..0adb6d1 100644 --- a/src/inputArea.ts +++ b/src/inputArea.ts @@ -1,5 +1,6 @@ import { EditorState, Plugin, PluginKey, PluginSpec, Transaction } from "prosemirror-state"; +import { WaterproofSchema } from "./schema"; /** * Interface describing the state of the input are plugin. @@ -80,7 +81,7 @@ const InputAreaPluginSpec : PluginSpec = { // Check if the current selection is inside an input area. state.doc.nodesBetween($from.pos, $from.pos, (node) => { - if (node.type.name === "input") { + if (node.type === WaterproofSchema.nodes.input) { // If so, this cell is editable. isEditable = true; } diff --git a/src/menubar/menubar.ts b/src/menubar/menubar.ts index 5f6fe90..25760a3 100644 --- a/src/menubar/menubar.ts +++ b/src/menubar/menubar.ts @@ -2,9 +2,10 @@ import { selectParentNode, wrapIn } from "prosemirror-commands"; import { Command, PluginView, Plugin, PluginKey } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; import { INPUT_AREA_PLUGIN_KEY } from "../inputArea"; -import { cmdInsertCode, cmdInsertLatex, cmdInsertMarkdown, InsertionPlace, liftWrapper } from "../commands"; +import { InsertionPlace, liftWrapper } from "../commands"; import { OS } from "../osType"; import { WaterproofSchema } from "../schema"; +import { getCmdInsertCode, getCmdInsertLatex, getCmdInsertMarkdown } from "../commands/insert-command"; /** MenuEntry type contains the DOM, whether to only show it in teacher mode and the command to execute on click */ type MenuEntry = { @@ -163,14 +164,14 @@ function createDefaultMenu(outerView: EditorView, os: OS): MenuView { // Create the list of menu entries. const items: MenuEntry[] = [ // Insert Coq command - createMenuItem("Math↓", `Insert new verified math block underneath (${keyBinding("q")})`, cmdInsertCode(InsertionPlace.Underneath)), - createMenuItem("Math↑", `Insert new verified math block above (${keyBinding("Q")})`, cmdInsertCode(InsertionPlace.Above)), + createMenuItem("Math↓", `Insert new verified math block underneath (${keyBinding("q")})`, getCmdInsertCode(InsertionPlace.Below)), + createMenuItem("Math↑", `Insert new verified math block above (${keyBinding("Q")})`, getCmdInsertCode(InsertionPlace.Above)), // Insert Markdown - createMenuItem("Text↓", `Insert new text block underneath (${keyBinding("m")})`, cmdInsertMarkdown(InsertionPlace.Underneath)), - createMenuItem("Text↑", `Insert new text block above (${keyBinding("M")})`, cmdInsertMarkdown(InsertionPlace.Above)), + createMenuItem("Text↓", `Insert new text block underneath (${keyBinding("m")})`, getCmdInsertMarkdown(InsertionPlace.Below)), + createMenuItem("Text↑", `Insert new text block above (${keyBinding("M")})`, getCmdInsertMarkdown(InsertionPlace.Above)), // Insert LaTeX - createMenuItem(`${LaTeX_SVG}
`, `Insert new LaTeX block underneath (${keyBinding("l")})`, cmdInsertLatex(InsertionPlace.Underneath)), - createMenuItem(`${LaTeX_SVG}
`, `Insert new LaTeX block above (${keyBinding("L")})`, cmdInsertLatex(InsertionPlace.Above)), + createMenuItem(`${LaTeX_SVG}
`, `Insert new LaTeX block underneath (${keyBinding("l")})`, getCmdInsertLatex(InsertionPlace.Below)), + createMenuItem(`${LaTeX_SVG}
`, `Insert new LaTeX block above (${keyBinding("L")})`, getCmdInsertLatex(InsertionPlace.Above)), // Select the parent node. createMenuItem("Parent", `Select the parent node (${keyBinding(".")})`, selectParentNode), // in teacher mode, display input area, hint and lift buttons. From ff7cffc8a3edd21dfd3caf40e0e81cf842e95ae7 Mon Sep 17 00:00:00 2001 From: DikieDick <26772815+DikieDick@users.noreply.github.com> Date: Sat, 27 Sep 2025 21:02:12 +0200 Subject: [PATCH 12/50] Implement newline nodes into schema and blocks --- src/api/types.ts | 36 +++++--- src/commands/command-helpers.ts | 142 ++++++++++++++++++++++++++---- src/commands/insert-command.ts | 15 ++-- src/document/blocks/block.ts | 5 +- src/document/blocks/blocktypes.ts | 26 +++++- src/document/blocks/index.ts | 2 +- src/document/blocks/schema.ts | 5 ++ src/document/blocks/typeguards.ts | 5 +- src/editor.ts | 16 ++-- src/index.ts | 3 +- src/markdownDefaults.ts | 45 ++++++++++ src/menubar/menubar.ts | 19 ++-- src/schema/schema-nodes.ts | 0 src/schema/schema.ts | 41 +++------ 14 files changed, 268 insertions(+), 92 deletions(-) create mode 100644 src/markdownDefaults.ts delete mode 100644 src/schema/schema-nodes.ts diff --git a/src/api/types.ts b/src/api/types.ts index 2951e63..a498c4d 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -60,17 +60,26 @@ export abstract class WaterproofMapping { abstract update: (step: Step, doc: Node) => DocChange | WrappingDocChange; } -export type TagMap = { - markdownOpen: string, - markdownClose: string, - codeOpen: string, - codeClose: string, - hintOpen: (title: string) => string, - hintClose: string, - inputOpen: string, - inputClose: string, - mathOpen: string - mathClose: string +export type OpenCloseTag = { openTag: string, closeTag: string } + +export type RequiresNewline = { openRequiresNewline: boolean, closeRequiresNewline: boolean }; + +export type TagConfiguration = { + markdown: OpenCloseTag & RequiresNewline, + code: OpenCloseTag & RequiresNewline, + hint: { openTag: ((title: string) => string), closeTag: string } & RequiresNewline, + input: OpenCloseTag & RequiresNewline, + math: OpenCloseTag & RequiresNewline, +} + +export type CommonSerializer = (content: string) => string; + +export type Serializers = { + markdown: CommonSerializer, + code: CommonSerializer, + input: CommonSerializer, + math: CommonSerializer, + hint: (content: string, title: string) => string } export class NodeUpdateError extends Error { @@ -102,9 +111,10 @@ export type WaterproofEditorConfig = { /** Determines how the editor document gets constructed from a string input. */ documentConstructor: (document: string) => WaterproofDocument, /** How to construct a mapping for this editor. The mapping is responsible for mapping changes from the underlying ProseMirror instance into changes that can be applied to the underlying document. */ - mapping: new (inputDocument: WaterproofDocument, versionNum: number, tagMap: TagMap) => WaterproofMapping, + mapping: new (inputDocument: WaterproofDocument, versionNum: number, tagMap: TagConfiguration, serializers: Serializers) => WaterproofMapping, - tagConfiguration: TagMap, + tagConfiguration: TagConfiguration, + serializers: Serializers, /** The name of the markdown node view, defaults to "markdown" */ markdownName?: string, diff --git a/src/commands/command-helpers.ts b/src/commands/command-helpers.ts index 6badafc..eea9db3 100644 --- a/src/commands/command-helpers.ts +++ b/src/commands/command-helpers.ts @@ -1,9 +1,10 @@ /////// Helper functions ///////// -import { NodeType, Node as PNode } from "prosemirror-model"; +import { NodeType, Node as PNode, ResolvedPos } from "prosemirror-model"; import { EditorState, TextSelection, Transaction, Selection, NodeSelection } from "prosemirror-state"; import { INPUT_AREA_PLUGIN_KEY } from "../inputArea"; import { WaterproofSchema } from "../schema"; +import { newline } from "../document/blocks/schema"; /////// Helper functions ///////// @@ -12,28 +13,74 @@ import { WaterproofSchema } from "../schema"; * @param state The current editor state. * @param tr The current transaction for the state of the editor. * @param escapeContainingNode Whether to escape the containing node. - * @param nodeType Array of nodes to insert. Depending on the node type this will be either one or more - * (coqcode outside of a coqblock needs to be enclosed within a new coqblock) + * @param nodeType ? * @returns An insertion transaction. */ -export function insertAbove(state: EditorState, tr: Transaction, nodeType: NodeType): Transaction | undefined { +export function insertAbove(state: EditorState, tr: Transaction, nodeType: NodeType, insertNewlineBeforeIfNotExists: boolean, insertNewlineAfterIfNotExists: boolean): Transaction | undefined { + // console.log("INSERTING ABOVE"); + const sel = state.selection; let trans: Transaction = tr; + const {before} = getSurroundingNodes(sel.$from); + const beforeIsNewline = before !== null ? (before.type === WaterproofSchema.nodes.newline) : false; + // console.log("Before", before?.type.name); + + let pos; + if (sel instanceof NodeSelection) { // To and from point directly to beginning and end of node. - const pos = sel.from; - trans = trans.insert(pos, nodeType.create()); - return trans; + 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. - const from = sel.from - sel.$from.parentOffset - 1; - trans = trans.insert(from, nodeType.create()); - return trans; + pos = sel.from - sel.$from.parentOffset - 1; + } else { + return; } - return; + + if (beforeIsNewline) { + // Assumption: If a newline appears before a node the current node wants that. + pos -= 1; // We are going to insert befofre + } + + // console.log("Node at", state.doc.nodeAt(pos)); + + const newBefore = getSurroundingNodes(state.doc.resolve(pos)).before; + // console.log("newbefore", newBefore); + + const toInsert: PNode[] = []; + + if (insertNewlineBeforeIfNotExists && newBefore?.type !== WaterproofSchema.nodes.newline) { + toInsert.push(newline()); + } + toInsert.push(nodeType.create()); + if (insertNewlineAfterIfNotExists && !beforeIsNewline) { + toInsert.push(newline()); + } + + trans = trans.insert(pos, toInsert); + + // if (insertNewlineBeforeIfNotExists && newBefore?.type !== WaterproofSchema.nodes.newline) { + // const node = newline(); + // trans = trans.insert(pos, node); + // console.log("inserting newline before"); + // // pos += 1; + // } + // const mainNode = nodeType.create(); + // trans = trans.insert(pos, mainNode); + // // pos += 1; + // if (insertNewlineAfterIfNotExists && !beforeIsNewline) { + // const node = newline(); + // trans = trans.insert(pos, node); + // console.log("inserting newline after"); + // // pos += 1; + // } + + // console.log(trans); + + return trans; } /** @@ -41,11 +88,10 @@ export function insertAbove(state: EditorState, tr: Transaction, nodeType: NodeT * @param state The current editor state. * @param tr The current transaction for the state of the editor. * @param escapeContainingNode Whether to escape the containing node. - * @param nodeType Array of nodes to insert. Depending on the node type this will be either one or more - * (coqcode outside of a coqblock needs to be enclosed within a new coqblock) + * @param nodeType ? * @returns An insertion transaction. */ -export function insertUnder(state: EditorState, tr: Transaction, nodeType: NodeType): Transaction | undefined { +export function insertUnder(state: EditorState, tr: Transaction, nodeType: NodeType, insertNewlineBeforeIfNotExists: boolean, insertNewlineAfterIfNotExists: boolean): Transaction | undefined { const sel = state.selection; let trans: Transaction = tr; @@ -70,14 +116,76 @@ export function insertUnder(state: EditorState, tr: Transaction, nodeType: NodeT return; } +export function nodeFromSel(sel: Selection): PNode | undefined { + if (sel instanceof TextSelection) { + return sel.$from.node(sel.$from.depth); + } else if (sel instanceof NodeSelection) { + return sel.node; + } else { + return; + } +} + +function getSurroundingNodes($from: ResolvedPos): {before: PNode | null; after: PNode | null} { + const depth = $from.depth; + let parent; + let index; + if (depth === 0) { + parent = $from.parent; + index = $from.index(0); + } else { + parent = $from.node(1); + index = $from.index(1); + } + const before = index > 0 ? parent.child(index - 1) : null; + const after = index < parent.childCount - 1 ? parent.child(index + 1) : null; + return {before, after}; +} + +// function getSurroundingNodes(sel: Selection): {before: PNode | null; after: PNode | null} { +// // console.log(sel); +// const depth = sel.$from.depth; +// // console.log(depth); + +// let parent; +// let index; +// if (depth === 0) { +// parent = sel.$from.parent; +// index = sel.$from.index(0); +// } else { +// parent = sel.$from.node(1); +// index = sel.$from.index(1); +// } +// // console.log(parent); + +// // const parent = (thingie !== undefined ? thingie : sel.$from.parent); +// // const index = sel.$from.index(1); + +// // console.log(index); + +// const before = index > 0 ? parent.child(index - 1) : null; +// const after = index < parent.childCount - 1 ? parent.child(index + 1) : null; +// return {before, after}; +// // if (sel instanceof TextSelection) { +// // const parent = sel.$from.node(1); +// // const index = sel.$from.index(1); +// // const before = index > 0 ? parent.child(index - 1) : null; +// // const after = index < parent.childCount - 1 ? parent.child(index + 1) : null; +// // return {before, after}; +// // } else if (sel instanceof NodeSelection) { +// // const parent = sel.$from.parent; +// // const index = sel.$from.index(1); +// // const before = +// // } +// // return {before: null, after: null}; +// } + /** * Returns the containing node for the current selection. * @param sel The user's selection. * @returns The node containing this selection. Will *not* return text nodes. */ export function getContainingNode(sel: Selection): PNode | undefined { - // const {isTextSelection, isNodeSelection} = selectionType(sel); - if (sel instanceof TextSelection) { return sel.$from.node(sel.$from.depth - 1); } else if (sel instanceof NodeSelection) { @@ -106,7 +214,7 @@ 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 TheSchema in `kroqed-schema.ts`) + // top level node (see WaterproofSchema in `schema.ts`) if (depth < 1) return false; return from.node(1).type === WaterproofSchema.nodes.input; } \ No newline at end of file diff --git a/src/commands/insert-command.ts b/src/commands/insert-command.ts index dcd3c2c..d02248d 100644 --- a/src/commands/insert-command.ts +++ b/src/commands/insert-command.ts @@ -3,8 +3,9 @@ import { EditorView } from "prosemirror-view"; import { allowedToInsert, insertAbove, insertUnder } from "./command-helpers"; import { WaterproofSchema } from "../schema"; import { InsertionPlace } from "./types"; +import { TagConfiguration } from "../api"; -export function getCmdInsertMarkdown(place: InsertionPlace) { +export function getCmdInsertMarkdown(place: InsertionPlace, tagConf: TagConfiguration) { return (state: EditorState, dispatch?: ((tr: Transaction) => void), _view?: EditorView): boolean => { // Early return when inserting is not allowed if (!allowedToInsert(state)) return false; @@ -12,8 +13,10 @@ export function getCmdInsertMarkdown(place: InsertionPlace) { // TODO: Can there be cases where this doesn't work? // Can we attempt this command in a case where our state and selection is such that // we can't actually add the node there? + const f = place === InsertionPlace.Above ? insertAbove : insertUnder; - const trans = f(state, state.tr, WaterproofSchema.nodes.markdown); + + const trans = f(state, state.tr, WaterproofSchema.nodes.markdown, tagConf.markdown.openRequiresNewline, tagConf.markdown.closeRequiresNewline); if (trans === undefined) { return false; } @@ -25,13 +28,13 @@ export function getCmdInsertMarkdown(place: InsertionPlace) { } } -export function getCmdInsertLatex(place: InsertionPlace) { +export function getCmdInsertLatex(place: InsertionPlace, tagConf: TagConfiguration) { return (state: EditorState, dispatch?: ((tr: Transaction) => void), _view?: EditorView): boolean => { // Early return when inserting is not allowed. if (!allowedToInsert(state)) return false; const f = place === InsertionPlace.Above ? insertAbove : insertUnder; - const trans = f(state, state.tr, WaterproofSchema.nodes.math_display); + const trans = f(state, state.tr, WaterproofSchema.nodes.math_display, tagConf.math.openRequiresNewline, tagConf.math.closeRequiresNewline); if (trans === undefined) { return false; } @@ -43,13 +46,13 @@ export function getCmdInsertLatex(place: InsertionPlace) { } } -export function getCmdInsertCode(place: InsertionPlace) { +export function getCmdInsertCode(place: InsertionPlace, tagConf: TagConfiguration) { return (state: EditorState, dispatch?: ((tr: Transaction) => void), _view?: EditorView): boolean => { // Again, early return when inserting is not allowed. if (!allowedToInsert(state)) return false; const f = place === InsertionPlace.Above ? insertAbove : insertUnder; - const trans = f(state, state.tr, WaterproofSchema.nodes.code); + const trans = f(state, state.tr, WaterproofSchema.nodes.code, tagConf.code.openRequiresNewline, tagConf.code.closeRequiresNewline); if (trans === undefined) { return false; } diff --git a/src/document/blocks/block.ts b/src/document/blocks/block.ts index 7557d98..8e25394 100644 --- a/src/document/blocks/block.ts +++ b/src/document/blocks/block.ts @@ -6,7 +6,8 @@ export enum BLOCK_NAME { INPUT_AREA = "input", HINT = "hint", MARKDOWN = "markdown", - CODE = "code", + CODE = "code", + NEWLINE = "newline", } export interface BlockRange { @@ -15,7 +16,7 @@ export interface BlockRange { } export interface Block { - type: string; + type: BLOCK_NAME; stringContent: string; /** Range in the original document, including possible tags (like ) */ range: BlockRange; diff --git a/src/document/blocks/blocktypes.ts b/src/document/blocks/blocktypes.ts index 7f455ab..41ed449 100644 --- a/src/document/blocks/blocktypes.ts +++ b/src/document/blocks/blocktypes.ts @@ -1,6 +1,7 @@ +import { Node } from "prosemirror-model"; import { WaterproofSchema } from "../../schema"; import { BLOCK_NAME, Block, BlockRange } from "./block"; -import { code, hint, inputArea, markdown, mathDisplay } from "./schema"; +import { code, 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}}`; @@ -124,7 +125,7 @@ export class MarkdownBlock implements Block { export class CodeBlock implements Block { public type = BLOCK_NAME.CODE; - constructor( public stringContent: string, public prePreWhite: string, public prePostWhite: string, public postPreWhite: string, public postPostWhite : string, public range: BlockRange, public innerRange: BlockRange) {} + constructor( public stringContent: string, public range: BlockRange, public innerRange: BlockRange) {} toProseMirror() { if (this.stringContent === "") { @@ -138,4 +139,25 @@ export class CodeBlock implements Block { debugPrint(level: number): void { console.log(`${indentation(level)}CoqCodeBlock {${debugInfo(this)}}: {${this.stringContent.replaceAll("\n", "\\n")}}`); } +} + +/** + * NewlineBlock are blocks that take the place of a newline that is significant in the document. + * That is, the newline should be preserved + */ +export class NewlineBlock implements Block { + public type = BLOCK_NAME.NEWLINE; + + constructor ( public range: BlockRange, public innerRange: BlockRange ) {} + + stringContent: string = ""; + + toProseMirror (): Node { + return newline(); + } + + // Debug print function. + debugPrint(level: number): void { + console.log(`${indentation(level)}Newline`); + } } \ No newline at end of file diff --git a/src/document/blocks/index.ts b/src/document/blocks/index.ts index 4f943d6..36e496e 100644 --- a/src/document/blocks/index.ts +++ b/src/document/blocks/index.ts @@ -1,3 +1,3 @@ export { BlockRange, Block } from "./block"; -export { InputAreaBlock, HintBlock, CodeBlock, MathDisplayBlock, MarkdownBlock } from "./blocktypes"; \ No newline at end of file +export * from "./blocktypes"; \ No newline at end of file diff --git a/src/document/blocks/schema.ts b/src/document/blocks/schema.ts index 1e13b44..976e3ba 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); } +// ##### Special newline block ###### +export const newline = () => { + return WaterproofSchema.nodes.newline.create(); +} + // ##### Root Node ##### export const root = (childNodes: ProseNode[]): ProseNode => { return WaterproofSchema.nodes.doc.create({}, childNodes); diff --git a/src/document/blocks/typeguards.ts b/src/document/blocks/typeguards.ts index 8c1eda9..90cae3a 100644 --- a/src/document/blocks/typeguards.ts +++ b/src/document/blocks/typeguards.ts @@ -1,8 +1,9 @@ import { BLOCK_NAME, Block } from "./block"; -import { CodeBlock, HintBlock, InputAreaBlock, MarkdownBlock, MathDisplayBlock } from "./blocktypes"; +import { CodeBlock, 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; \ No newline at end of file +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 diff --git a/src/editor.ts b/src/editor.ts index 7d7c393..47f2dbf 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -136,7 +136,7 @@ export class WaterproofEditor { const blocks = this._editorConfig.documentConstructor(resultingDocument); const proseDoc = constructDocument(blocks); - this._mapping = new this._editorConfig.mapping(blocks, version, this._editorConfig.tagConfiguration); + this._mapping = new this._editorConfig.mapping(blocks, version, this._editorConfig.tagConfiguration, this._editorConfig.serializers); this.createProseMirrorEditor(proseDoc); /** Ask for line numbers */ @@ -256,7 +256,7 @@ export class WaterproofEditor { codePlugin(this._editorConfig.completions, this._editorConfig.symbols), progressBarPlugin, documentProgressDecoratorPlugin, - menuPlugin(this._userOS), + menuPlugin(this._userOS, this._editorConfig.tagConfiguration), keymap({ "Mod-h": () => { this.executeCommand("Help."); @@ -264,12 +264,12 @@ export class WaterproofEditor { }, "Backspace": deleteSelection, "Delete": deleteSelection, - "Mod-m": getCmdInsertMarkdown(InsertionPlace.Below), - "Mod-M": getCmdInsertMarkdown(InsertionPlace.Above), - "Mod-q": getCmdInsertCode(InsertionPlace.Below), - "Mod-Q": getCmdInsertCode(InsertionPlace.Above), - "Mod-l": getCmdInsertLatex(InsertionPlace.Below), - "Mod-L": getCmdInsertLatex(InsertionPlace.Above), + "Mod-m": getCmdInsertMarkdown(InsertionPlace.Below, this._editorConfig.tagConfiguration), + "Mod-M": getCmdInsertMarkdown(InsertionPlace.Above, this._editorConfig.tagConfiguration), + "Mod-q": getCmdInsertCode(InsertionPlace.Below, this._editorConfig.tagConfiguration), + "Mod-Q": getCmdInsertCode(InsertionPlace.Above, this._editorConfig.tagConfiguration), + "Mod-l": getCmdInsertLatex(InsertionPlace.Below, this._editorConfig.tagConfiguration), + "Mod-L": getCmdInsertLatex(InsertionPlace.Above, this._editorConfig.tagConfiguration), // We bind Ctrl/Cmd+. to selecting the parent node of the currently selected node. "Mod-.": selectParentNode }) diff --git a/src/index.ts b/src/index.ts index c6cc65d..a105f63 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,4 +3,5 @@ export { WaterproofEditor } from "./editor"; export { WaterproofSchema } from "./schema"; export * from "./document"; export * from "./api"; -export { defaultToMarkdown } from "./translation"; \ No newline at end of file +export { defaultToMarkdown } from "./translation"; +export { markdownConfiguration, markdownSerializers } from "./markdownDefaults"; \ No newline at end of file diff --git a/src/markdownDefaults.ts b/src/markdownDefaults.ts new file mode 100644 index 0000000..442333b --- /dev/null +++ b/src/markdownDefaults.ts @@ -0,0 +1,45 @@ +import { Serializers, TagConfiguration } from "./api" + +export function markdownConfiguration(languageId: string): TagConfiguration { + return { + markdown: { + openTag: "", closeTag: "", + openRequiresNewline: false, closeRequiresNewline: false, + }, + code: { + openTag: "```" + languageId + "\n", + closeTag: "\n```", + openRequiresNewline: true, + closeRequiresNewline: true, + }, + hint: { + openTag: (title: string) => ``, + closeTag: "", + openRequiresNewline: false, closeRequiresNewline: false, + }, + input: { + openTag: "", closeTag: "", + openRequiresNewline: false, closeRequiresNewline: false, + }, + math: { + openTag: "$$", closeTag: "$$", + openRequiresNewline: false, closeRequiresNewline: false + } + } +}; + +/** + * Assumes using the `markdownTagMap` with the same language id. + * @param languageId + * @returns + */ +export function markdownSerializers(languageId: string): Serializers { + const tagConf = markdownConfiguration(languageId); + return { + code: (content) => tagConf.code.openTag + content + tagConf.code.closeTag, + input: (content) => tagConf.input.openTag + content + tagConf.input.closeTag, + hint: (content, title) => tagConf.hint.openTag(title) + content + tagConf.hint.closeTag, + markdown: (content) => tagConf.markdown.openTag + content + tagConf.markdown.closeTag, + math: (content) => tagConf.math.openTag + content + tagConf.math.closeTag, + }; +} diff --git a/src/menubar/menubar.ts b/src/menubar/menubar.ts index 25760a3..0a30274 100644 --- a/src/menubar/menubar.ts +++ b/src/menubar/menubar.ts @@ -6,6 +6,7 @@ import { InsertionPlace, liftWrapper } from "../commands"; import { OS } from "../osType"; import { WaterproofSchema } from "../schema"; import { getCmdInsertCode, getCmdInsertLatex, getCmdInsertMarkdown } from "../commands/insert-command"; +import { TagConfiguration } from "../api"; /** MenuEntry type contains the DOM, whether to only show it in teacher mode and the command to execute on click */ type MenuEntry = { @@ -153,7 +154,7 @@ function teacherOnlyWrapper(cmd: Command): Command { * @param filef The file format of the current file. Some commands will behave differently in `.mv` vs `.v` context. * @returns A new `MenuView` filled with default menu items. */ -function createDefaultMenu(outerView: EditorView, os: OS): MenuView { +function createDefaultMenu(outerView: EditorView, os: OS, tagConf: TagConfiguration): MenuView { // Platform specific keybinding string: const cmdOrCtrl = os == OS.MacOS ? "Cmd" : "Ctrl"; @@ -164,14 +165,14 @@ function createDefaultMenu(outerView: EditorView, os: OS): MenuView { // Create the list of menu entries. const items: MenuEntry[] = [ // Insert Coq command - createMenuItem("Math↓", `Insert new verified math block underneath (${keyBinding("q")})`, getCmdInsertCode(InsertionPlace.Below)), - createMenuItem("Math↑", `Insert new verified math block above (${keyBinding("Q")})`, getCmdInsertCode(InsertionPlace.Above)), + 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)), - createMenuItem("Text↑", `Insert new text block above (${keyBinding("M")})`, getCmdInsertMarkdown(InsertionPlace.Above)), + 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)), - createMenuItem(`${LaTeX_SVG}
`, `Insert new LaTeX block above (${keyBinding("L")})`, getCmdInsertLatex(InsertionPlace.Above)), + 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. @@ -214,12 +215,12 @@ export const MENU_PLUGIN_KEY = new PluginKey("prosemirror-menu * @param filef The file format of the currently opened file. * @returns A prosemirror `Plugin` type containing the menubar. */ -export function menuPlugin(os: OS) { +export function menuPlugin(os: OS, tagConf: TagConfiguration) { return new Plugin({ // This plugin has an associated `view`. This allows it to add DOM elements. view(outerView: EditorView) { // Create the default menu. - const menuView = createDefaultMenu(outerView, os); + const menuView = createDefaultMenu(outerView, os, tagConf); // Get the parent node (the parent node of the outer prosemirror dom) const parentNode = outerView.dom.parentNode; if (parentNode == null) { diff --git a/src/schema/schema-nodes.ts b/src/schema/schema-nodes.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/schema/schema.ts b/src/schema/schema.ts index e9e0e5f..1eebc82 100644 --- a/src/schema/schema.ts +++ b/src/schema/schema.ts @@ -5,14 +5,15 @@ export const SchemaCell = { Hint: "hint", Markdown: "markdown", MathDisplay: "math_display", - Code: "code" + Code: "code", + Newline: "newline" } as const; export type SchemaKeys = keyof typeof SchemaCell; export type SchemaNames = typeof SchemaCell[SchemaKeys]; -const cell = `(markdown | hint | code | input | math_display)`; -const containercontent = "(markdown | code | math_display)"; +const cell = `(markdown | hint | code | input | math_display | newline)`; +const containercontent = "(markdown | code | math_display | newline)"; // const groupMarkdown = "markdowncontent"; /** @@ -81,12 +82,6 @@ export const WaterproofSchema = new Schema({ //#region Code code: { content: "text*",// content is of type text - attrs: { - prePreWhite:{default:"newLine"}, - prePostWhite:{default:"newLine"}, - postPreWhite:{default:"newLine"}, - postPostWhite:{default:"newLine"} - }, code: true, atom: true, // doesn't have directly editable content (content is edited through codemirror) toDOM(node) { return ["WaterproofCode", node.attrs, 0] } // cells @@ -104,27 +99,11 @@ export const WaterproofSchema = new Schema({ toDOM(node) { return ["math-display", {...{ class: "math-node" }, ...node.attrs}, 0]; }, }, //#endregion - }, - // marks: { - // em: { - // toDOM() { return ["em"] } - // }, - - // strong: { - // toDOM() { return ["strong"] } - // }, - // link: { - // attrs: { - // href: {}, - // title: {default: null} - // }, - // inclusive: false, - // toDOM(node) { return ["a", node.attrs] } - // }, - - // code: { - // toDOM() { return ["code"] } - // } - // } + newline: { + toDOM(node) { return ["WaterproofNewline", node.attrs]}, + selectable: false, + atom: true, + } + } }); \ No newline at end of file From d88865e4ea51f7fb8a8cb64369f0ec09a1ba9c1e Mon Sep 17 00:00:00 2001 From: DikieDick <26772815+DikieDick@users.noreply.github.com> Date: Sun, 28 Sep 2025 10:49:31 +0200 Subject: [PATCH 13/50] Add node delete functionality --- src/commands/command-helpers.ts | 18 +------- src/commands/commands.ts | 78 ++++++++++++++++++++++++++++++++- src/commands/utils.ts | 68 ++++++++++++++++++++++++++++ src/editor.ts | 7 +-- 4 files changed, 150 insertions(+), 21 deletions(-) create mode 100644 src/commands/utils.ts diff --git a/src/commands/command-helpers.ts b/src/commands/command-helpers.ts index eea9db3..97c0c1c 100644 --- a/src/commands/command-helpers.ts +++ b/src/commands/command-helpers.ts @@ -1,10 +1,11 @@ /////// Helper functions ///////// -import { NodeType, Node as PNode, ResolvedPos } from "prosemirror-model"; +import { NodeType, Node as PNode } from "prosemirror-model"; import { EditorState, TextSelection, Transaction, Selection, NodeSelection } from "prosemirror-state"; import { INPUT_AREA_PLUGIN_KEY } from "../inputArea"; import { WaterproofSchema } from "../schema"; import { newline } from "../document/blocks/schema"; +import { getSurroundingNodes } from "./utils"; /////// Helper functions ///////// @@ -126,21 +127,6 @@ export function nodeFromSel(sel: Selection): PNode | undefined { } } -function getSurroundingNodes($from: ResolvedPos): {before: PNode | null; after: PNode | null} { - const depth = $from.depth; - let parent; - let index; - if (depth === 0) { - parent = $from.parent; - index = $from.index(0); - } else { - parent = $from.node(1); - index = $from.index(1); - } - const before = index > 0 ? parent.child(index - 1) : null; - const after = index < parent.childCount - 1 ? parent.child(index + 1) : null; - return {before, after}; -} // function getSurroundingNodes(sel: Selection): {before: PNode | null; after: PNode | null} { // // console.log(sel); diff --git a/src/commands/commands.ts b/src/commands/commands.ts index 5613f75..6b97df6 100644 --- a/src/commands/commands.ts +++ b/src/commands/commands.ts @@ -1,8 +1,10 @@ import { NodeRange } from "prosemirror-model"; -import { Command, EditorState, NodeSelection, Transaction } from "prosemirror-state"; +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 { TagConfiguration } from "../api"; export const liftWrapper: Command = (state: EditorState, dispatch?: ((tr: Transaction) => void), _view?: EditorView): boolean => { const sel = state.selection; @@ -25,4 +27,76 @@ export const liftWrapper: Command = (state: EditorState, dispatch?: ((tr: Transa } return false; -} \ No newline at end of file +} + +export function deleteSelection(tagConf: TagConfiguration): Command { + return (state, dispatch) => { + if (state.selection.empty) return false; + if (state.selection instanceof TextSelection) { + if (dispatch) dispatch(state.tr.deleteSelection().scrollIntoView()); + return true; + } else if (state.selection instanceof NodeSelection) { + // console.log("Deleting node selection"); + const {parent, index} = getParentAndIndex(state.selection.$from); + // console.log("Parent and index:", parent, index); + + const before = parent.maybeChild(index - 1); + const after = parent.maybeChild(index + 1); + const beforeSize = before !== null ? before.nodeSize : 0; + const afterSize = after !== null ? after.nodeSize : 0; + // node before before + const befoore = parent.maybeChild(index - 2); + // node after after + const afteer = parent.maybeChild(index + 2); + // console.log("Before and after:", before, after); + // console.log("Befoore and afteer:", befoore, afteer); + + const beforeIsNewline = before !== null ? before.type === WaterproofSchema.nodes.newline : false; + const afterIsNewline = after !== null ? after.type === WaterproofSchema.nodes.newline : false; + + if (beforeIsNewline && afterIsNewline && befoore !== null && afteer !== null && needsNewlineAfter(befoore.type, tagConf) && needsNewlineBefore(afteer.type, tagConf)) { + console.log("Before and after are newlines, and befoore needs newline after and afteer needs newline before"); + // Before and after are newlines, and befoore needs newline after and afteer needs newline before + // We need to keep one of the newlines, so we delete the node and the after newline + if (dispatch) dispatch(state.tr.delete(state.selection.from, state.selection.to + afterSize).scrollIntoView()); + + return true; + } else if (afterIsNewline && afteer !== null && needsNewlineBefore(state.selection.node.type, tagConf)) { + console.log("After is newline and afteer needs newline before"); + // After is newline and afteer needs newline before + // We need to keep the after newline, so we delete the node and the before newline + if (dispatch) dispatch(state.tr.delete(state.selection.from - beforeSize, state.selection.to).scrollIntoView()); + return true; + } else if (beforeIsNewline && befoore !== null && needsNewlineAfter(befoore.type, tagConf)) { + console.log("Before is newline and befoore needs newline after"); + // Before is newline and befoore needs newline after + // We need to keep the before newline, so we delete the node and the after newline + if (dispatch) dispatch(state.tr.delete(state.selection.from, state.selection.to + afterSize).scrollIntoView()); + return true; + } else if (beforeIsNewline && afterIsNewline && (befoore === null || (befoore !== null && !needsNewlineAfter(befoore.type, tagConf))) && (afteer === null || (afteer !== null && !needsNewlineBefore(afteer.type, tagConf)))) { + console.log("Before and after are newlines, but befoore does not need newline after and afteer does not need newline before"); + // Before and after are newlines, but befoore does not need newline after and afteer does not need newline before + // We can delete both newlines + if (dispatch) dispatch(state.tr.delete(state.selection.from - beforeSize, state.selection.to + afterSize).scrollIntoView()); + return true; + } else { + console.log("Deleting node selection"); + if (dispatch) dispatch(state.tr.deleteSelection().scrollIntoView()); + return true; + } + // const before = index > 0 ? parent.child(index - 1) : null; + // const after = index < parent.childCount - 1 ? parent.child(index + 1) : null; + + + // const befoore = index > 1 ? parent.child(index - 2) : null; + // const afteer = index < parent.childCount - 2 ? parent.child(index + 2) : null; + + + // if (before && before.type == WaterproofSchema.nodes.newline) { + // // We have a newline before + // const befooreNeedsNewline = befoore !== null ? needsNewlineAfter(befoore.type, tagConf) : false; + // } + } + return false; + } +} diff --git a/src/commands/utils.ts b/src/commands/utils.ts new file mode 100644 index 0000000..58a333c --- /dev/null +++ b/src/commands/utils.ts @@ -0,0 +1,68 @@ +import { ResolvedPos, Node, NodeType } from "prosemirror-model"; +import { TagConfiguration } from "../api"; +import { WaterproofSchema } from "../schema"; + +export function getSurroundingNodes($pos: ResolvedPos): {before: Node | null; after: Node | null} { + const depth = $pos.depth; + let parent; + let index; + if (depth === 0) { + parent = $pos.parent; + index = $pos.index(0); + } else { + parent = $pos.node(1); + index = $pos.index(1); + } + const before = index > 0 ? parent.child(index - 1) : null; + const after = index < parent.childCount - 1 ? parent.child(index + 1) : null; + return {before, after}; +} + +export function getParentAndIndex($pos: ResolvedPos): {parent: Node; index: number} { + const depth = $pos.depth; + let parent; + let index; + if (depth === 0) { + parent = $pos.parent; + index = $pos.index(0); + } + else { + parent = $pos.node(1); + index = $pos.index(1); + } + return {parent, index}; +} + +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; + } +} + +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 diff --git a/src/editor.ts b/src/editor.ts index 47f2dbf..c0b19c6 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -1,5 +1,5 @@ import { mathPlugin, mathSerializer } from "@benrbray/prosemirror-math"; -import { deleteSelection, selectParentNode } from "prosemirror-commands"; +import { selectParentNode } from "prosemirror-commands"; import { keymap } from "prosemirror-keymap"; import { ResolvedPos, Schema, Node as ProseNode } from "prosemirror-model"; import { EditorState, NodeSelection, Plugin, Selection, TextSelection, Transaction } from "prosemirror-state"; @@ -33,6 +33,7 @@ import { setCurrentTheme } from "./themeStore"; import { ServerStatus } from "./api"; import { getCmdInsertCode, getCmdInsertLatex, getCmdInsertMarkdown } from "./commands/insert-command"; import { InsertionPlace } from "./commands"; +import { deleteSelection } from "./commands/commands"; /** Type that contains a coq diagnostics object fit for use in the ProseMirror editor context. */ type DiagnosticObjectProse = {message: string, start: number, end: number, $start: ResolvedPos, $end: ResolvedPos, severity: Severity}; @@ -262,8 +263,8 @@ export class WaterproofEditor { this.executeCommand("Help."); return true; }, - "Backspace": deleteSelection, - "Delete": deleteSelection, + "Backspace": deleteSelection(this._editorConfig.tagConfiguration), + "Delete": deleteSelection(this._editorConfig.tagConfiguration), "Mod-m": getCmdInsertMarkdown(InsertionPlace.Below, this._editorConfig.tagConfiguration), "Mod-M": getCmdInsertMarkdown(InsertionPlace.Above, this._editorConfig.tagConfiguration), "Mod-q": getCmdInsertCode(InsertionPlace.Below, this._editorConfig.tagConfiguration), From bf24236f6bfc9e99a27110625a38a6a63e2fe3e8 Mon Sep 17 00:00:00 2001 From: DikieDick <26772815+DikieDick@users.noreply.github.com> Date: Sun, 28 Sep 2025 18:56:30 +0200 Subject: [PATCH 14/50] Insert above works, insert below needs work --- src/commands/command-helpers.ts | 50 ++++++++++++++++++++++----------- src/commands/insert-command.ts | 8 +++--- src/commands/types.ts | 2 +- 3 files changed, 39 insertions(+), 21 deletions(-) diff --git a/src/commands/command-helpers.ts b/src/commands/command-helpers.ts index 97c0c1c..6e89c98 100644 --- a/src/commands/command-helpers.ts +++ b/src/commands/command-helpers.ts @@ -85,36 +85,54 @@ export function insertAbove(state: EditorState, tr: Transaction, nodeType: NodeT } /** - * Helper function for inserting a new node underneath the currently selected one. + * Helper function for inserting a new node below the currently selected one. * @param state The current editor state. * @param tr The current transaction for the state of the editor. * @param escapeContainingNode Whether to escape the containing node. * @param nodeType ? * @returns An insertion transaction. */ -export function insertUnder(state: EditorState, tr: Transaction, nodeType: NodeType, insertNewlineBeforeIfNotExists: boolean, insertNewlineAfterIfNotExists: boolean): Transaction | undefined { - const sel = state.selection; +export function insertBelow(state: EditorState, tr: Transaction, nodeType: NodeType, insertNewlineBeforeIfNotExists: boolean, insertNewlineAfterIfNotExists: boolean): Transaction | undefined { + console.log("INSERTING BELOW"); + const sel = state.selection; let trans: Transaction = tr; - + + const {after} = getSurroundingNodes(sel.$from); + const afterIsNewline = after !== null ? (after.type === WaterproofSchema.nodes.newline) : false; + // console.log("After", after?.type.name); + let pos; + if (sel instanceof NodeSelection) { // To and from point directly to beginning and end of node. - const pos = sel.to; - trans = trans.insert(pos, nodeType.create()); - return trans; + pos = sel.to; } else if (sel instanceof TextSelection) { - const to = sel.to + (sel.$from.parent.nodeSize - sel.$from.parentOffset) - 1; - - if (to > state.doc.nodeSize) { - console.log("The computed `to` value lies outside of the document"); - return; - } + pos = sel.to + (sel.$from.parent.nodeSize - sel.$from.parentOffset) - 1; + } else { + return; + } - trans = trans.insert(to, nodeType.create()); - return trans; + if (afterIsNewline) { + // Assumption: If a newline appears after a node the current node wants that. + pos += 1; // We are going to insert after + } + + // console.log("Node at", state.doc.nodeAt(pos)); + const newAfter = getSurroundingNodes(state.doc.resolve(pos)).after; + // console.log("newafter", newAfter); + + const toInsert: PNode[] = []; + if (insertNewlineBeforeIfNotExists && !afterIsNewline) { + toInsert.push(newline()); + } + toInsert.push(nodeType.create()); + if (insertNewlineAfterIfNotExists && newAfter?.type !== WaterproofSchema.nodes.newline) { + toInsert.push(newline()); } - return; + trans = trans.insert(pos, toInsert); + + return trans; } export function nodeFromSel(sel: Selection): PNode | undefined { diff --git a/src/commands/insert-command.ts b/src/commands/insert-command.ts index d02248d..a399ba3 100644 --- a/src/commands/insert-command.ts +++ b/src/commands/insert-command.ts @@ -1,6 +1,6 @@ import { EditorState, Transaction } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; -import { allowedToInsert, insertAbove, insertUnder } from "./command-helpers"; +import { allowedToInsert, insertAbove, insertBelow } from "./command-helpers"; import { WaterproofSchema } from "../schema"; import { InsertionPlace } from "./types"; import { TagConfiguration } from "../api"; @@ -14,7 +14,7 @@ export function getCmdInsertMarkdown(place: InsertionPlace, tagConf: TagConfigur // Can we attempt this command in a case where our state and selection is such that // we can't actually add the node there? - const f = place === InsertionPlace.Above ? insertAbove : insertUnder; + const f = place === InsertionPlace.Above ? insertAbove : insertBelow; const trans = f(state, state.tr, WaterproofSchema.nodes.markdown, tagConf.markdown.openRequiresNewline, tagConf.markdown.closeRequiresNewline); @@ -33,7 +33,7 @@ export function getCmdInsertLatex(place: InsertionPlace, tagConf: TagConfigurati // Early return when inserting is not allowed. if (!allowedToInsert(state)) return false; - const f = place === InsertionPlace.Above ? insertAbove : insertUnder; + const f = place === InsertionPlace.Above ? insertAbove : insertBelow; const trans = f(state, state.tr, WaterproofSchema.nodes.math_display, tagConf.math.openRequiresNewline, tagConf.math.closeRequiresNewline); if (trans === undefined) { return false; } @@ -51,7 +51,7 @@ export function getCmdInsertCode(place: InsertionPlace, tagConf: TagConfiguratio // Again, early return when inserting is not allowed. if (!allowedToInsert(state)) return false; - const f = place === InsertionPlace.Above ? insertAbove : insertUnder; + const f = place === InsertionPlace.Above ? insertAbove : insertBelow; const trans = f(state, state.tr, WaterproofSchema.nodes.code, tagConf.code.openRequiresNewline, tagConf.code.closeRequiresNewline); if (trans === undefined) { return false; } diff --git a/src/commands/types.ts b/src/commands/types.ts index 6499030..480b499 100644 --- a/src/commands/types.ts +++ b/src/commands/types.ts @@ -2,7 +2,7 @@ import { NodeType } from "prosemirror-model"; import { EditorState, Transaction } from "prosemirror-state"; /** - * Enum for the insertion place, can be either `Above` or `Underneath` the currently selected cell. + * Enum for the insertion place, can be either `Above` or `Below` the currently selected cell. */ export enum InsertionPlace { Above, From b6d5b3091259c698b803bc7546d9d0e3953505cc Mon Sep 17 00:00:00 2001 From: DikieDick <26772815+DikieDick@users.noreply.github.com> Date: Sun, 28 Sep 2025 20:34:08 +0200 Subject: [PATCH 15/50] Fix vFile parser and default markdown parser, move markdown parser to editor --- src/commands/command-helpers.ts | 2 +- src/commands/commands.ts | 26 ++ src/index.ts | 2 +- .../index.ts} | 10 +- src/markdown-defaults/statemachine.ts | 327 ++++++++++++++++++ 5 files changed, 361 insertions(+), 6 deletions(-) rename src/{markdownDefaults.ts => markdown-defaults/index.ts} (83%) create mode 100644 src/markdown-defaults/statemachine.ts diff --git a/src/commands/command-helpers.ts b/src/commands/command-helpers.ts index 6e89c98..ea084b4 100644 --- a/src/commands/command-helpers.ts +++ b/src/commands/command-helpers.ts @@ -93,7 +93,7 @@ export function insertAbove(state: EditorState, tr: Transaction, nodeType: NodeT * @returns An insertion transaction. */ export function insertBelow(state: EditorState, tr: Transaction, nodeType: NodeType, insertNewlineBeforeIfNotExists: boolean, insertNewlineAfterIfNotExists: boolean): Transaction | undefined { - console.log("INSERTING BELOW"); + // console.log("INSERTING BELOW"); const sel = state.selection; let trans: Transaction = tr; diff --git a/src/commands/commands.ts b/src/commands/commands.ts index 6b97df6..559cfca 100644 --- a/src/commands/commands.ts +++ b/src/commands/commands.ts @@ -50,6 +50,7 @@ export function deleteSelection(tagConf: TagConfiguration): Command { const afteer = parent.maybeChild(index + 2); // console.log("Before and after:", before, after); // console.log("Befoore and afteer:", befoore, afteer); + // console.log("Before using nodeBefore and nodeAfter:", state.selection.$from.nodeBefore, state.selection.$to.nodeAfter); const beforeIsNewline = before !== null ? before.type === WaterproofSchema.nodes.newline : false; const afterIsNewline = after !== null ? after.type === WaterproofSchema.nodes.newline : false; @@ -100,3 +101,28 @@ export function deleteSelection(tagConf: TagConfiguration): Command { return false; } } + +export function wrapInInput(tagConf: TagConfiguration): Command { + return (state, dispatch) => { + const sel = state.selection; + // We need to possible extend this blockRange + // sel.$from.blockRange(sel.$to); + + + + const before = sel.$from.nodeBefore; + const after = sel.$to.nodeAfter; + + const beforeIsNewline = before !== null ? before.type === WaterproofSchema.nodes.newline : false; + const afterIsNewline = after !== null ? after.type === WaterproofSchema.nodes.newline : false; + + + const nodeBeingWrapped = state.doc.nodeAt(sel.from); + + // const nodeAtEnd = state.doc.nodeAt(sel.to - 1); + + + // sel.$from.block + return false; + } +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index a105f63..d842740 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,4 +4,4 @@ export { WaterproofSchema } from "./schema"; export * from "./document"; export * from "./api"; export { defaultToMarkdown } from "./translation"; -export { markdownConfiguration, markdownSerializers } from "./markdownDefaults"; \ No newline at end of file +export * as "markdown" from "./markdown-defaults"; \ No newline at end of file diff --git a/src/markdownDefaults.ts b/src/markdown-defaults/index.ts similarity index 83% rename from src/markdownDefaults.ts rename to src/markdown-defaults/index.ts index 442333b..eefffdf 100644 --- a/src/markdownDefaults.ts +++ b/src/markdown-defaults/index.ts @@ -1,6 +1,8 @@ -import { Serializers, TagConfiguration } from "./api" +import { Serializers, TagConfiguration } from "../api"; -export function markdownConfiguration(languageId: string): TagConfiguration { +export { parser } from "./statemachine"; + +export function configuration(languageId: string): TagConfiguration { return { markdown: { openTag: "", closeTag: "", @@ -33,8 +35,8 @@ export function markdownConfiguration(languageId: string): TagConfiguration { * @param languageId * @returns */ -export function markdownSerializers(languageId: string): Serializers { - const tagConf = markdownConfiguration(languageId); +export function serializers(languageId: string): Serializers { + const tagConf = configuration(languageId); return { code: (content) => tagConf.code.openTag + content + tagConf.code.closeTag, input: (content) => tagConf.input.openTag + content + tagConf.input.closeTag, diff --git a/src/markdown-defaults/statemachine.ts b/src/markdown-defaults/statemachine.ts new file mode 100644 index 0000000..fd58b98 --- /dev/null +++ b/src/markdown-defaults/statemachine.ts @@ -0,0 +1,327 @@ +import { WaterproofDocument } from "../api"; +import { Block, CodeBlock, HintBlock, InputAreaBlock, MarkdownBlock, MathDisplayBlock, NewlineBlock } from "../document"; + +enum ParserState { + /** Parsing regular markdown content */ + Markdown, + /** Parsing the contents of a code block ` ```langid ` to ` ``` ` */ + Code, + /** Inside a LaTeX block (i.e. $$ ... $$) */ + LaTeX, + /** Parsing a hint title (i.e. after `` and ` ` is turned into a hint cell, `{title}` will turn into the title that is displayed in the editor. + * * The content between `` and `` is turned into an input area. + * @param document The document to convert into a `WaterproofDocument` + * @param language The language tag to use for the code cells. That is, the part of the ` ``` ` when opening a code block (` ```python ` for a python + * code block). Defaults to `""`. + * @returns A array of `Block` that form a `WaterproofDocument`. + */ +export function parser(document: string, language: string = ""): WaterproofDocument { + // Stack to store the produced blocks + const blocks: Block[] = []; + + // Whether we are in a nested state, initially set to none. + let nested: NestedState = NestedState.None; + + let innerBlocks: Block[] = [] + let state: ParserState = ParserState.Markdown; + let rangeStart = 0; // Range of the entire block + let innerRangeStart = 0; // Range of the content + + let rangeStartNested = 0; + let innerRangeStartNested = 0; + + let hintTitle = ""; + + let i = 0; + + // Stores the offset of a codeblock (1 if we have an extra \n, 0 otherwise) + let codeBlockOffset = 0; + + // Define the tags and their length. + const hintOpen = ' getRangeStart()) { + const range = { from: getRangeStart(), to: i }; + const markdownBlock = new MarkdownBlock( + document.slice(getRangeStart(), i), + range, range); + pushBlock(markdownBlock); + } + } + + while (i < document.length) { + switch (state) { + case ParserState.Markdown: { + if (opensCodeBlock()) { + closeMarkdown(); + // Set parser state to start parsing the code block contents. + state = ParserState.Code; + setRangeStart(); + i += codeBlockOffset + codeBlockOpenLength; + setInnerRangeStart(); + continue; + } + else if (opensLaTeXBlock()) { + closeMarkdown(); + state = ParserState.LaTeX; + setRangeStart(); + i += latexBlockOpenCloseLength; // Skip the $$ + setInnerRangeStart(); + continue; + } + else if (nested === NestedState.None && opensHintBlock()) { + closeMarkdown(); + setRangeStart(); + i += hintOpenLength; // Skip the + backToMarkdown(true); + continue; + } + else { + i++; + continue; + } + } + case ParserState.Code: { + if (closesCodeBlock()) { + // End of this code block + const range = { from: getRangeStart(), to: i + codeBlockCloseLength + codeBlockOffset }; + const innerRange = { from: getInnerRangeStart(), to: i }; + const codeBlock = new CodeBlock( + document.slice(innerRange.from, innerRange.to), + range, + innerRange); + const newlineBefore = document[rangeStart - 1] === '\n'; + const newlineAfter = codeBlockOffset === 1 ? "\n" : ""; + + if (newlineBefore) { + pushBlock(new NewlineBlock({ from: rangeStart - 1, to: rangeStart }, { from: rangeStart - 1, to: rangeStart })); + } + pushBlock(codeBlock); + if (newlineAfter) { + pushBlock(new NewlineBlock({ from: range.to - 1, to: range.to }, { from: range.to - 1, to: range.to })); + } + + i += codeBlockCloseLength + codeBlockOffset; // Skip the closing ``` + backToMarkdown(); + continue; + } else { + i++; + continue; + } + } + case ParserState.LaTeX: { + if (closesLaTeXBlock()) { + // End of this LaTeX block + const range = { from: getRangeStart(), to: i + latexBlockOpenCloseLength }; + const innerRange = { from: getInnerRangeStart(), to: i }; + const mathBlock = new MathDisplayBlock( + document.slice(getInnerRangeStart(), i), + range, + innerRange); + pushBlock(mathBlock); + i += latexBlockOpenCloseLength; // Skip the closing $$ + backToMarkdown(); + continue; + } else { + i++; + continue; + } + } + case ParserState.HintTitle: { + // Parse until we find the closing quote and > + while (i < document.length) { + const char = document[i]; + if (char === '"' && document[i + 1] === '>') { + i += 2; // Skip the closing quote and > + // Back to parsing markdown + backToMarkdown(); + // The inner range of the hint starts here. + innerRangeStart = i; + break; + } else { + hintTitle += char; + i++; + } + } + break; + } + } + } + + // If there is still content then we should create a final markdown block. + closeMarkdown(); + return blocks; + +} \ No newline at end of file From 38a47a826ee7ffc4bb0a7b8d276aa57a1b16d28c Mon Sep 17 00:00:00 2001 From: DikieDick <26772815+DikieDick@users.noreply.github.com> Date: Mon, 29 Sep 2025 10:39:16 +0200 Subject: [PATCH 16/50] Add serializer, remove preProcessor step, allow editor to serialize full document --- src/api/index.ts | 4 +- src/api/types.ts | 79 ++++++++++++++----------- src/editor.ts | 33 +++++------ src/markdown-defaults/index.ts | 22 ++----- src/serialization/DocumentSerializer.ts | 62 +++++++++++++++++++ 5 files changed, 130 insertions(+), 70 deletions(-) create mode 100644 src/serialization/DocumentSerializer.ts diff --git a/src/api/index.ts b/src/api/index.ts index afe1cb1..ebaf62f 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -16,4 +16,6 @@ export { Completion } from "@codemirror/autocomplete"; export { Step, ReplaceStep, ReplaceAroundStep } from "prosemirror-transform"; export { Fragment, Node } from "prosemirror-model"; -export { ServerStatus, Idle, Busy } from "./ServerStatus"; \ No newline at end of file +export { ServerStatus, Idle, Busy } from "./ServerStatus"; + +export { DocumentSerializer } from "../serialization/DocumentSerializer"; \ No newline at end of file diff --git a/src/api/types.ts b/src/api/types.ts index a498c4d..658477a 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -1,22 +1,7 @@ import { Step } from "prosemirror-transform"; -import { DocChange, WrappingDocChange, Severity, WaterproofCompletion, WaterproofSymbol, Node } from "."; +import { DocChange, WrappingDocChange, Severity, WaterproofCompletion, WaterproofSymbol, Node, DocumentSerializer } from "."; import { Block } from "../document"; -/** - * Represents an area of text, that is editable in the prosemirror view and its - * mapping to the vscode document - */ -export type StringCell = { - /** The prosemirror starting index of this cell */ - startProse: number, - /** The prosemirror ending index of this cell */ - endProse: number, - /** The starting index of this cell in the text document string vscode side */ - startText: number, - /** The ending index of this cell in the text document string vscode side */ - endText: number, -}; - export type Positioned = { obj: A; pos: number | undefined; @@ -60,10 +45,37 @@ export abstract class WaterproofMapping { abstract update: (step: Step, doc: Node) => DocChange | WrappingDocChange; } -export type OpenCloseTag = { openTag: string, closeTag: string } +/** + * Type describing the open and close tag for a cell. + */ +export type OpenCloseTag = { + openTag: string, + closeTag: string +} +/** + * Type describing whether the open tag requires a newline before and whether the closing tag requires a newline after. + * + * Together with the string representation this will ensure that the tags marked with `true` will appear on their own line. + * + * Example: The ` ```language ` and ` ``` ` tags in a Markdown file should be placed on their own line (i.e. the first backtick should be placed as the first character on a line. ) + * We ensure this by setting the open and close tag (see {@linkcode OpenCloseTag}) to ` ```language\n ` and ` \n``` ` respectively and setting both `openRequiresNewline` and `closeRequiersNewline` to true. + * + * When inserting new nodes into the document WaterproofEditor will ensure that the nodes marked with `openRequiresNewline = true` always have a newline before + * and nodes marked with `closeRequiresNewline = true` always have a newline after. + */ export type RequiresNewline = { openRequiresNewline: boolean, closeRequiresNewline: boolean }; +/** + * The `TagConfiguration` describes how the different parts of a document on disk are described/delimited. WaterproofEditor will use + * these tags when generating edits and serializing the document. + * + * _Note_: The tags described here should be the same as generating when + * + * For example in a Markdown file, code should be placed in between ` ```language ` and ` ``` `, where `language` denotes the language of the code. + * + * Every type of cell representable in `WaterproofEditor` should receive some form of tag. If the content of some cell has no open or close tag use `""`. + */ export type TagConfiguration = { markdown: OpenCloseTag & RequiresNewline, code: OpenCloseTag & RequiresNewline, @@ -72,16 +84,6 @@ export type TagConfiguration = { math: OpenCloseTag & RequiresNewline, } -export type CommonSerializer = (content: string) => string; - -export type Serializers = { - markdown: CommonSerializer, - code: CommonSerializer, - input: CommonSerializer, - math: CommonSerializer, - hint: (content: string, title: string) => string -} - export class NodeUpdateError extends Error { constructor(message: string) { super("[NodeUpdateError]" + message); } } @@ -111,18 +113,29 @@ export type WaterproofEditorConfig = { /** Determines how the editor document gets constructed from a string input. */ documentConstructor: (document: string) => WaterproofDocument, /** How to construct a mapping for this editor. The mapping is responsible for mapping changes from the underlying ProseMirror instance into changes that can be applied to the underlying document. */ - mapping: new (inputDocument: WaterproofDocument, versionNum: number, tagMap: TagConfiguration, serializers: Serializers) => WaterproofMapping, + mapping: new (inputDocument: WaterproofDocument, versionNum: number, tagMap: TagConfiguration, serializer: DocumentSerializer) => WaterproofMapping, + /** + * The tag configuration to use for this editor. + * + * See {@linkcode TagConfiguration} for more information. + */ tagConfiguration: TagConfiguration, - serializers: Serializers, - /** The name of the markdown node view, defaults to "markdown" */ + /** The name of the markdown node view, defaults to `"Markdown"`. + * This name will show up in the editor when editing text in 'Markdown' cells. + */ markdownName?: string, + /** + * A function that can be used to convert a different markup variant into Markdown. + * + * This function is also responsible for converting math-inline content (e.g. in Markdown this is the content between `$` and `$`) to math-inline + * nodes. That is, the content should be placed inside `` and ``. + * @param inputString The input string that should be converted to Markdown + * @returns The output string should be valid Markdown, with possible inline LaTeX wrapped in the tags as described above. + */ toMarkdown?: (inputString: string) => string, - - /** THIS IS A TEMPORARY FEATURE THAT WILL GET REMOVED */ - documentPreprocessor?: (inputString: string) => {resultingDocument: string, documentChange: DocChange | WrappingDocChange | undefined}, } export enum HistoryChange { diff --git a/src/editor.ts b/src/editor.ts index c0b19c6..e53c87b 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -8,7 +8,7 @@ import { EditorView } from "prosemirror-view"; import { undo, redo, history } from "prosemirror-history"; import { constructDocument } from "./document/construct-document"; -import { DocChange, LineNumber, InputAreaStatus, SimpleProgressParams, WrappingDocChange, HistoryChange, Severity, MappingError, NodeUpdateError, TextUpdateError } from "./api"; +import { DocChange, LineNumber, InputAreaStatus, SimpleProgressParams, WrappingDocChange, HistoryChange, Severity, MappingError, NodeUpdateError, TextUpdateError, DocumentSerializer } from "./api"; import { CODE_PLUGIN_KEY, codePlugin } from "./codeview"; import { createHintPlugin } from "./hinting"; import { INPUT_AREA_PLUGIN_KEY, inputAreaPlugin } from "./inputArea"; @@ -64,6 +64,8 @@ export class WaterproofEditor { private _lineNumbersShown: boolean = false; + private _serializer: DocumentSerializer; + /** * Create a new WaterproofEditor instance. * @param editorElement The HTML element where the editor will be inserted in the document @@ -74,6 +76,7 @@ export class WaterproofEditor { this._editorElem = editorElement; this.currentProseDiagnostics = []; this._editorConfig = config; + this._serializer = new DocumentSerializer(this._editorConfig.tagConfiguration); const userAgent = window.navigator.userAgent; this._userOS = OS.Unknown; @@ -119,25 +122,10 @@ export class WaterproofEditor { this._view.dom.remove(); } - let resultingDocument = content; - let documentChange: DocChange | WrappingDocChange | undefined = undefined; - - if (this._editorConfig.documentPreprocessor !== undefined) { - console.log("Using document preprocessor!!"); - const result = this._editorConfig.documentPreprocessor(content); - resultingDocument = result.resultingDocument; - documentChange = result.documentChange; - if (documentChange !== undefined) { - console.log("Document change due to preprocessor: ", documentChange); - this._editorConfig.api.documentChange(documentChange); - } - if (resultingDocument !== content) version = version + 1; - } - - const blocks = this._editorConfig.documentConstructor(resultingDocument); + const blocks = this._editorConfig.documentConstructor(content); const proseDoc = constructDocument(blocks); - this._mapping = new this._editorConfig.mapping(blocks, version, this._editorConfig.tagConfiguration, this._editorConfig.serializers); + this._mapping = new this._editorConfig.mapping(blocks, version, this._editorConfig.tagConfiguration, this._serializer); this.createProseMirrorEditor(proseDoc); /** Ask for line numbers */ @@ -277,6 +265,15 @@ export class WaterproofEditor { ]; } + /** + * Serialize the current document to a string. + * @returns Either the serialized document or `undefined` when the editor is not initialized. + */ + public serializeDocument(): string | undefined { + if (!this._view) return; + return this._serializer.serializeDocument(this._view.state.doc); + } + public updateNodeViewThemes(theme: ThemeStyle) { setCurrentTheme(theme); const view = this._view!; diff --git a/src/markdown-defaults/index.ts b/src/markdown-defaults/index.ts index eefffdf..1fbe36d 100644 --- a/src/markdown-defaults/index.ts +++ b/src/markdown-defaults/index.ts @@ -1,4 +1,4 @@ -import { Serializers, TagConfiguration } from "../api"; +import { TagConfiguration } from "../api"; export { parser } from "./statemachine"; @@ -9,9 +9,11 @@ export function configuration(languageId: string): TagConfiguration { openRequiresNewline: false, closeRequiresNewline: false, }, code: { + // There should be a newline before the opening tag of the code cell. + openRequiresNewline: true, openTag: "```" + languageId + "\n", closeTag: "\n```", - openRequiresNewline: true, + // There should be a newline after the closing tag of the code cell. closeRequiresNewline: true, }, hint: { @@ -29,19 +31,3 @@ export function configuration(languageId: string): TagConfiguration { } } }; - -/** - * Assumes using the `markdownTagMap` with the same language id. - * @param languageId - * @returns - */ -export function serializers(languageId: string): Serializers { - const tagConf = configuration(languageId); - return { - code: (content) => tagConf.code.openTag + content + tagConf.code.closeTag, - input: (content) => tagConf.input.openTag + content + tagConf.input.closeTag, - hint: (content, title) => tagConf.hint.openTag(title) + content + tagConf.hint.closeTag, - markdown: (content) => tagConf.markdown.openTag + content + tagConf.markdown.closeTag, - math: (content) => tagConf.math.openTag + content + tagConf.math.closeTag, - }; -} diff --git a/src/serialization/DocumentSerializer.ts b/src/serialization/DocumentSerializer.ts new file mode 100644 index 0000000..1d4833e --- /dev/null +++ b/src/serialization/DocumentSerializer.ts @@ -0,0 +1,62 @@ +import { Node } from "prosemirror-model"; +import { WaterproofSchema } from "../schema"; +import { TagConfiguration } from "../api"; + +export class DocumentSerializer { + constructor(private tagConf: TagConfiguration) {} + + /** + * + * @param node + * @returns + */ + serializeNode(node: Node): string { + + let serialized: string = ""; + if (node.type == WaterproofSchema.nodes.markdown) { + const serializerOutput = this.tagConf.markdown.openTag + node.textContent + this.tagConf.markdown.closeTag; + serialized = serializerOutput; + } else if (node.type == WaterproofSchema.nodes.code) { + const serializerOutput = this.tagConf.code.openTag + node.textContent + this.tagConf.code.closeTag; + serialized = serializerOutput; + } else if (node.type == WaterproofSchema.nodes.hint) { + const title = node.attrs.title; + // Has child content + const textContent: string[] = []; + node.forEach(child => { + const output = this.serializeNode(child); + textContent.push(output); + }); + serialized = this.tagConf.hint.openTag(title) + textContent.join("") + this.tagConf.hint.closeTag; + } else if (node.type == WaterproofSchema.nodes.input) { + // Has child content + const textContent: string[] = []; + node.forEach(child => { + const output = this.serializeNode(child); + textContent.push(output); + }); + serialized = this.tagConf.input.openTag + textContent.join("") + this.tagConf.input.closeTag; + } else if (node.type == WaterproofSchema.nodes.math_display) { + const serializerOutput = this.tagConf.math.openTag + node.textContent + this.tagConf.math.closeTag; + serialized += serializerOutput; + } else if (node.type == WaterproofSchema.nodes.newline) { + serialized = "\n"; + } else { + throw new Error(`[NodeSerializer] Encountered unsupported node type: ${node.type.name}`); + } + + return serialized; + } + + /** + * + * @param node + */ + serializeDocument(node: Node) { + const output: string[] = []; + node.content.forEach(child => { + output.push(this.serializeNode(child)); + }); + return output.join(""); + } +} \ No newline at end of file From f7054c72faa3bd93cdf3f0a793929057d666e5fe Mon Sep 17 00:00:00 2001 From: DikieDick <26772815+DikieDick@users.noreply.github.com> Date: Thu, 2 Oct 2025 11:12:29 +0200 Subject: [PATCH 17/50] Move coqdoc testing to waterproof-vscode --- __tests__/parser.test.ts | 94 ---------------------------------------- 1 file changed, 94 deletions(-) delete mode 100644 __tests__/parser.test.ts diff --git a/__tests__/parser.test.ts b/__tests__/parser.test.ts deleted file mode 100644 index e47536d..0000000 --- a/__tests__/parser.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { translateCoqDoc } from "../src/translation/toProsemirror/parser"; - - -/* - This test file aims at testing the translate coqdoc functionality as defined in: - editor/src/kroqed-editor/translation/toProsemirror/parser -*/ - -test("Expect empty input to return empty output", () => { - expect(translateCoqDoc("")).toBe(""); -}); - -test("Expect comment to be pretty-printed", () => { - expect(translateCoqDoc("This is just a pretty-printed comment.")).toBe(`This is just a pretty-printed comment.`); -}); - -test("Expect whitespace to still work", () => { - expect(translateCoqDoc(" This is just a comment. ")).toBe(` This is just a comment. `); -}); - -test("Expect H1 to be replaced", () => { - expect(translateCoqDoc("* This is a header")).toBe(`# This is a header`); -}); - -test("Header with paragraph content", () => { - expect(translateCoqDoc("* Header\nParagraph")).toBe(`# Header\nParagraph`); -}); - -test("Expect H1 to be able to include asterisks", () => { - expect(translateCoqDoc("* *This* is a header")).toBe(`# *This* is a header`); -}); - -test("Expect H2 to be replaced", () => { - expect(translateCoqDoc("** This is a header")).toBe(`## This is a header`); -}); - -test("Expect H3 to be replaced", () => { - expect(translateCoqDoc("*** This is a header")).toBe(`### This is a header`); -}); - -test("Expect H4 to be replaced", () => { - expect(translateCoqDoc("**** This is a header")).toBe(`#### This is a header`); -}); - -test("Expect mixed H1 and H2 to be replaced", () => { - expect(translateCoqDoc("* This is an H1. \n** Here is an H2")).toBe(`# This is an H1. \n## Here is an H2`); -}); - -test("Expect mixed H1 and H3 to be replaced", () => { - expect(translateCoqDoc("* This is an H1. \n*** Here is an H3")).toBe(`# This is an H1. \n### Here is an H3`); -}); - -test("Expect verbatim to be replaced", () => { - // Verbatim tags need to be on their own line! - expect(translateCoqDoc("<<\nThis is verbatim\n>>")).toBe("```\nThis is verbatim\n```"); -}); - -test("Expect default pretty printing character to be replaced", () => { - expect(translateCoqDoc("Here follows a pretty-printing character: ->")).toBe(`Here follows a pretty-printing character: →`); -}); - -test("Expect default pretty printing characters to be replaced", () => { - expect(translateCoqDoc("-> <- <= >=")).toBe(`→ ← ≤ ≥`); -}); - -test("Expect quoted coq to be replaced", () => { - expect(translateCoqDoc("[let id := fun [T : Type] (x : t) => x in id 0]")).toBe("`let id := fun [T : Type] (x : t) ⇒ x in id 0`"); -}); - -test("Expect preformatted vernacular to be replaced", () => { - expect(translateCoqDoc("[[\nDefinition test := 1.\n]]")).toBe("```\nDefinition test := 1.\n```"); -}); - -test("Preserves whitespace inside coqdoc comment.", () => { - expect(translateCoqDoc("Hello whitespace\n \n")).toBe(`Hello whitespace\n \n`); -}); - -test("Preserves whitespace inside coq code cell.", () => { - expect(translateCoqDoc("This is a coq code cell\n\nHello whitespace. \n\n \n")).toBe(`This is a coq code cell\n\nHello whitespace. \n\n \n`); -}); - - - -test("From indented list in Coqdoc comments, make markdown list", () => { - - expect(translateCoqDoc("- First item\n- Second item\n - Indented item\n - Second indented item\n- Third item")) - .toBe(`- First item\n- Second item\n - Indented item\n - Second indented item\n- Third item`); -}); - -/* TEMPLATE -test("name", () => { - expect(translateCoqDoc("input")).toBe("expected output"); -}); -*/ \ No newline at end of file From 67083444e47c41e6984f42e9191dab992b077327 Mon Sep 17 00:00:00 2001 From: DikieDick <26772815+DikieDick@users.noreply.github.com> Date: Thu, 2 Oct 2025 11:14:03 +0200 Subject: [PATCH 18/50] Remove old document translator test --- __tests__/mvFileToProsemirror.test.ts | 127 -------------------------- 1 file changed, 127 deletions(-) delete mode 100644 __tests__/mvFileToProsemirror.test.ts diff --git a/__tests__/mvFileToProsemirror.test.ts b/__tests__/mvFileToProsemirror.test.ts deleted file mode 100644 index 4f638c5..0000000 --- a/__tests__/mvFileToProsemirror.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -/* eslint-disable no-useless-escape */ -// Disable due to test data including latex code -import { translateMvToProsemirror } from "../src/translation/toProsemirror/mvFileToProsemirror"; -import { expect } from "@jest/globals"; - -test("Expect empty input to return empty output", () => { - expect(translateMvToProsemirror("")).toBe(""); -}); - -test("Normal code cell", () => { - expect(translateMvToProsemirror("```coq\nThis is code\n```")).toBe(`This is code`); -}); - -test("Normal Text", () => { - expect(translateMvToProsemirror("This is not code")).toBe(`This is not code`); -}); - -test("Normal Header", () => { - expect(translateMvToProsemirror("# This is a 1 header\n")).toBe(`# This is a 1 header\n`); -}); - -test("Normal 6 Header", () => { - expect(translateMvToProsemirror("###### This is a 6 header\n")).toBe(`###### This is a 6 header\n`); -}); - -test("Text + code 1", () => { - expect(translateMvToProsemirror("This is text\n```coq\nThis is code\n```")).toBe(`This is textThis is code`); -}); - -test("Text + code 2", () => { - expect(translateMvToProsemirror("```coq\nThis is code\n```\nThis is text")).toBe(`This is codeThis is text`); -}); - -test("Header + code 1", () => { - expect(translateMvToProsemirror("```coq\nThis is code\n```\n# This is a header\n")).toBe(`This is code# This is a header\n`); -}); - -test("Header + code 2", () => { - expect(translateMvToProsemirror("# This is a header\n```coq\nThis is code\n```")).toBe(`# This is a headerThis is code`); -}); - -test("Header + text + code 1", () => { - expect(translateMvToProsemirror("# This is a header\nThis is text\n```coq\nThis is code\n```")).toBe(`# This is a header\nThis is textThis is code`); -}); - -test("Header + text + code 2", () => { - expect(translateMvToProsemirror("# This is a header\n```coq\nThis is code\n```\nThis is text")).toBe(`# This is a headerThis is codeThis is text`); -}); - -test("Header + text + code 3", () => { - expect(translateMvToProsemirror("```coq\nThis is code\n```\n# This is a header\nThis is text")).toBe(`This is code# This is a header\nThis is text`); -}); - -// test("CoqDocTest without text", () => { -// expect(translateMvToProsemirror("```coq\nThis is code (** coqDoc *)\n```")).toBe(`This is code

coqDoc

`); -// }); - -test("Header coqdoc", () => { - expect(translateMvToProsemirror("```coq\n(** * This is a header*)\n```\n# This is also a header")) - .toBe(`* This is a header# This is also a header`) -}) - -test("Double coqdoc", () => { - expect(translateMvToProsemirror("```coq\n(** * This is head*)\n(** This is not head*)\n```")) - .toBe(`* This is headThis is not head`) -}) - -test("entire document", () => { - const docString = `Hello\n\`\`\`coq\nNo edit pls.\n\`\`\`\nHELLO\n## Header\n\n\`\`\`coq\nNo edit pls.\n\`\`\`` - const predict = `HelloNo edit pls.HELLO\n## Header\nNo edit pls.` - expect(translateMvToProsemirror(docString)).toBe(predict) -}) - -test("entire document2", () => { - const docString = `# This is a header. - -This is a paragraph. Paragraphs support inline LaTeX like $5+3=22$. Underneath you'll find a math display block. -$$ -\operatorname{P} = \operatorname{NP} -$$ -Headers can be changed to paragraphs and vice versa. - -New \`inline math\` blocks can be created by pressing \`$\$$\` followed by a space and another \`$\$$\`. -New \`display math\` blocks can be created by inserting \`$\$\$$\` in the document followed by a space. - -\`\`\`coq -From Coq Require Import List. -Import ListNotations. -\`\`\` -Above **and** below are Coq cells. - -\`\`\`coq -(** * This is header*) -(** %\text{inline}%*) -(** $\text{display}$*) -Lemma rev_snoc_cons A : - forall (x : A) (l : list A), rev (l ++ [x]) = x :: rev l. -Proof. - (** Use induction on \`l.\`*) - induction l. - - reflexivity. - - simpl. rewrite IHl. simpl. reflexivity. -Qed. -\`\`\`` - const predict = `# This is a header. - -This is a paragraph. Paragraphs support inline LaTeX like $5+3=22$. Underneath you'll find a math display block. - -\operatorname{P} = \operatorname{NP} - -Headers can be changed to paragraphs and vice versa. - -New \`inline math\` blocks can be created by pressing \`$\$$\` followed by a space and another \`$\$$\`. -New \`display math\` blocks can be created by inserting \`$\$\$$\` in the document followed by a space. -From Coq Require Import List. -Import ListNotations.Above **and** below are Coq cells. -* This is header%\text{inline}%\text{display}Lemma rev_snoc_cons A : - forall (x : A) (l : list A), rev (l ++ [x]) = x :: rev l. -Proof. - (** Use induction on \`l.\`*) - induction l. - - reflexivity. - - simpl. rewrite IHl. simpl. reflexivity. -Qed.` - expect(translateMvToProsemirror(docString)).toBe(predict) - -}) From 6b57b17d58553d153b0a6ebca813489e4f5d25d9 Mon Sep 17 00:00:00 2001 From: DikieDick <26772815+DikieDick@users.noreply.github.com> Date: Thu, 2 Oct 2025 11:22:06 +0200 Subject: [PATCH 19/50] Fix typing issue in utils.test.ts and use correct math translation function in mathinline.test.ts --- __tests__/mathinline.test.ts | 11 +++------- __tests__/utils.test.ts | 40 ++++++++++++++++++------------------ 2 files changed, 23 insertions(+), 28 deletions(-) diff --git a/__tests__/mathinline.test.ts b/__tests__/mathinline.test.ts index f2b66d0..26d1b77 100644 --- a/__tests__/mathinline.test.ts +++ b/__tests__/mathinline.test.ts @@ -1,14 +1,9 @@ -import { toMathInline } from "../src/translation/toProsemirror/parser" - +import { defaultToMarkdown } from "../src/translation"; test("Replace $ inside of markdown", () => { - expect(toMathInline("markdown", "$\\text{math-inline}$")).toBe("\\text{math-inline}"); + expect(defaultToMarkdown("$\\text{math-inline}$")).toBe("\\text{math-inline}"); }); test("Replace $ inside of markdown with content", () => { - expect(toMathInline("markdown", "Content\n$\\text{math-inline}$ content in the line\nMore content")).toBe("Content\n\\text{math-inline} content in the line\nMore content"); + expect(defaultToMarkdown("Content\n$\\text{math-inline}$ content in the line\nMore content")).toBe("Content\n\\text{math-inline} content in the line\nMore content"); }); - -test("Replace % inside of coqdoc", () => { - expect(toMathInline("coqdoc", "%\\text{coqdoc in mathinline?!}%")).toBe("\\text{coqdoc in mathinline?!}"); -}); \ No newline at end of file diff --git a/__tests__/utils.test.ts b/__tests__/utils.test.ts index a3ef8b3..66c83e6 100644 --- a/__tests__/utils.test.ts +++ b/__tests__/utils.test.ts @@ -1,39 +1,41 @@ import { Block } from "../src/document/blocks"; +import { BLOCK_NAME } from "../src/document/blocks/block"; import { text } from "../src/document/blocks/schema"; import { extractInterBlockRanges, iteratePairs, maskInputAndHints, sortBlocks } from "../src/document/utils"; const toProseMirror = () => text("null"); const debugPrint = () => null; const innerRange = {from: 0, to: 0}; +const type = BLOCK_NAME.CODE; test("Sort blocks #1", () => { const stringContent = ""; - const testBlocks = [ - {type: "second", range: {from: 1, to: 2}, innerRange, stringContent, toProseMirror, debugPrint}, - {type: "first", range: {from: 0, to: 1}, innerRange, stringContent, toProseMirror, debugPrint} + const testBlocks: Array = [ + {type: BLOCK_NAME.CODE, range: {from: 1, to: 2}, innerRange, stringContent, toProseMirror, debugPrint}, + {type: BLOCK_NAME.INPUT_AREA, range: {from: 0, to: 1}, innerRange, stringContent, toProseMirror, debugPrint} ]; const sorted = sortBlocks(testBlocks); expect(sorted.length).toBe(2); - expect(sorted[0].type).toBe("first"); - expect(sorted[1].type).toBe("second"); + expect(sorted[0].type).toBe(BLOCK_NAME.INPUT_AREA); + expect(sorted[1].type).toBe(BLOCK_NAME.CODE); }); test("Sort blocks #2", () => { const stringContent = ""; - const testBlocks = [ - {type: "second", range: {from: 1, to: 2}, innerRange, stringContent, toProseMirror, debugPrint}, - {type: "first", range: {from: 0, to: 1}, innerRange, stringContent, toProseMirror, debugPrint}, - {type: "third", range: {from: 2, to: 3}, innerRange, stringContent, toProseMirror, debugPrint} + const testBlocks: Array = [ + {type: BLOCK_NAME.CODE, range: {from: 1, to: 2}, innerRange, stringContent, toProseMirror, debugPrint}, + {type: BLOCK_NAME.INPUT_AREA, range: {from: 0, to: 1}, innerRange, stringContent, toProseMirror, debugPrint}, + {type: BLOCK_NAME.HINT, range: {from: 2, to: 3}, innerRange, stringContent, toProseMirror, debugPrint} ]; const sorted = sortBlocks(testBlocks); expect(sorted.length).toBe(3); - expect(sorted[0].type).toBe("first"); - expect(sorted[1].type).toBe("second"); - expect(sorted[2].type).toBe("third"); + expect(sorted[0].type).toBe(BLOCK_NAME.INPUT_AREA); + expect(sorted[1].type).toBe(BLOCK_NAME.CODE); + expect(sorted[2].type).toBe(BLOCK_NAME.HINT); }); // TODO: What is the expected behaviour in this case? @@ -74,8 +76,8 @@ test("Iterate pairs (single element array)", () => { test("Mask input and hints #1", () => { const inputDocument = "# Example\n\n# Test input area\n\n"; - const blocks = [ - {type: "input_area", range: {from: 10, to: 54}, innerRange, stringContent: "# Test input area", toProseMirror, debugPrint} + const blocks: Array = [ + {type, range: {from: 10, to: 54}, innerRange, stringContent: "# Test input area", toProseMirror, debugPrint} ]; const maskedString = "# Example\n \n"; @@ -84,9 +86,9 @@ test("Mask input and hints #1", () => { test("Mask input and hints #2", () => { const inputDocument = `\nThis is a test hint\n<\\hint>\n# Example\n\n# Test input area\n\n`; - const blocks = [ - {type: "hint", range: {from: 0, to: 47}, innerRange, stringContent: "This is a test hint", toProseMirror, debugPrint}, - {type: "input_area", range: {from: 58, to: 102}, innerRange, stringContent: "# Test input area", toProseMirror, debugPrint} + const blocks: Array = [ + {type, range: {from: 0, to: 47}, innerRange, stringContent: "This is a test hint", toProseMirror, debugPrint}, + {type, range: {from: 58, to: 102}, innerRange, stringContent: "# Test input area", toProseMirror, debugPrint} ]; const maskedString = " \n# Example\n \n"; @@ -94,12 +96,11 @@ test("Mask input and hints #2", () => { }); test("Extract inter-block ranges", () => { - const type = "test"; const stringContent = "test"; const document = "Hello, this is a test document, I am testing this document. Test test test test." - const blocks: Block[] = [ + const blocks: Array = [ { range: { from: 0, to: 10 }, innerRange, type, stringContent, toProseMirror, debugPrint }, { range: { from: 15, to: 20 }, innerRange, type, stringContent, toProseMirror, debugPrint }, { range: { from: 25, to: 30 }, innerRange, type, stringContent, toProseMirror, debugPrint }, @@ -114,7 +115,6 @@ test("Extract inter-block ranges", () => { }); test("Extract inter-block ranges with touching blocks", () => { - const type = "test"; const stringContent = "test"; const document = "012345678901234567890123456789" From 8802eb2f9681d0116e8f8e6ba029c51be40f8bf3 Mon Sep 17 00:00:00 2001 From: DikieDick <26772815+DikieDick@users.noreply.github.com> Date: Fri, 3 Oct 2025 09:34:52 +0200 Subject: [PATCH 20/50] Fix newlines in markdown parser and preliminary test --- __tests__/markdown-parser.test.ts | 20 ++++++++++++++++++++ src/markdown-defaults/statemachine.ts | 17 ++++++++++------- 2 files changed, 30 insertions(+), 7 deletions(-) create mode 100644 __tests__/markdown-parser.test.ts diff --git a/__tests__/markdown-parser.test.ts b/__tests__/markdown-parser.test.ts new file mode 100644 index 0000000..14a2865 --- /dev/null +++ b/__tests__/markdown-parser.test.ts @@ -0,0 +1,20 @@ +import { parser } from "../src/markdown-defaults"; + +const doc = `# test +\`\`\`python +def example_function(): + return "Hello, World!" +\`\`\` +test +\`\`\`python +def example_function(): +\`\`\` + +This is a hint block with some **markdown** content. +`; + +test("test", () => { + const blocks = parser(doc, "python"); + console.log(blocks); + expect(blocks.length).toBe(9); +}); \ No newline at end of file diff --git a/src/markdown-defaults/statemachine.ts b/src/markdown-defaults/statemachine.ts index fd58b98..3836720 100644 --- a/src/markdown-defaults/statemachine.ts +++ b/src/markdown-defaults/statemachine.ts @@ -256,24 +256,27 @@ export function parser(document: string, language: string = ""): WaterproofDocum case ParserState.Code: { if (closesCodeBlock()) { // End of this code block - const range = { from: getRangeStart(), to: i + codeBlockCloseLength + codeBlockOffset }; + + // Check if we have a newline before this block + const newlineBefore = document[getRangeStart()] === '\n'; + const range = { from: getRangeStart() + (newlineBefore ? 1 : 0), to: i + codeBlockCloseLength }; const innerRange = { from: getInnerRangeStart(), to: i }; const codeBlock = new CodeBlock( document.slice(innerRange.from, innerRange.to), range, innerRange); - const newlineBefore = document[rangeStart - 1] === '\n'; - const newlineAfter = codeBlockOffset === 1 ? "\n" : ""; + // Add a newline block before the block if needed if (newlineBefore) { - pushBlock(new NewlineBlock({ from: rangeStart - 1, to: rangeStart }, { from: rangeStart - 1, to: rangeStart })); + pushBlock(new NewlineBlock({ from: getRangeStart(), to: getRangeStart() + 1 }, { from: getRangeStart(), to: getRangeStart() + 1 })); } pushBlock(codeBlock); - if (newlineAfter) { - pushBlock(new NewlineBlock({ from: range.to - 1, to: range.to }, { from: range.to - 1, to: range.to })); + // Add a newline block after the block if needed + if (codeBlockOffset) { + pushBlock(new NewlineBlock({ from: range.to, to: range.to + 1 }, { from: range.to, to: range.to + 1 })); } - i += codeBlockCloseLength + codeBlockOffset; // Skip the closing ``` + i += codeBlockCloseLength + codeBlockOffset; // Skip the closing ``` and possible \n backToMarkdown(); continue; } else { From f2b50f361874a296d880f583d8dfd5b13de0b1ce Mon Sep 17 00:00:00 2001 From: DikieDick <26772815+DikieDick@users.noreply.github.com> Date: Fri, 3 Oct 2025 09:35:26 +0200 Subject: [PATCH 21/50] Reexport slices from prosemirror --- src/api/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/index.ts b/src/api/index.ts index ebaf62f..859f64c 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -14,7 +14,7 @@ export { WaterproofCompletion, WaterproofSymbol } from "./Completions"; export { Completion } from "@codemirror/autocomplete"; export { Step, ReplaceStep, ReplaceAroundStep } from "prosemirror-transform"; -export { Fragment, Node } from "prosemirror-model"; +export { Fragment, Node, Slice } from "prosemirror-model"; export { ServerStatus, Idle, Busy } from "./ServerStatus"; From a3bad2fdaf0096e39ce13760ce28312377bfddc3 Mon Sep 17 00:00:00 2001 From: DikieDick <26772815+DikieDick@users.noreply.github.com> Date: Sat, 4 Oct 2025 13:09:01 +0200 Subject: [PATCH 22/50] update commands --- src/commands/command-helpers.ts | 2 - src/commands/commands.ts | 91 +++++++++++++++++++++++++-------- src/menubar/menubar.ts | 12 +++-- 3 files changed, 78 insertions(+), 27 deletions(-) diff --git a/src/commands/command-helpers.ts b/src/commands/command-helpers.ts index ea084b4..64d0509 100644 --- a/src/commands/command-helpers.ts +++ b/src/commands/command-helpers.ts @@ -13,7 +13,6 @@ import { getSurroundingNodes } from "./utils"; * Helper function for inserting a new node above the currently selected one. * @param state The current editor state. * @param tr The current transaction for the state of the editor. - * @param escapeContainingNode Whether to escape the containing node. * @param nodeType ? * @returns An insertion transaction. */ @@ -88,7 +87,6 @@ export function insertAbove(state: EditorState, tr: Transaction, nodeType: NodeT * Helper function for inserting a new node below the currently selected one. * @param state The current editor state. * @param tr The current transaction for the state of the editor. - * @param escapeContainingNode Whether to escape the containing node. * @param nodeType ? * @returns An insertion transaction. */ diff --git a/src/commands/commands.ts b/src/commands/commands.ts index 559cfca..3b9eee0 100644 --- a/src/commands/commands.ts +++ b/src/commands/commands.ts @@ -1,4 +1,4 @@ -import { NodeRange } from "prosemirror-model"; +import { NodeRange, NodeType } from "prosemirror-model"; import { Command, EditorState, NodeSelection, TextSelection, Transaction } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; import { liftTarget } from "prosemirror-transform"; @@ -56,32 +56,32 @@ export function deleteSelection(tagConf: TagConfiguration): Command { const afterIsNewline = after !== null ? after.type === WaterproofSchema.nodes.newline : false; if (beforeIsNewline && afterIsNewline && befoore !== null && afteer !== null && needsNewlineAfter(befoore.type, tagConf) && needsNewlineBefore(afteer.type, tagConf)) { - console.log("Before and after are newlines, and befoore needs newline after and afteer needs newline before"); + // console.log("Before and after are newlines, and befoore needs newline after and afteer needs newline before"); // Before and after are newlines, and befoore needs newline after and afteer needs newline before // We need to keep one of the newlines, so we delete the node and the after newline if (dispatch) dispatch(state.tr.delete(state.selection.from, state.selection.to + afterSize).scrollIntoView()); return true; } else if (afterIsNewline && afteer !== null && needsNewlineBefore(state.selection.node.type, tagConf)) { - console.log("After is newline and afteer needs newline before"); + // console.log("After is newline and afteer needs newline before"); // After is newline and afteer needs newline before // We need to keep the after newline, so we delete the node and the before newline if (dispatch) dispatch(state.tr.delete(state.selection.from - beforeSize, state.selection.to).scrollIntoView()); return true; } else if (beforeIsNewline && befoore !== null && needsNewlineAfter(befoore.type, tagConf)) { - console.log("Before is newline and befoore needs newline after"); + // console.log("Before is newline and befoore needs newline after"); // Before is newline and befoore needs newline after // We need to keep the before newline, so we delete the node and the after newline if (dispatch) dispatch(state.tr.delete(state.selection.from, state.selection.to + afterSize).scrollIntoView()); return true; } else if (beforeIsNewline && afterIsNewline && (befoore === null || (befoore !== null && !needsNewlineAfter(befoore.type, tagConf))) && (afteer === null || (afteer !== null && !needsNewlineBefore(afteer.type, tagConf)))) { - console.log("Before and after are newlines, but befoore does not need newline after and afteer does not need newline before"); + // console.log("Before and after are newlines, but befoore does not need newline after and afteer does not need newline before"); // Before and after are newlines, but befoore does not need newline after and afteer does not need newline before // We can delete both newlines if (dispatch) dispatch(state.tr.delete(state.selection.from - beforeSize, state.selection.to + afterSize).scrollIntoView()); return true; } else { - console.log("Deleting node selection"); + // console.log("Deleting node selection"); if (dispatch) dispatch(state.tr.deleteSelection().scrollIntoView()); return true; } @@ -102,27 +102,76 @@ export function deleteSelection(tagConf: TagConfiguration): Command { } } +export function wrapInHint(tagConf: TagConfiguration): Command { + return wpWrapIn(WaterproofSchema.nodes.hint, tagConf); +} + export function wrapInInput(tagConf: TagConfiguration): Command { + return wpWrapIn(WaterproofSchema.nodes.input, tagConf); +} + +function wpWrapIn(nodeType: NodeType, tagConf: TagConfiguration): Command { return (state, dispatch) => { const sel = state.selection; - // We need to possible extend this blockRange - // sel.$from.blockRange(sel.$to); - - - + if (!(sel instanceof NodeSelection)) return false; + const before = sel.$from.nodeBefore; + const after = sel.$to.nodeAfter; - - const beforeIsNewline = before !== null ? before.type === WaterproofSchema.nodes.newline : false; - const afterIsNewline = after !== null ? after.type === WaterproofSchema.nodes.newline : false; - - - const nodeBeingWrapped = state.doc.nodeAt(sel.from); - // const nodeAtEnd = state.doc.nodeAt(sel.to - 1); - + if (dispatch) { + const beforeIsNewline = before !== null ? before.type === WaterproofSchema.nodes.newline : false; + const afterIsNewline = after !== null ? after.type === WaterproofSchema.nodes.newline : false; + const nodeBeingWrapped = sel.node; + 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); + if (before !== null && consumeBefore) { + // extend the selection to incldue the before newline node + $start = state.doc.resolve(sel.from - before.nodeSize); + } + if (after !== null && consumeAfter) { + // extend the selection to include the after newline node + $end = state.doc.resolve(sel.to + after.nodeSize); + } + + // We extend the blockRange to include the newlines if they are being consumed. + const blockRange = $start.blockRange($end); + if (blockRange === null) return false; + const tr = state.tr; + tr.wrap(blockRange, [{type: nodeType}]); + console.log(blockRange.startIndex, blockRange.endIndex); + + // 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($start.pos) - 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($end.pos), WaterproofSchema.nodes.newline.create()); + } + } - // sel.$from.block - return false; + // Finally, dispatch the transaction and set the selection to be the node selection of the newly created input area. + tr.setSelection(NodeSelection.create(tr.doc, tr.mapping.map(sel.from))); + dispatch(tr.scrollIntoView()); + return true; + } + return true; } } \ No newline at end of file diff --git a/src/menubar/menubar.ts b/src/menubar/menubar.ts index 0a30274..bae2a20 100644 --- a/src/menubar/menubar.ts +++ b/src/menubar/menubar.ts @@ -1,4 +1,4 @@ -import { selectParentNode, wrapIn } from "prosemirror-commands"; +import { autoJoin, joinDown, joinForward, selectParentNode, wrapIn } from "prosemirror-commands"; import { Command, PluginView, Plugin, PluginKey } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; import { INPUT_AREA_PLUGIN_KEY } from "../inputArea"; @@ -7,6 +7,7 @@ import { OS } from "../osType"; import { WaterproofSchema } from "../schema"; import { getCmdInsertCode, getCmdInsertLatex, getCmdInsertMarkdown } from "../commands/insert-command"; import { TagConfiguration } from "../api"; +import { deleteSelection, wrapInHint, wrapInInput } from "../commands/commands"; /** MenuEntry type contains the DOM, whether to only show it in teacher mode and the command to execute on click */ type MenuEntry = { @@ -176,9 +177,12 @@ function createDefaultMenu(outerView: EditorView, os: OS, tagConf: TagConfigurat // 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(wrapIn(WaterproofSchema.nodes.input)), teacherOnly), - createMenuItem("?", "Make selection a hint element", teacherOnlyWrapper(wrapIn(WaterproofSchema.nodes.hint)), teacherOnly), - createMenuItem("↑", "Lift selected node (Reverts the effect of making a 'hint' or 'input area')", teacherOnlyWrapper(liftWrapper), teacherOnly) + 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(liftWrapper), teacherOnly), + createMenuItem("🗑️", "Delete selection", teacherOnlyWrapper(deleteSelection(tagConf)), teacherOnly), + // createMenuItem("", "Join forward", joinForward), + // createMenuItem("", "Join down", joinDown) ] // If the DEBUG variable is set to `true` then we display a `dump` menu item, which outputs the current From e746ce0030eb086f4465e4db5d8c72a0a87b8fbc Mon Sep 17 00:00:00 2001 From: DikieDick <26772815+DikieDick@users.noreply.github.com> Date: Mon, 6 Oct 2025 09:19:58 +0200 Subject: [PATCH 23/50] Set 'group' property, do not allow empty input/hint --- src/schema/schema.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/schema/schema.ts b/src/schema/schema.ts index 1eebc82..408bf76 100644 --- a/src/schema/schema.ts +++ b/src/schema/schema.ts @@ -12,10 +12,6 @@ export const SchemaCell = { export type SchemaKeys = keyof typeof SchemaCell; export type SchemaNames = typeof SchemaCell[SchemaKeys]; -const cell = `(markdown | hint | code | input | math_display | newline)`; -const containercontent = "(markdown | code | math_display | newline)"; -// const groupMarkdown = "markdowncontent"; - /** * General schema obtained from `prosemirror-markdown`: * https://github.com/ProseMirror/prosemirror-markdown/blob/master/src/schema.ts @@ -31,7 +27,7 @@ const containercontent = "(markdown | code | math_display | newline)"; export const WaterproofSchema = new Schema({ nodes: { doc: { - content: `${cell}*` + content: "cell+" }, text: { @@ -43,6 +39,7 @@ export const WaterproofSchema = new Schema({ markdown: { block: true, content: "text*", + group: "cell containercontent", parseDOM: [{tag: "markdown", preserveWhitespace: "full"}], atom: true, toDOM: () => { @@ -54,7 +51,8 @@ export const WaterproofSchema = new Schema({ /////// HINT ////// //#region Hint hint: { - content: `${containercontent}*`, + content: "containercontent+", + group: "cell", attrs: { title: {default: "💡 Hint"}, shown: {default: false} @@ -68,7 +66,8 @@ export const WaterproofSchema = new Schema({ /////// Input Area ////// //#region input input: { - content: `${containercontent}*`, + content: "containercontent+", + group: "cell", attrs: { status: {default: null} }, @@ -82,6 +81,7 @@ export const WaterproofSchema = new Schema({ //#region Code code: { content: "text*",// content is of type text + group: "cell containercontent", code: true, atom: true, // doesn't have directly editable content (content is edited through codemirror) toDOM(node) { return ["WaterproofCode", node.attrs, 0] } // cells @@ -92,7 +92,7 @@ export const WaterproofSchema = new Schema({ /////// MATH DISPLAY ////// //#region math-display math_display: { - group: "math", + group: "math cell containercontent", content: "text*", atom: true, code: true, @@ -101,9 +101,9 @@ export const WaterproofSchema = new Schema({ //#endregion newline: { + group: "cell containercontent", toDOM(node) { return ["WaterproofNewline", node.attrs]}, selectable: false, - atom: true, } } }); \ No newline at end of file From 3e6cd368d7c71a86c0a593d28b5c7854f54751e9 Mon Sep 17 00:00:00 2001 From: DikieDick <26772815+DikieDick@users.noreply.github.com> Date: Mon, 6 Oct 2025 09:23:26 +0200 Subject: [PATCH 24/50] Let esbuild set 'DEBUG' variable --- .vscode/tasks.json | 27 ++++++++++++++++++++++++++- esbuild.mjs | 4 ++++ package.json | 1 + src/menubar/menubar.ts | 6 +++--- 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/.vscode/tasks.json b/.vscode/tasks.json index bdd70c8..2a5b22b 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -10,13 +10,27 @@ "npm: watch:esbuild" ], "presentation": { - "reveal": "never" + "reveal": "silent" }, "group": { "kind": "build", "isDefault": true } }, + { + "label": "watch:debug", + "dependsOn": [ + "npm: watch:esbuild-debug", + "npm: watch:tsc" + ], + "presentation": { + "reveal": "silent" + }, + "group": { + "kind":"build", + "isDefault": false + } + }, { "type": "npm", "script": "watch:esbuild", @@ -27,6 +41,17 @@ "group": "watch", "reveal": "always" } + }, + { + "type": "npm", + "script": "watch:esbuild-debug", + "group": "build", + "isBackground": true, + "label": "npm: watch:esbuild-debug", + "presentation": { + "group": "watch", + "reveal": "always" + } }, { "type": "npm", diff --git a/esbuild.mjs b/esbuild.mjs index 20e8847..935a43b 100644 --- a/esbuild.mjs +++ b/esbuild.mjs @@ -6,6 +6,7 @@ const watch = process.argv.includes("--watch"); const minify = process.argv.includes("--minify"); const disableSourcemap = process.argv.includes("--sourcemap=no"); const genSourcemap = disableSourcemap ? null : { sourcemap: "inline" }; +const debugBuild = process.argv.includes("--debug"); // Setting to `copy` means we bundle the fonts in dist. Setting this to `dataurl` includes the fonts as base64 encoded data in the generated css file. const fontLoader = "base64"; @@ -22,6 +23,9 @@ const sharedConfig = { ".ttf": fontLoader, ".grammar": "file" }, + define: { + "DEBUG": debugBuild ? "true" : "false" + }, minify, plugins: [ { diff --git a/package.json b/package.json index dc27cc0..1fa1ba9 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ }, "scripts": { "watch:esbuild": "node esbuild.mjs --watch", + "watch:esbuild-debug": "node esbuild.mjs --watch --debug", "watch:tsc": "npx tsc -b --watch", "test": "npm run unit-tests", "unit-tests": "npx jest", diff --git a/src/menubar/menubar.ts b/src/menubar/menubar.ts index bae2a20..3b150e8 100644 --- a/src/menubar/menubar.ts +++ b/src/menubar/menubar.ts @@ -132,8 +132,8 @@ const LaTeX_SVG = ` ` -/** If set to `true`, the menubar will display debug buttons.*/ -const DEBUG = false; +//@ts-expect-error Defined by esbuild +const debugMode = DEBUG; /** * Only execute the command when in teacher mode @@ -187,7 +187,7 @@ function createDefaultMenu(outerView: EditorView, os: OS, tagConf: TagConfigurat // If the DEBUG variable is set to `true` then we display a `dump` menu item, which outputs the current // document in the console as a JSON object. - if (DEBUG) { + if (debugMode) { items.push(createMenuItem("DUMP DOC", "", (state, dispatch) => { if (dispatch) console.log("\x1b[33m[DEBUG]\x1b[0m dumped doc", JSON.stringify(state.doc.toJSON())); return true; From d4f20bfeed5d693cd6112595fd0c1b06deb7b3d3 Mon Sep 17 00:00:00 2001 From: DikieDick <26772815+DikieDick@users.noreply.github.com> Date: Mon, 6 Oct 2025 09:26:42 +0200 Subject: [PATCH 25/50] Update commands --- documentation/UsingWaterproofEditor.md | 7 +- src/commands/command-helpers.ts | 71 +--------------- src/commands/commands.ts | 113 +++++++++++++++---------- src/commands/index.ts | 2 +- src/menubar/menubar.ts | 10 +-- src/schema/notes.md | 23 ----- 6 files changed, 81 insertions(+), 145 deletions(-) delete mode 100644 src/schema/notes.md diff --git a/documentation/UsingWaterproofEditor.md b/documentation/UsingWaterproofEditor.md index 40b8b94..0dfcbdc 100644 --- a/documentation/UsingWaterproofEditor.md +++ b/documentation/UsingWaterproofEditor.md @@ -17,9 +17,9 @@ It may be helpful to think of a WaterproofDocument in terms of the following "gr ``` WaterproofDocument ::= Block+ -Block ::= HintBlock | InputAreaBlock | MarkdownBlock | CoqBlock | MathDisplayBlock +Block ::= HintBlock | InputAreaBlock | MarkdownBlock | CoqBlock | MathDisplayBlock | NewlineBlock -InnerBlock ::= MarkdownBlock | CoqBlock | MathDisplayBlock +InnerBlock ::= MarkdownBlock | CoqBlock | MathDisplayBlock | NewlineBlock HintBlock ::= Container of InnerBlock+ with a title. InputAreaBlock ::= Container of InnerBlock+ @@ -27,8 +27,11 @@ InputAreaBlock ::= Container of InnerBlock+ MarkdownBlock ::= A container with markdown content (supports inline LaTeX). CoqBlock ::= A container with code content. MathDisplayBlock ::= A container with LaTeX content that should be rendered in math display mode. +NewlineBlock ::= A block that keeps track of signifcant newlines ``` +The schema `WaterproofSchema` defined in [`src/schema/schema.ts`](../src/schema/schema.ts) follows from the above grammar. + ### WaterproofMapping The `WaterproofMapping` that is constructed is responsible for translating [ProseMirror positions](https://prosemirror.net/docs/guide/#doc.indexing) into an offset position into the document string. diff --git a/src/commands/command-helpers.ts b/src/commands/command-helpers.ts index 64d0509..ca3f665 100644 --- a/src/commands/command-helpers.ts +++ b/src/commands/command-helpers.ts @@ -16,15 +16,12 @@ import { getSurroundingNodes } from "./utils"; * @param nodeType ? * @returns An insertion transaction. */ -export function insertAbove(state: EditorState, tr: Transaction, nodeType: NodeType, insertNewlineBeforeIfNotExists: boolean, insertNewlineAfterIfNotExists: boolean): Transaction | undefined { - // console.log("INSERTING ABOVE"); - +export function insertAbove(state: EditorState, tr: Transaction, nodeType: NodeType, insertNewlineBeforeIfNotExists: boolean, insertNewlineAfterIfNotExists: boolean): Transaction | undefined { const sel = state.selection; let trans: Transaction = tr; const {before} = getSurroundingNodes(sel.$from); const beforeIsNewline = before !== null ? (before.type === WaterproofSchema.nodes.newline) : false; - // console.log("Before", before?.type.name); let pos; @@ -45,14 +42,11 @@ export function insertAbove(state: EditorState, tr: Transaction, nodeType: NodeT pos -= 1; // We are going to insert befofre } - // console.log("Node at", state.doc.nodeAt(pos)); - const newBefore = getSurroundingNodes(state.doc.resolve(pos)).before; - // console.log("newbefore", newBefore); const toInsert: PNode[] = []; - if (insertNewlineBeforeIfNotExists && newBefore?.type !== WaterproofSchema.nodes.newline) { + if (insertNewlineBeforeIfNotExists && newBefore !== null && newBefore.type !== WaterproofSchema.nodes.newline) { toInsert.push(newline()); } toInsert.push(nodeType.create()); @@ -62,24 +56,6 @@ export function insertAbove(state: EditorState, tr: Transaction, nodeType: NodeT trans = trans.insert(pos, toInsert); - // if (insertNewlineBeforeIfNotExists && newBefore?.type !== WaterproofSchema.nodes.newline) { - // const node = newline(); - // trans = trans.insert(pos, node); - // console.log("inserting newline before"); - // // pos += 1; - // } - // const mainNode = nodeType.create(); - // trans = trans.insert(pos, mainNode); - // // pos += 1; - // if (insertNewlineAfterIfNotExists && !beforeIsNewline) { - // const node = newline(); - // trans = trans.insert(pos, node); - // console.log("inserting newline after"); - // // pos += 1; - // } - - // console.log(trans); - return trans; } @@ -91,8 +67,6 @@ export function insertAbove(state: EditorState, tr: Transaction, nodeType: NodeT * @returns An insertion transaction. */ export function insertBelow(state: EditorState, tr: Transaction, nodeType: NodeType, insertNewlineBeforeIfNotExists: boolean, insertNewlineAfterIfNotExists: boolean): Transaction | undefined { - // console.log("INSERTING BELOW"); - const sel = state.selection; let trans: Transaction = tr; @@ -124,7 +98,7 @@ export function insertBelow(state: EditorState, tr: Transaction, nodeType: NodeT toInsert.push(newline()); } toInsert.push(nodeType.create()); - if (insertNewlineAfterIfNotExists && newAfter?.type !== WaterproofSchema.nodes.newline) { + if (insertNewlineAfterIfNotExists && newAfter !== null && newAfter.type !== WaterproofSchema.nodes.newline) { toInsert.push(newline()); } @@ -143,45 +117,6 @@ export function nodeFromSel(sel: Selection): PNode | undefined { } } - -// function getSurroundingNodes(sel: Selection): {before: PNode | null; after: PNode | null} { -// // console.log(sel); -// const depth = sel.$from.depth; -// // console.log(depth); - -// let parent; -// let index; -// if (depth === 0) { -// parent = sel.$from.parent; -// index = sel.$from.index(0); -// } else { -// parent = sel.$from.node(1); -// index = sel.$from.index(1); -// } -// // console.log(parent); - -// // const parent = (thingie !== undefined ? thingie : sel.$from.parent); -// // const index = sel.$from.index(1); - -// // console.log(index); - -// const before = index > 0 ? parent.child(index - 1) : null; -// const after = index < parent.childCount - 1 ? parent.child(index + 1) : null; -// return {before, after}; -// // if (sel instanceof TextSelection) { -// // const parent = sel.$from.node(1); -// // const index = sel.$from.index(1); -// // const before = index > 0 ? parent.child(index - 1) : null; -// // const after = index < parent.childCount - 1 ? parent.child(index + 1) : null; -// // return {before, after}; -// // } else if (sel instanceof NodeSelection) { -// // const parent = sel.$from.parent; -// // const index = sel.$from.index(1); -// // const before = -// // } -// // return {before: null, after: null}; -// } - /** * Returns the containing node for the current selection. * @param sel The user's selection. diff --git a/src/commands/commands.ts b/src/commands/commands.ts index 3b9eee0..876abad 100644 --- a/src/commands/commands.ts +++ b/src/commands/commands.ts @@ -1,4 +1,4 @@ -import { NodeRange, NodeType } from "prosemirror-model"; +import { NodeType } from "prosemirror-model"; import { Command, EditorState, NodeSelection, TextSelection, Transaction } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; import { liftTarget } from "prosemirror-transform"; @@ -6,27 +6,75 @@ import { WaterproofSchema } from "../schema"; import { getParentAndIndex, needsNewlineAfter, needsNewlineBefore } from "./utils"; import { TagConfiguration } from "../api"; -export const liftWrapper: Command = (state: EditorState, dispatch?: ((tr: Transaction) => void), _view?: EditorView): boolean => { - const sel = state.selection; +export function wpLift(_tagConf: TagConfiguration): Command { + return (state: EditorState, dispatch?: ((tr: Transaction) => void), _view?: EditorView): boolean => { + const sel = state.selection; + + if (!(sel instanceof NodeSelection)) return false; + + const { $from, $to, node, from, to } = sel; + const before = $from.nodeBefore; + 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. + return false; + } - if (sel instanceof NodeSelection) { - const {type} = sel.node; - if (type === WaterproofSchema.nodes.hint || type === WaterproofSchema.nodes.input) { - // Hardcoded +1 and -1 are here to move the selection into the input/hint. - // The hardcoded depth 1 is the depth of a hint or an input area node type. - const range = new NodeRange(state.doc.resolve(sel.from + 1), state.doc.resolve(sel.to - 1), 1); + // The schema enforces that the input/hint contains at least one child. + // Retrieve the first and last child (may be the same) + const { firstChild, lastChild, childCount } = node; + if (!firstChild || !lastChild) return false; + const firstIsNewline = firstChild.type === WaterproofSchema.nodes.newline; + const lastIsNewline = lastChild.type === WaterproofSchema.nodes.newline; - const target = liftTarget(range); - if (target === null) return false; - if (dispatch) dispatch(state.tr.lift(range, target).scrollIntoView()); + const beforeIsNewline = before !== null ? before.type === WaterproofSchema.nodes.newline : false; + const afterIsNewline = after !== null ? after.type === WaterproofSchema.nodes.newline : false; + // 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); - return true; + // 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; + + // Compute the lifting depth given the range covering the content of the hint/input + const target = liftTarget(range); + if (target === null) return false; + + if (dispatch) { + const tr = state.tr; + tr.lift(range, target).scrollIntoView(); + if (shouldRemoveNewlineBefore) { + tr.delete(tr.mapping.map(from), tr.mapping.map(from) + 1); + } + if (shouldRemoveNewlineAfter) { + tr.delete(tr.mapping.map(to), tr.mapping.map(to) + 1); + } + // Dispatch the transaction + dispatch(tr); } - } - return false; + return true; + } } export function deleteSelection(tagConf: TagConfiguration): Command { @@ -36,9 +84,7 @@ export function deleteSelection(tagConf: TagConfiguration): Command { if (dispatch) dispatch(state.tr.deleteSelection().scrollIntoView()); return true; } else if (state.selection instanceof NodeSelection) { - // console.log("Deleting node selection"); const {parent, index} = getParentAndIndex(state.selection.$from); - // console.log("Parent and index:", parent, index); const before = parent.maybeChild(index - 1); const after = parent.maybeChild(index + 1); @@ -48,55 +94,34 @@ export function deleteSelection(tagConf: TagConfiguration): Command { const befoore = parent.maybeChild(index - 2); // node after after const afteer = parent.maybeChild(index + 2); - // console.log("Before and after:", before, after); - // console.log("Befoore and afteer:", befoore, afteer); - // console.log("Before using nodeBefore and nodeAfter:", state.selection.$from.nodeBefore, state.selection.$to.nodeAfter); - + const beforeIsNewline = before !== null ? before.type === WaterproofSchema.nodes.newline : false; const afterIsNewline = after !== null ? after.type === WaterproofSchema.nodes.newline : false; if (beforeIsNewline && afterIsNewline && befoore !== null && afteer !== null && needsNewlineAfter(befoore.type, tagConf) && needsNewlineBefore(afteer.type, tagConf)) { - // console.log("Before and after are newlines, and befoore needs newline after and afteer needs newline before"); // Before and after are newlines, and befoore needs newline after and afteer needs newline before // We need to keep one of the newlines, so we delete the node and the after newline if (dispatch) dispatch(state.tr.delete(state.selection.from, state.selection.to + afterSize).scrollIntoView()); - return true; } else if (afterIsNewline && afteer !== null && needsNewlineBefore(state.selection.node.type, tagConf)) { - // console.log("After is newline and afteer needs newline before"); // After is newline and afteer needs newline before // We need to keep the after newline, so we delete the node and the before newline if (dispatch) dispatch(state.tr.delete(state.selection.from - beforeSize, state.selection.to).scrollIntoView()); return true; } else if (beforeIsNewline && befoore !== null && needsNewlineAfter(befoore.type, tagConf)) { - // console.log("Before is newline and befoore needs newline after"); // Before is newline and befoore needs newline after // We need to keep the before newline, so we delete the node and the after newline if (dispatch) dispatch(state.tr.delete(state.selection.from, state.selection.to + afterSize).scrollIntoView()); return true; } else if (beforeIsNewline && afterIsNewline && (befoore === null || (befoore !== null && !needsNewlineAfter(befoore.type, tagConf))) && (afteer === null || (afteer !== null && !needsNewlineBefore(afteer.type, tagConf)))) { - // console.log("Before and after are newlines, but befoore does not need newline after and afteer does not need newline before"); // Before and after are newlines, but befoore does not need newline after and afteer does not need newline before // We can delete both newlines if (dispatch) dispatch(state.tr.delete(state.selection.from - beforeSize, state.selection.to + afterSize).scrollIntoView()); return true; } else { - // console.log("Deleting node selection"); if (dispatch) dispatch(state.tr.deleteSelection().scrollIntoView()); return true; } - // const before = index > 0 ? parent.child(index - 1) : null; - // const after = index < parent.childCount - 1 ? parent.child(index + 1) : null; - - - // const befoore = index > 1 ? parent.child(index - 2) : null; - // const afteer = index < parent.childCount - 2 ? parent.child(index + 2) : null; - - - // if (before && before.type == WaterproofSchema.nodes.newline) { - // // We have a newline before - // const befooreNeedsNewline = befoore !== null ? needsNewlineAfter(befoore.type, tagConf) : false; - // } } return false; } @@ -116,7 +141,6 @@ function wpWrapIn(nodeType: NodeType, tagConf: TagConfiguration): Command { if (!(sel instanceof NodeSelection)) return false; const before = sel.$from.nodeBefore; - const after = sel.$to.nodeAfter; if (dispatch) { @@ -149,27 +173,28 @@ function wpWrapIn(nodeType: NodeType, tagConf: TagConfiguration): Command { if (blockRange === null) return false; const tr = state.tr; tr.wrap(blockRange, [{type: nodeType}]); - console.log(blockRange.startIndex, blockRange.endIndex); // 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($start.pos) - 1, WaterproofSchema.nodes.newline.create()); + 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($end.pos), WaterproofSchema.nodes.newline.create()); + 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. tr.setSelection(NodeSelection.create(tr.doc, tr.mapping.map(sel.from))); - dispatch(tr.scrollIntoView()); + tr.scrollIntoView(); + dispatch(tr); return true; } return true; diff --git a/src/commands/index.ts b/src/commands/index.ts index 33bdee4..f44e08b 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,5 +1,5 @@ // Export `deleteNodeIfEmpty` command. export { deleteNodeIfEmpty } from "./delete-command"; // Export all insertion commands for use in the menubar or with keybindings. -export { liftWrapper } from "./commands"; +export { wpLift, wrapInHint, wrapInInput, deleteSelection } from "./commands"; export { InsertionPlace } from "./types"; \ No newline at end of file diff --git a/src/menubar/menubar.ts b/src/menubar/menubar.ts index 3b150e8..8e4ac14 100644 --- a/src/menubar/menubar.ts +++ b/src/menubar/menubar.ts @@ -1,13 +1,11 @@ -import { autoJoin, joinDown, joinForward, selectParentNode, wrapIn } from "prosemirror-commands"; +import { selectParentNode } from "prosemirror-commands"; import { Command, PluginView, Plugin, PluginKey } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; import { INPUT_AREA_PLUGIN_KEY } from "../inputArea"; -import { InsertionPlace, liftWrapper } from "../commands"; +import { InsertionPlace, wrapInHint, wrapInInput, deleteSelection, wpLift } from "../commands"; import { OS } from "../osType"; -import { WaterproofSchema } from "../schema"; import { getCmdInsertCode, getCmdInsertLatex, getCmdInsertMarkdown } from "../commands/insert-command"; import { TagConfiguration } from "../api"; -import { deleteSelection, wrapInHint, wrapInInput } from "../commands/commands"; /** MenuEntry type contains the DOM, whether to only show it in teacher mode and the command to execute on click */ type MenuEntry = { @@ -179,10 +177,8 @@ function createDefaultMenu(outerView: EditorView, os: OS, tagConf: TagConfigurat // 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(liftWrapper), 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("", "Join forward", joinForward), - // createMenuItem("", "Join down", joinDown) ] // If the DEBUG variable is set to `true` then we display a `dump` menu item, which outputs the current diff --git a/src/schema/notes.md b/src/schema/notes.md deleted file mode 100644 index a50f4f6..0000000 --- a/src/schema/notes.md +++ /dev/null @@ -1,23 +0,0 @@ -# Waterproof Documents - -A WaterproofEditor document (`WaterproofDoc`) looks as follows: - -``` -WaterproofDoc = ( InputArea - | Hint - | Markdown - | MathDisplay - | Code )* - -InputArea = Containerizable* -Hint = Containerizable* - -Containerizable = Markdown | MathDisplay | Code - -Markdown = text* -MathDisplay = text* -Code = text* -``` - -# ProseMirror -The schema `WaterproofSchema` defined in [`./schema.ts`](./schema.ts) follows from the above grammar. From 6b6e31cdb4809aaf1108b2fed5cacc28b07ee1e2 Mon Sep 17 00:00:00 2001 From: DikieDick <26772815+DikieDick@users.noreply.github.com> Date: Mon, 6 Oct 2025 10:25:18 +0200 Subject: [PATCH 26/50] Remove skull --- src/commands/command-helpers.ts | 2 -- src/editor.ts | 12 +--------- src/inputArea.ts | 41 ++++++--------------------------- src/schema/schema.ts | 2 -- 4 files changed, 8 insertions(+), 49 deletions(-) diff --git a/src/commands/command-helpers.ts b/src/commands/command-helpers.ts index ca3f665..41ee3b7 100644 --- a/src/commands/command-helpers.ts +++ b/src/commands/command-helpers.ts @@ -136,8 +136,6 @@ export function allowedToInsert(state: EditorState): boolean { const pluginState = INPUT_AREA_PLUGIN_KEY.getState(state); if (!pluginState) return false; const isTeacher = pluginState.teacher; - // If in global locking mode, disallow everything - if (pluginState.globalLock) return false; // If the user is in teacher mode always return `true`, if not // we check wether they are in a input area. return isTeacher ? true : checkInputArea(state.selection); diff --git a/src/editor.ts b/src/editor.ts index e53c87b..2199ff7 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -168,13 +168,6 @@ export class WaterproofEditor { // Send message to VSCode that an error has occured this._editorConfig.api.applyStepError(err.message); - // Set global locking mode - // const tr = view.state.tr; - // tr.setMeta(INPUT_AREA_PLUGIN_KEY,"ErrorMode"); - // tr.setSelection(new AllSelection(view.state.doc)); - // view.updateState(view.state.apply(tr)); - - // We ensure this transaction is not applied return; } @@ -525,10 +518,7 @@ export class WaterproofEditor { // Early return if the plugin state is undefined. if (inputAreaPluginState === undefined) return false; - const { teacher, globalLock } = inputAreaPluginState; - // Early return if we are in the global locked mode - // (nothing should be editable anymore) - if (globalLock) return false; + const { teacher } = inputAreaPluginState; // If we are in teacher mode (ie. not locked) than // we are always able to insert. diff --git a/src/inputArea.ts b/src/inputArea.ts index 0adb6d1..b2c249b 100644 --- a/src/inputArea.ts +++ b/src/inputArea.ts @@ -1,15 +1,13 @@ -import { EditorState, Plugin, PluginKey, PluginSpec, Transaction } from -"prosemirror-state"; +import { EditorState, Plugin, PluginKey, PluginSpec, Transaction } from "prosemirror-state"; import { WaterproofSchema } from "./schema"; /** * Interface describing the state of the input are plugin. - * Contains fields `locked: boolean` indicating wether non-input areas should be locked (ie. non-teacher mode) and - * `globalLock: boolean` indicating that we are in a global lockdown state (caused by an unrecoverable error). + * Contains field `teacher: boolean` indicating wether we are in teacher mode + * (in teacher mode content outside of input areas should be editable) */ export interface IInputAreaPluginState { teacher: boolean; - globalLock: boolean; } /** The plugin key for the input area plugin */ @@ -21,42 +19,21 @@ const InputAreaPluginSpec : PluginSpec = { key: INPUT_AREA_PLUGIN_KEY, state: { init(_config, _instance){ - // Initially set the locked state to true and globalLock to false. + // Initially set the mode to be student (content outside of input areas is locked) return { - teacher: false, - globalLock: false, + teacher: false }; }, apply(tr : Transaction, value: IInputAreaPluginState, _oldState: EditorState, _newState: EditorState ){ // produce updated state field for this plugin - const meta = tr.getMeta(INPUT_AREA_PLUGIN_KEY); - if (meta === undefined) { + if (meta === undefined || meta.teacher === undefined) { return value; } else { - let newGlobalLock = value.globalLock; - let newTeacher = value.teacher; - if (meta == "ErrorMode") { - // We are in a global locked state if we receive this meta. - newTeacher = value.teacher; - newGlobalLock = true; - - // If we are in lockdown then we remove the editor and show an error message. - const node = document.querySelector("#editor"); - if (!node) throw new Error("Node cannot be undefined here"); - node.innerHTML = ""; - const container = document.createElement("div"); - container.classList.add("frozen-thingie"); - container.innerHTML = `
💀
DOCUMENT FROZEN!
Reopen file...
`; - node.appendChild(container); - } else { - newTeacher = meta.teacher ?? false; - } return { - teacher: newTeacher, - globalLock: newGlobalLock, + teacher: meta.teacher }; } } @@ -65,10 +42,6 @@ const InputAreaPluginSpec : PluginSpec = { editable: (state) => { // Get locked and globalLock states from the plugin. const teacher = INPUT_AREA_PLUGIN_KEY.getState(state)?.teacher ?? false; - const globalLock = INPUT_AREA_PLUGIN_KEY.getState(state)?.globalLock ?? false; - - // In the `globalLock` state nothing is editable anymore. - if (globalLock) return false; // In teacher mode, everything is editable by default. if (teacher) return true; diff --git a/src/schema/schema.ts b/src/schema/schema.ts index 408bf76..eb1fe22 100644 --- a/src/schema/schema.ts +++ b/src/schema/schema.ts @@ -21,8 +21,6 @@ export type SchemaNames = typeof SchemaCell[SchemaKeys]; * * math blocks obtained from `prosemirror-math`: * https://github.com/benrbray/prosemirror-math/blob/master/src/math-schema.ts - * - * see [notes](./notes.md) */ export const WaterproofSchema = new Schema({ nodes: { From 8ca5761839283501ce988ac3721472afd20c53f7 Mon Sep 17 00:00:00 2001 From: DikieDick <26772815+DikieDick@users.noreply.github.com> Date: Mon, 6 Oct 2025 10:56:54 +0200 Subject: [PATCH 27/50] Add prosemirror dev tools --- package-lock.json | 582 +++++++++++++++++++++++++++++++++++++++++++++- package.json | 1 + src/editor.ts | 10 + 3 files changed, 590 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5015500..1b453ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,6 +45,7 @@ "jest-environment-jsdom": "^29.7.0", "mocha": "^10.2.0", "prettier": "^2.8.1", + "prosemirror-dev-tools": "^4.2.0", "ts-jest": "^29.1.0", "typescript": "^5.1.3", "typescript-eslint": "^8.20.0" @@ -95,6 +96,7 @@ "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -542,6 +544,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", @@ -689,6 +701,19 @@ "w3c-keyname": "^2.2.4" } }, + "node_modules/@compiled/react": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/@compiled/react/-/react-0.11.4.tgz", + "integrity": "sha512-mtnEUFM7w/5xABWWWj3wW0vjS/cHSg0PAttJC+hOpQ5z5qGZCwk43Gy8Hfjruxvll73igJ5DSMzcAyek6DMKjw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "csstype": "^3.1.1" + }, + "peerDependencies": { + "react": ">= 16.12.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz", @@ -2165,9 +2190,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", - "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT" }, @@ -2346,6 +2371,13 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/base16": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/base16/-/base16-1.0.5.tgz", + "integrity": "sha512-OzOWrTluG9cwqidEzC/Q6FAmIPcnZfm8BFRlIx0+UIUqnuAmi5OS88O0RpT3Yz6qdmqObvUhasrbNsCofE4W9A==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2447,6 +2479,13 @@ "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", "license": "MIT" }, + "node_modules/@types/lodash": { + "version": "4.17.20", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/markdown-it": { "version": "14.1.2", "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", @@ -2484,6 +2523,25 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.25", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.25.tgz", + "integrity": "sha512-oSVZmGtDPmRZtVDqvdKUi/qgCsWp5IDY29wp8na8Bj4B3cc99hfNzvNhlMkVVxctkAOGUA3Km7MMpBHAnWfcIA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, "node_modules/@types/sizzle": { "version": "2.3.9", "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.9.tgz", @@ -2568,6 +2626,7 @@ "integrity": "sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.38.0", "@typescript-eslint/types": "8.38.0", @@ -2794,6 +2853,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2888,6 +2948,19 @@ "node": ">=8" } }, + "node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -3131,6 +3204,13 @@ "dev": true, "license": "MIT" }, + "node_modules/base16": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/base16/-/base16-1.0.0.tgz", + "integrity": "sha512-pNdYkNPiJUnEhnfXV56+sQy8+AaPcG3POZAUnwr4EeqCUZFz4u2PePbo3e5Gj4ziYPCWGUZT9RHisvJKnwFuBQ==", + "dev": true, + "license": "MIT" + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -3195,6 +3275,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", @@ -3293,6 +3374,21 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/char-regex": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", @@ -3381,6 +3477,45 @@ "dev": true, "license": "MIT" }, + "node_modules/color": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz", + "integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.3", + "color-string": "^1.6.0" + } + }, + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -3410,6 +3545,22 @@ "dev": true, "license": "MIT" }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "dev": true, + "engines": [ + "node >= 0.8" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -3417,6 +3568,13 @@ "dev": true, "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, "node_modules/create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -3563,6 +3721,13 @@ "dev": true, "license": "MIT" }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true, + "license": "MIT" + }, "node_modules/data-urls": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", @@ -3688,6 +3853,13 @@ "node": ">=0.3.1" } }, + "node_modules/diff-match-patch": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz", + "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/diff-sequences": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", @@ -3893,6 +4065,16 @@ "node": ">=6" } }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/escodegen": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", @@ -3921,6 +4103,7 @@ "integrity": "sha512-QldCVh/ztyKJJZLr4jXNUByx3gR+TDYZCRXEktiZoUR3PGy4qCmSbkxcIle8GEwGpb5JBZazlaJ/CxLidXdEbQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -4570,6 +4753,16 @@ "dev": true, "license": "MIT" }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -4622,6 +4815,19 @@ "he": "bin/he" } }, + "node_modules/html": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/html/-/html-1.0.0.tgz", + "integrity": "sha512-lw/7YsdKiP3kk5PnR1INY17iJuzdAtJewxr14ozKJWbbR97znovZ0mh+WEMZ8rjc3lgTK+ID/htTjuyGKB52Kw==", + "dev": true, + "license": "BSD", + "dependencies": { + "concat-stream": "^1.4.7" + }, + "bin": { + "html": "bin/html.js" + } + }, "node_modules/html-encoding-sniffer": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", @@ -4876,6 +5082,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -5078,6 +5291,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -6836,6 +7050,65 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/jotai": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/jotai/-/jotai-1.13.1.tgz", + "integrity": "sha512-RUmH1S4vLsG3V6fbGlKzGJnLrDcC/HNb5gH2AeA9DzuJknoVxSGvvg8OBB7lke+gDc4oXmdVsaKn/xDUhWZ0vw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@babel/core": "*", + "@babel/template": "*", + "jotai-devtools": "*", + "jotai-immer": "*", + "jotai-optics": "*", + "jotai-redux": "*", + "jotai-tanstack-query": "*", + "jotai-urql": "*", + "jotai-valtio": "*", + "jotai-xstate": "*", + "jotai-zustand": "*", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@babel/template": { + "optional": true + }, + "jotai-devtools": { + "optional": true + }, + "jotai-immer": { + "optional": true + }, + "jotai-optics": { + "optional": true + }, + "jotai-redux": { + "optional": true + }, + "jotai-tanstack-query": { + "optional": true + }, + "jotai-urql": { + "optional": true + }, + "jotai-valtio": { + "optional": true + }, + "jotai-xstate": { + "optional": true + }, + "jotai-zustand": { + "optional": true + } + } + }, "node_modules/jquery": { "version": "3.7.1", "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", @@ -7053,6 +7326,23 @@ "node": ">=6" } }, + "node_modules/jsondiffpatch": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/jsondiffpatch/-/jsondiffpatch-0.4.1.tgz", + "integrity": "sha512-t0etAxTUk1w5MYdNOkZBZ8rvYYN5iL+2dHCCx/DpkFm/bW28M6y5nUS83D4XdZiHy35Fpaw6LBb+F88fHZnVCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^2.3.0", + "diff-match-patch": "^1.0.0" + }, + "bin": { + "jsondiffpatch": "bin/jsondiffpatch" + }, + "engines": { + "node": ">=8.17.0" + } + }, "node_modules/katex": { "version": "0.16.22", "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.22.tgz", @@ -7062,6 +7352,7 @@ "https://github.com/sponsors/katex" ], "license": "MIT", + "peer": true, "dependencies": { "commander": "^8.3.0" }, @@ -7145,6 +7436,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.curry": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.curry/-/lodash.curry-4.1.1.tgz", + "integrity": "sha512-/u14pXGviLaweY5JI0IUzgzF2J6Ne8INyzAZjImcryjgkZ+ebruBxy2/JaOOkTqScddcYtakjhSaeemV8lR0tA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -7252,6 +7557,19 @@ "node": ">=8" } }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -7551,6 +7869,25 @@ "dev": true, "license": "MIT" }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -7602,6 +7939,16 @@ "dev": true, "license": "MIT" }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -7942,6 +8289,13 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -7956,22 +8310,105 @@ "node": ">= 6" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/prosemirror-commands": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.6.0.tgz", "integrity": "sha512-xn1U/g36OqXn2tn5nGmvnnimAj/g1pUx2ypJJIe8WkVX83WyJVC5LTARaxZa2AtQRwntu9Jc5zXs9gL9svp/mg==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.0.0" } }, + "node_modules/prosemirror-dev-tools": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/prosemirror-dev-tools/-/prosemirror-dev-tools-4.2.0.tgz", + "integrity": "sha512-Hm1HRgK0Fxhb+Dy507R1uHgP3Ixuwbh7ZHD6NUZGEAl26BKW95L70nwdA1P4odPfxPsx0lBZm1KMpulsOJMwpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.6", + "@compiled/react": "^0.11.1", + "html": "^1.0.0", + "jotai": "^1.10.0", + "jsondiffpatch": "^0.4.1", + "nanoid": "^3.3.8", + "prosemirror-model": ">=1.0.0", + "prosemirror-state": ">=1.0.0", + "react-dock": "^0.6.0", + "react-json-tree": "^0.17.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/prosemirror-dev-tools/node_modules/react-dock": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/react-dock/-/react-dock-0.6.0.tgz", + "integrity": "sha512-jEOhv1s+pqRQ4JxgUw4XUotnprOehZ23mqchf3whxYXnvNgTQOXCxh6bpcqW8P6OybIk2bYO18r3qimZ3ypCbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@types/lodash": "^4.14.182", + "@types/prop-types": "^15.7.5", + "lodash.debounce": "^4.0.8", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "@types/react": "^16.3.0 || ^17.0.0 || ^18.0.0", + "react": "^16.3.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/prosemirror-dev-tools/node_modules/react-json-tree": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-json-tree/-/react-json-tree-0.17.0.tgz", + "integrity": "sha512-hcWjibI/fAvsKnfYk+lka5OrE1Lvb1jH5pSnFhIU5T8cCCxB85r6h/NOzDPggSSgErjmx4rl3+2EkeclIKBOhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@types/lodash": "^4.14.182", + "@types/prop-types": "^15.7.5", + "prop-types": "^15.8.1", + "react-base16-styling": "^0.9.1" + }, + "peerDependencies": { + "@types/react": "^16.3.0 || ^17.0.0 || ^18.0.0", + "react": "^16.3.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/prosemirror-history": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.4.1.tgz", "integrity": "sha512-2JZD8z2JviJrboD9cPuX/Sv/1ChFng+xh2tChQ2X4bB2HeK+rra/bmJ3xGntCcjhOqIzSDG6Id7e8RJ9QPXLEQ==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-state": "^1.2.2", "prosemirror-transform": "^1.0.0", @@ -7984,6 +8421,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.4.0.tgz", "integrity": "sha512-6ygpPRuTJ2lcOXs9JkefieMst63wVJBgHZGl5QOytN7oSZs3Co/BYbc3Yx9zm9H37Bxw8kVzCnDsihsVsL4yEg==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.0.0" @@ -7994,6 +8432,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.2.tgz", "integrity": "sha512-EAlXoksqC6Vbocqc0GtzCruZEzYgrn+iiGnNjsJsH4mrnIGex4qbLdWWNza3AW5W36ZRrlBID0eM6bdKH4OStQ==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-state": "^1.0.0", "w3c-keyname": "^2.2.0" @@ -8065,6 +8504,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.22.3.tgz", "integrity": "sha512-V4XCysitErI+i0rKFILGt/xClnFJaohe/wrrlT2NSZ+zk8ggQfDH4x2wNK7Gm0Hp4CIoWizvXFP7L9KMaCuI0Q==", "license": "MIT", + "peer": true, "dependencies": { "orderedmap": "^2.0.0" } @@ -8074,6 +8514,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.3.tgz", "integrity": "sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", @@ -8085,6 +8526,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.0.tgz", "integrity": "sha512-9UOgFSgN6Gj2ekQH5CTDJ8Rp/fnKR2IkYfGdzzp5zQMFsS4zDllLVx/+jGcX86YlACpG7UR5fwAXiWzxqWtBTg==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.21.0" } @@ -8094,6 +8536,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.34.3.tgz", "integrity": "sha512-mKZ54PrX19sSaQye+sef+YjBbNu2voNwLS1ivb6aD2IRmxRGW64HU9B644+7OfJStGLyxvOreKqEgfvXa91WIA==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -8187,6 +8630,47 @@ "safe-buffer": "^5.1.0" } }, + "node_modules/react": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", + "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-base16-styling": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/react-base16-styling/-/react-base16-styling-0.9.1.tgz", + "integrity": "sha512-1s0CY1zRBOQ5M3T61wetEpvQmsYSNtWEcdYzyZNxKa8t7oDvaOn9d21xrGezGAHFWLM7SHcktPuPTrvoqxSfKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.16.7", + "@types/base16": "^1.0.2", + "@types/lodash": "^4.14.178", + "base16": "^1.0.0", + "color": "^3.2.1", + "csstype": "^3.0.10", + "lodash.curry": "^4.1.1" + } + }, + "node_modules/react-dom": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", + "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.0" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -8194,6 +8678,29 @@ "dev": true, "license": "MIT" }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -8370,6 +8877,13 @@ "node": ">=v12.22.7" } }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/semver": { "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", @@ -8423,6 +8937,23 @@ "dev": true, "license": "ISC" }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/simple-swizzle/node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "dev": true, + "license": "MIT" + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -8491,6 +9022,23 @@ "node": ">=8" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -8572,6 +9120,19 @@ "integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==", "license": "MIT" }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -8765,12 +9326,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "dev": true, + "license": "MIT" + }, "node_modules/typescript": { "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8861,6 +9430,13 @@ "requires-port": "^1.0.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", diff --git a/package.json b/package.json index 04b88d0..0ba6ee1 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "jest-environment-jsdom": "^29.7.0", "mocha": "^10.2.0", "prettier": "^2.8.1", + "prosemirror-dev-tools": "^4.2.0", "ts-jest": "^29.1.0", "typescript": "^5.1.3", "typescript-eslint": "^8.20.0" diff --git a/src/editor.ts b/src/editor.ts index 2199ff7..4a78c91 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -35,6 +35,11 @@ import { getCmdInsertCode, getCmdInsertLatex, getCmdInsertMarkdown } from "./com import { InsertionPlace } from "./commands"; import { deleteSelection } from "./commands/commands"; +//@ts-expect-error Defined by esbuild. +const debugMode = DEBUG; +//@ts-expect-error No types for this import, but only used in debug mode +import { applyDevTools } from "prosemirror-dev-tools"; + /** Type that contains a coq diagnostics object fit for use in the ProseMirror editor context. */ type DiagnosticObjectProse = {message: string, start: number, end: number, $start: ResolvedPos, $end: ResolvedPos, severity: Severity}; @@ -215,6 +220,11 @@ export class WaterproofEditor { } }); this._view = view; + + if (debugMode) { + console.log("\x1b[33m[DEBUG]\x1b[0m Debug mode enabled. We will attach pm-dev-tools"); + applyDevTools(view); + } } /** Create initial prosemirror state */ From 81e8fc6ccdcaf3272e2e4794346a3ef0f088cb7c Mon Sep 17 00:00:00 2001 From: DikieDick <26772815+DikieDick@users.noreply.github.com> Date: Mon, 6 Oct 2025 12:07:34 +0200 Subject: [PATCH 28/50] Update utils for Pieter --- src/document/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/document/utils.ts b/src/document/utils.ts index 751a133..91c50be 100644 --- a/src/document/utils.ts +++ b/src/document/utils.ts @@ -73,7 +73,7 @@ export function extractBlocksUsingRanges( const content = inputDocument.slice(range.from, range.to); const eRange = { from: range.from + parentOffset, to: range.to + parentOffset }; // Fixme: inner range is currently just the same as the outer range (fine for markdown) - const iRange = eRange; + const iRange = { from: eRange.from, to: eRange.to }; return new BlockConstructor(content, eRange, iRange); }).filter(block => { return block.range.from !== block.range.to; From e59bf813f0f9074ad6228f1b235cb0dc912c946c Mon Sep 17 00:00:00 2001 From: DikieDick <26772815+DikieDick@users.noreply.github.com> Date: Mon, 6 Oct 2025 17:18:28 +0200 Subject: [PATCH 29/50] Move mapping to waterproof-editor Co-authored-by: XyntaxCS <82953362+XyntaxCS@users.noreply.github.com> Co-authored-by: raulTUe --- __tests__/mapping-blockat.test.ts | 616 +++++++++++++++++++++++++ __tests__/markdown-parser.test.ts | 18 +- __tests__/newmapping.test.ts | 153 ++++++ __tests__/nodeupdate.test.ts | 5 + __tests__/statemachine.test.ts | 108 +++++ __tests__/textupdate.test.ts | 110 +++++ __tests__/tree.test.ts | 16 + documentation/UsingWaterproofEditor.md | 4 - src/api/index.ts | 6 +- src/api/types.ts | 16 +- src/document/blocks/index.ts | 3 +- src/editor.ts | 7 +- src/mapping/Tree.ts | 198 ++++++++ src/mapping/helper-functions.ts | 12 + src/mapping/index.ts | 4 + src/mapping/newmapping.ts | 243 ++++++++++ src/mapping/nodeUpdate.ts | 475 +++++++++++++++++++ src/mapping/textUpdate.ts | 80 ++++ src/mapping/types.ts | 22 + src/markdown-defaults/index.ts | 2 +- src/markdown-defaults/statemachine.ts | 8 +- 21 files changed, 2076 insertions(+), 30 deletions(-) create mode 100644 __tests__/mapping-blockat.test.ts create mode 100644 __tests__/newmapping.test.ts create mode 100644 __tests__/nodeupdate.test.ts create mode 100644 __tests__/statemachine.test.ts create mode 100644 __tests__/textupdate.test.ts create mode 100644 __tests__/tree.test.ts create mode 100644 src/mapping/Tree.ts create mode 100644 src/mapping/helper-functions.ts create mode 100644 src/mapping/index.ts create mode 100644 src/mapping/newmapping.ts create mode 100644 src/mapping/nodeUpdate.ts create mode 100644 src/mapping/textUpdate.ts create mode 100644 src/mapping/types.ts diff --git a/__tests__/mapping-blockat.test.ts b/__tests__/mapping-blockat.test.ts new file mode 100644 index 0000000..040ecba --- /dev/null +++ b/__tests__/mapping-blockat.test.ts @@ -0,0 +1,616 @@ +import { DocumentSerializer } from "../src/api"; +import { Block } from "../src/document"; +import { Mapping } from "../src/mapping"; +import { configuration, parse } from "../src/markdown-defaults"; +import { WaterproofSchema } from "../src/schema"; +import { Node as ProseNode } from "prosemirror-model"; + + + +function root (childNodes: ProseNode[]) { + return WaterproofSchema.nodes.doc.create({}, childNodes); +} + +function constructDocument(blocks: Block[]): ProseNode { + const documentContent: ProseNode[] = blocks.map(block => block.toProseMirror()); + return root(documentContent); +} + +test("BlockAt with simple .mv file", () => { + const doc = "# Test\n```coq\nTest.\n```\n\n```coq\nTestingtest.\n```\n"; + + // const doc = "# Test\n"; + const blocks = parse(doc, "coq"); + + const mapping = new Mapping(blocks, 0, configuration("coq"), new DocumentSerializer(configuration("coq"))); + const proseDoc = constructDocument(blocks); + const tree = mapping.getMapping(); + + tree.traverseDepthFirst(treeNode => { + if (treeNode === tree.root) return; + + expect(proseDoc.nodeAt(treeNode.pmRange.from)?.type.name).toBe(treeNode.type); + }); +}); + +test("BlockAt for full tutorial", () => { + const tutorial = `# Waterproof Tutorial + +Try to solve the exercises below by inspecting the examples. Not sure how to start? You can type **Ctrl + space** or **Command + space** to get a list of possible options. +\`\`\`coq +Require Import Rbase. +Require Import Rfunctions. + +Require Import Waterproof.Waterproof. +Require Import Waterproof.Notations.Common. +Require Import Waterproof.Notations.Reals. +Require Import Waterproof.Notations.Sets. +Require Import Waterproof.Chains. +Require Import Waterproof.Tactics. +Require Import Waterproof.Libs.Analysis.SupAndInf. +Require Import Waterproof.Automation. + +Waterproof Enable Automation RealsAndIntegers. + + +Open Scope R_scope. +Open Scope subset_scope. +Set Default Goal Selector "!". + +Set Bullet Behavior "Waterproof Relaxed Subproofs". + + +Notation "'max(' x , y )" := (Rmax x y) + (format "'max(' x , y ')'"). +Notation "'min(' x , y )" := (Rmin x y) + (format "'min(' x , y ')'"). +\`\`\` +## 1. We conclude that + +### Example: +\`\`\`coq +Lemma example_reflexivity : + 0 = 0. +Proof. +Help. (* optional, ask for help, remove from final proof *) +We conclude that 0 = 0. +Qed. +\`\`\` +### Try it yourself: + +**Note**: Note sure how to continue? You can always write the sentence \`Help.\` in your proof to ask for help. Please remove the sentence from your final proof. +\`\`\`coq +Lemma exercise_reflexivity : + 3 = 3. +Proof. +\`\`\` + +\`\`\`coq + +\`\`\` + +\`\`\`coq +Qed. +\`\`\` +## 2. We need to show that +Sometimes it is useful to remind the reader or yourself of what you need to show. +### Example +\`\`\`coq +Lemma example_we_need_to_show_that : + 2 = 2. +Proof. +We need to show that 2 = 2. +We conclude that 2 = 2. +Qed. +\`\`\` +### Try it yourself +\`\`\`coq +Lemma exercise_we_need_to_show_that : + 7 = 7. +Proof. +\`\`\` + +\`\`\`coq + +\`\`\` + +\`\`\`coq +Qed. +\`\`\` +## 3. Show for-all statements: take arbitrary values + +### Example: +\`\`\`coq +Lemma example_take : + ∀ x ∈ ℝ, + x = x. +Proof. +Take x ∈ ℝ. +We conclude that x = x. +Qed. +\`\`\` +### Try it yourself: +\`\`\`coq +Lemma exercise_take : + ∀ x ∈ ℝ, + x + 3 = 3 + x. +Proof. +\`\`\` + +\`\`\`coq + +\`\`\` + +\`\`\`coq +Qed. +\`\`\` +## 4. Show there-exists statements: choose values +### Example +\`\`\`coq +Lemma example_choose : + ∃ y ∈ ℝ, + y < 3. +Proof. +Choose y := 2. +* Indeed, y ∈ ℝ. +* We conclude that y < 3. +Qed. +\`\`\` +### Try it yourself +\`\`\`coq +Lemma exercise_choose : + ∃ z > 10, + z < 14. +Proof. +\`\`\` + +\`\`\`coq + +\`\`\` + +\`\`\`coq +Qed. +\`\`\` +## 5. Combine for-all and there-exists statements +### Example +\`\`\`coq +Lemma example_combine_quantifiers : + ∀ a ∈ ℝ, + ∀ b > 5, + ∃ c ∈ ℝ, + c > b - a. +Proof. +Take a ∈ ℝ. +Take b > 5. +Choose c := b - a + 1. +* Indeed, c ∈ ℝ. +* We conclude that c > b - a. +Qed. +\`\`\` +### Try it yourself +\`\`\`coq +Lemma exercise_combine_quantifiers : + ∀ x > 3, + ∀ y ≥ 4, + ∃ z ∈ ℝ, + x < z - y. +Proof. +\`\`\` + +\`\`\`coq + +\`\`\` + +\`\`\`coq +Qed. +\`\`\` +## 6. Make an assumption +### Example +\`\`\`coq +Lemma example_assumptions : + ∀ a ∈ ℝ, + a < 0 ⇒ - a > 0. +Proof. +Take a ∈ ℝ. +Assume that a < 0. +We conclude that - a > 0. +Qed. +\`\`\` +### Another example with explicit labels +\`\`\`coq +Lemma example_assumptions_2 : + ∀ a ∈ ℝ, + a < 0 ⇒ - a > 0. +Proof. +Take a ∈ ℝ. +Assume that a < 0 as (i). (* The label here is optional *) +By (i) we conclude that - a > 0. +Qed. +\`\`\` +### Try it yourself +\`\`\`coq +Lemma exercise_assumptions : + ∀ a ≥ 2, + ∀ b ∈ ℝ, + a > 0 ⇒ b > 0 ⇒ a + b > - 1. +Proof. +\`\`\` + +\`\`\`coq + +\`\`\` + +\`\`\`coq +Qed. +\`\`\` +## 7. Chains of (in)equalities +### Example +\`\`\`coq +Section monotone_function. +Variable f : ℝ → ℝ. +Parameter f_increasing : ∀ x ∈ ℝ, ∀ y ∈ ℝ, x ≤ y ⇒ f(x) ≤ f(y). + +Lemma example_inequalities: + 2 < f(0) ⇒ 2 < f(1). +Proof. +Assume that 2 < f(0). +By f_increasing we conclude that & 2 < f(0) ≤ f(1). +Qed. +\`\`\` +### Try it yourself +\`\`\`coq +Lemma exercise_inequalities: + f(3) < 5 ⇒ f(-1) < 5. +Proof. +\`\`\` + +\`\`\`coq + +\`\`\` + +\`\`\`coq +Qed. +\`\`\` +## 8. Backwards reasoning in smaller steps + +### Example +\`\`\`coq +Lemma example_backwards : + 3 < f(0) ⇒ 2 < f(5). +Proof. +Assume that 3 < f(0). +It suffices to show that f(0) ≤ f(5). +By f_increasing we conclude that f(0) ≤ f(5). +Qed. +\`\`\` +### Try it yourself +\`\`\`coq +Lemma exercise_backwards : + f(5) < 4 ⇒ f(-2) < 5. +Proof. +\`\`\` + +\`\`\`coq + +\`\`\` + +\`\`\`coq +Qed. +\`\`\` +## 9. Forwards reasoning in smaller steps +### Example +\`\`\`coq +Lemma example_forwards : + 7 < f(-1) ⇒ 2 < f(6). +Proof. +Assume that 7 < f(-1). +By f_increasing it holds that f(-1) ≤ f(6). +We conclude that 2 < f(6). +Qed. +\`\`\` +### Try it yourself +\`\`\`coq +Lemma exercise_forwards : + f(7) < 8 ⇒ f(3) ≤ 10. +Proof. +\`\`\` + +\`\`\`coq + +\`\`\` + +\`\`\`coq +Qed. +\`\`\` + +\`\`\`coq +End monotone_function. +\`\`\` +## 10. Use a *for-all* statement +### Example +\`\`\`coq +Lemma example_use_for_all : + ∀ x ∈ ℝ, + (∀ ε > 0, x < ε) ⇒ + x + 1/2 < 1. +Proof. +Take x ∈ ℝ. +Assume that ∀ ε > 0, x < ε as (i). +Use ε := 1/2 in (i). +* Indeed, 1 / 2 > 0. +* It holds that x < 1 / 2. + We conclude that x + 1/2 < 1. +Qed. +\`\`\` +### Try it yourself +\`\`\`coq +Lemma exercise_use_for_all: + ∀ x ∈ ℝ, + (∀ ε > 0, x < ε) ⇒ + 10 * x < 1. + +Proof. +\`\`\` + +\`\`\`coq + +\`\`\` + +\`\`\`coq +Qed. +\`\`\` +## 11. Use a *there-exists* statement +### Example +\`\`\`coq +Lemma example_use_there_exists : + ∀ x ∈ ℝ, + (∃ y > 10, y < x) ⇒ + 10 < x. +Proof. +Take x ∈ ℝ. +Assume that ∃ y > 10, y < x as (i). +Obtain such a y. +We conclude that & 10 < y < x. +Qed. +\`\`\` +### Another example +\`\`\`coq +Lemma example_use_there_exists_2 : + ∀ x ∈ ℝ, + (∃ y > 14, y < x) ⇒ + 12 < x. +Proof. +Take x ∈ ℝ. +Assume that ∃ y > 14, y < x as (i). +Obtain y according to (i). +We conclude that & 12 < y < x. +Qed. +\`\`\` +### Try it yourself +\`\`\`coq +Lemma exercise_use_there_exists : + ∀ z ∈ ℝ, + (∃ x ≥ 5, x^2 < z) ⇒ + 25 < z. + +Proof. +\`\`\` + +\`\`\`coq + +\`\`\` + +\`\`\`coq +Qed. +\`\`\` +## 12. Argue by contradiction +### Example +\`\`\`coq +Lemma example_contradicition : + ∀ x ∈ ℝ, + (∀ ε > 0, x > 1 - ε) ⇒ + x ≥ 1. +Proof. +Take x ∈ ℝ. +Assume that ∀ ε > 0, x > 1 - ε as (i). +We need to show that x ≥ 1. +We argue by contradiction. +Assume that ¬ (x ≥ 1). +It holds that (1 - x) > 0. +By (i) it holds that x > 1 - (1 - x). +Contradiction. +Qed. +\`\`\` +### Try it yourself +\`\`\`coq +Lemma exercise_contradiction : + ∀ x ∈ ℝ, + (∀ ε > 0, x < ε) + ⇒ x ≤ 0. +Proof. +\`\`\` + +\`\`\`coq + +\`\`\` + +\`\`\`coq +Qed. +\`\`\` +## 13. Split into cases +### Example +\`\`\`coq +Lemma example_cases : + ∀ x ∈ ℝ, ∀ y ∈ ℝ, + max(x, y) = x ∨ max(x, y) = y. +Proof. +\`\`\` + +\`\`\`coq +Take x ∈ (ℝ). +Take y ∈ (ℝ). +Either x < y or x ≥ y. +- Case x < y. + It suffices to show that max(x, y) = y. + We conclude that max(x, y) = y. +- Case x ≥ y. + It suffices to show that max(x, y) = x. + We conclude that max(x, y) = x. +Qed. +\`\`\` +### Try it yourself +\`\`\`coq +Lemma exercises_cases : + ∀ x ∈ ℝ, ∀ y ∈ ℝ, + min(x, y) = x ∨ min(x, y) = y. +Proof. +\`\`\` + +\`\`\`coq + +\`\`\` + +\`\`\`coq +Qed. +\`\`\` +## 14. Prove two statements: A ∧ B +### Example +\`\`\`coq +Lemma example_both_statements : + ∀ x ∈ ℝ, x^2 ≥ 0 ∧ | x | ≥ 0. +Proof. +Take x ∈ ℝ. +We show both statements. +* We need to show that x^2 ≥ 0. + We conclude that x^2 ≥ 0. +* We need to show that | x | ≥ 0. + We conclude that | x | ≥ 0. +Qed. +\`\`\` +### Try it yourself +\`\`\`coq +Lemma exercise_both_statements : + ∀ x ∈ ℝ, 0 * x = 0 ∧ x + 1 > x. +Proof. +\`\`\` + +\`\`\`coq + +\`\`\` + +\`\`\`coq +Qed. +\`\`\` +## 15. Show both directions +### Example +\`\`\`coq +Lemma example_both_directions : + ∀ x ∈ ℝ, ∀ y ∈ ℝ, + x < y ⇔ y > x. +Proof. +Take x ∈ ℝ. +Take y ∈ ℝ. +We show both directions. +++ We need to show that x < y ⇒ y > x. + Assume that x < y. + We conclude that y > x. +++ We need to show that y > x ⇒ x < y. + Assume that y > x. + We conclude that x < y. +Qed. +\`\`\` +### Try it yourself +\`\`\`coq +Lemma exercise_both_directions : + ∀ x ∈ ℝ, x > 1 ⇔ x - 1 > 0. +Proof. +\`\`\` + +\`\`\`coq + +\`\`\` + +\`\`\`coq +Qed. +\`\`\` +## 16. Proof by induction +### Example +\`\`\`coq +Lemma example_induction : + ∀ n : ℕ → ℕ, (∀ k ∈ ℕ, (n(k) < n(k+1))%nat) ⇒ + ∀ k ∈ ℕ, (k ≤ n(k))%nat. +Proof. +Take n : ℕ → ℕ. +Assume that (∀ k ∈ ℕ, n(k) < n(k+1))%nat. +We use induction on k. ++ We first show the base case (0 ≤ n(0))%nat. + We conclude that (0 ≤ n(0))%nat. ++ We now show the induction step. + Take k ∈ ℕ. + Assume that (k ≤ n(k))%nat. + It holds that (n(k) < n(k+1))%nat. + It holds that (n(k) + 1 ≤ n(k+1))%nat. + We conclude that (& k + 1 ≤ n(k) + 1 ≤ n(k + 1))%nat. +Qed. +\`\`\` +### Try it yourself +\`\`\`coq +Lemma exercise_induction : + ∀ F : ℕ → ℕ, (∀ k ∈ ℕ, (F(k+1) = F(k))%nat) ⇒ + ∀ k ∈ ℕ, (F(k) = F(0))%nat. +Proof. +\`\`\` + +\`\`\`coq + +\`\`\` + +\`\`\`coq +Qed. +\`\`\` +## 17. Expand definitions +By writing \`Expand the definition of square.\` you get suggestions on how to use the definition of _square_ in your proof. + +### Example +\`\`\`coq +Definition square (x : ℝ) := x^2. +\`\`\` + +\`\`\`coq +Lemma example_expand : + ∀ x ∈ ℝ, square x ≥ 0. +Proof. +Take x ∈ (ℝ). +Expand the definition of square. + (* Remove the above line in your own code! *) +We need to show that x^2 ≥ 0. +We conclude that x^2 ≥ 0. +Qed. +\`\`\` +### Try it yourself +\`\`\`coq +Lemma exercise_expand : + ∀ x ∈ ℝ, - (square x) ≤ 0. +Proof. +\`\`\` + +\`\`\`coq + +\`\`\` + +\`\`\`coq +Qed. +\`\`\` +` + const blocks = parse(tutorial, "coq"); + + const mapping = new Mapping(blocks, 0, configuration("coq"), new DocumentSerializer(configuration("coq"))); + const proseDoc = constructDocument(blocks); + const tree = mapping.getMapping(); + + tree.traverseDepthFirst(treeNode => { + if (treeNode === tree.root) return; + + expect(proseDoc.nodeAt(treeNode.pmRange.from)?.type.name).toBe(treeNode.type); + }); +}); \ No newline at end of file diff --git a/__tests__/markdown-parser.test.ts b/__tests__/markdown-parser.test.ts index 14a2865..5b485f9 100644 --- a/__tests__/markdown-parser.test.ts +++ b/__tests__/markdown-parser.test.ts @@ -1,4 +1,5 @@ -import { parser } from "../src/markdown-defaults"; +import { typeguards } from "../src/document"; +import { parse } from "../src/markdown-defaults"; const doc = `# test \`\`\`python @@ -14,7 +15,18 @@ This is a hint block with some **markdown** content.
`; test("test", () => { - const blocks = parser(doc, "python"); - console.log(blocks); + const blocks = parse(doc, "python"); + expect(blocks.length).toBe(9); + const [md1, nl1, py1, nl2, md2, nl3, py2, nl4, hint] = blocks; + + expect(typeguards.isMarkdownBlock(md1)).toBe(true); + expect(typeguards.isNewlineBlock(nl1)).toBe(true); + expect(typeguards.isCodeBlock(py1)).toBe(true); + expect(typeguards.isNewlineBlock(nl2)).toBe(true); + expect(typeguards.isMarkdownBlock(md2)).toBe(true); + expect(typeguards.isNewlineBlock(nl3)).toBe(true); + expect(typeguards.isCodeBlock(py2)).toBe(true); + expect(typeguards.isNewlineBlock(nl4)).toBe(true); + expect(typeguards.isHintBlock(hint)).toBe(true); }); \ No newline at end of file diff --git a/__tests__/newmapping.test.ts b/__tests__/newmapping.test.ts new file mode 100644 index 0000000..cab6359 --- /dev/null +++ b/__tests__/newmapping.test.ts @@ -0,0 +1,153 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +// Disable because the @ts-expect-error clashes with the tests +import { DocumentSerializer, Mapping } from "../src/api"; +import { TreeNode } from "../src/mapping"; +import { configuration, parse } from "../src/markdown-defaults"; + +const config = configuration("coq"); +const serializer = new DocumentSerializer(config); + +function createTestMapping(content: string): TreeNode[] { + const blocks = parse(content, "coq"); + + const mapping = new Mapping(blocks, 1, config, serializer) + const tree = mapping.getMapping(); + const nodes: TreeNode[] = []; + tree.traverseDepthFirst((node: TreeNode) => { + nodes.push(node); + }); + return nodes; +} + +// // Not sure about the values for prosemirrorStart and prosemirrorEnd +// test("testMapping", () => { +// const content = `Hello`; +// const nodes = createTestMapping(content); +// expect(nodes.length).toBe(2); +// const markdownNode = nodes[1]; +// console.log(markdownNode) +// expect(markdownNode.type).toBe("markdown"); +// expect(markdownNode.innerRange.from).toBe(0); +// expect(markdownNode.innerRange.to).toBe(5); +// expect(markdownNode.prosemirrorStart).toBe(1); +// expect(markdownNode.prosemirrorEnd).toBe(6); +// expect(markdownNode.stringContent).toBe("Hello"); +// }) + +// test("testMapping coqblock with code", () => { +// const content = "```coq\nLemma test\n```"; +// const nodes = createTestMapping(content); + +// expect(nodes.length).toBe(2); + +// // Parent coqblock +// const coqblockNode = nodes[1]; +// expect(coqblockNode.type).toBe("code"); +// expect(coqblockNode.innerRange.from).toBe(7); +// expect(coqblockNode.innerRange.to).toBe(17); +// expect(coqblockNode.prosemirrorStart).toBe(1); +// expect(coqblockNode.prosemirrorEnd).toBe(11); +// expect(coqblockNode.stringContent).toBe("Lemma test"); +// }); + +test("Input-area with nested coqblock", () => { + const content = "\n```coq\nTest\n```\nHello"; + const nodes = createTestMapping(content); + + expect(nodes.length).toBe(6); + + // Input-area node + const inputAreaNode = nodes[1]; + expect(inputAreaNode.type).toBe("input"); + expect(inputAreaNode.innerRange.from).toBe(12); + expect(inputAreaNode.innerRange.to).toBe(29); + expect(inputAreaNode.prosemirrorStart).toBe(1); + expect(inputAreaNode.prosemirrorEnd).toBe(9); + + // Nested coqblock + const coqblockNode = nodes[3]; + console.log(nodes) + expect(coqblockNode.type).toBe("code"); + expect(coqblockNode.innerRange.from).toBe(20); + expect(coqblockNode.innerRange.to).toBe(24); + expect(coqblockNode.prosemirrorStart).toBe(3); + expect(coqblockNode.prosemirrorEnd).toBe(7); + +}); + +// test("Hint block with coqblock and markdown inside", () => { +// const content = "\n```coq\nRequire Import Rbase.\n```\n"; +// const nodes = createTestMapping(content); + +// expect(nodes.length).toBe(3); + +// // Hint node +// const hintNode = nodes[1]; +// expect(hintNode.type).toBe("hint"); +// expect(hintNode.innerRange.from).toBe(31); +// expect(hintNode.innerRange.to).toBe(65); +// expect(hintNode.prosemirrorStart).toBe(1); +// expect(hintNode.prosemirrorEnd).toBe(31); +// // Nested coqblock +// const coqblockNode = nodes[2]; +// expect(coqblockNode.innerRange.from).toBe(39); +// expect(coqblockNode.innerRange.to).toBe(60); +// }); + +// test("Mixed content section", () => { +// const content = `### Example: +// \`\`\`coq +// Lemma +// Test +// \`\`\` +// +// \`\`\`coq +// (* Your solution here *) +// \`\`\` +// `; +// const nodes = createTestMapping(content); +// console.log(nodes) + +// // Expected nodes: markdown (header), coqblock, input-area (with coqblock) +// expect(nodes.length).toBe(5); + +// // Verify markdown header +// const headerNode = nodes[1]; +// expect(headerNode.type).toBe("markdown"); +// expect(headerNode.stringContent).toContain("### Example:"); +// expect(headerNode.innerRange.from).toBe(0) +// expect(headerNode.innerRange.to).toBe(12) +// expect(headerNode.prosemirrorStart).toBe(1) +// expect(headerNode.prosemirrorEnd).toBe(13) + +// // Example coqblock +// const exampleCoqblock = nodes[2]; +// expect(exampleCoqblock.type).toBe("code"); +// expect(exampleCoqblock.innerRange.from).toBe(20) +// expect(exampleCoqblock.innerRange.to).toBe(30) +// expect(exampleCoqblock.prosemirrorStart).toBe(15) +// expect(exampleCoqblock.prosemirrorEnd).toBe(25) + +// // Input-area +// const inputAreaNode = nodes[3]; +// expect(inputAreaNode.type).toBe("input_area"); + +// // Nested coqblock inside input-area +// const nestedCoqblock = nodes[4]; +// expect(nestedCoqblock.type).toBe("code"); +// expect(nestedCoqblock.innerRange.from).toBe(55) +// expect(nestedCoqblock.innerRange.to).toBe(79) +// expect(nestedCoqblock.prosemirrorStart).toBe(28) +// expect(nestedCoqblock.prosemirrorEnd).toBe(52) +// }); + +// test("Empty coqblock", () => { +// const content = "```coq\n```"; +// const nodes = createTestMapping(content); + +// expect(nodes.length).toBe(2); + +// // Child coqcode (empty) +// const coqcodeNode = nodes[1]; +// expect(coqcodeNode.stringContent).toBe(""); +// }); \ No newline at end of file diff --git a/__tests__/nodeupdate.test.ts b/__tests__/nodeupdate.test.ts new file mode 100644 index 0000000..30d0ecc --- /dev/null +++ b/__tests__/nodeupdate.test.ts @@ -0,0 +1,5 @@ + + +test("empty", () => { + +}); \ No newline at end of file diff --git a/__tests__/statemachine.test.ts b/__tests__/statemachine.test.ts new file mode 100644 index 0000000..6c20b16 --- /dev/null +++ b/__tests__/statemachine.test.ts @@ -0,0 +1,108 @@ +/* eslint-disable no-useless-escape */ + +import { parse } from "../src/markdown-defaults"; +import { isMarkdownBlock, isCodeBlock, isHintBlock, isInputAreaBlock, isMathDisplayBlock, isNewlineBlock } from "../src/document/blocks"; +import { HintBlock } from "../src/document"; + +const exampleDocument = `# Sample Document + +Here is some introductory text. +\`\`\`python +def example_function(): + return "Hello, World!" +\`\`\` + +This is a hint block with some **markdown** content. + +\`\`\`python +# Nested code block inside hint +print("This is a nested code block") +\`\`\` +More hint text. +Some concluding text. +$$ +E = mc^2 +$$`; + +test("test", () => { + const blocks = parse(exampleDocument, "python"); + + expect(blocks.length).toBe(6); + const [b1, nl1, b2, nl2, b3, b4] = blocks; + expect(isMarkdownBlock(b1)).toBe(true); + expect(isNewlineBlock(nl1)).toBe(true); + expect(isCodeBlock(b2)).toBe(true); + expect(isNewlineBlock(nl2)).toBe(true); + expect(isHintBlock(b3)).toBe(true); + expect(isInputAreaBlock(b4)).toBe(true); + + expect(b1.range.from).toBe(0); + expect(b1.range.to).toBe(50); + expect(b1.innerRange.from).toBe(0); + expect(b1.innerRange.to).toBe(50); + expect(b1.stringContent).toBe(`# Sample Document + +Here is some introductory text.`); + + expect(b2.range.from).toBe(51); + expect(b2.range.to).toBe(115); + expect(b2.innerRange.from).toBe(61); + expect(b2.innerRange.to).toBe(111); + expect(b2.stringContent).toBe(`def example_function(): + return "Hello, World!"`); + + expect(b3.range.from).toBe(116); + expect(b3.range.to).toBe(306); + expect((b3 as HintBlock).title).toBe("Important Hint"); + expect(b3.innerRange.from).toBe(145); + expect(b3.innerRange.to).toBe(299); + + expect(b4.range.from).toBe(306); + expect(b4.range.to).toBe(367); + expect(b4.innerRange.from).toBe(318); + expect(b4.innerRange.to).toBe(354); + + expect(b3.innerBlocks?.length).toBe(5); + const [hIn1, hIn_nl1, hIn2, hIn_nl2, hIn3] = b3.innerBlocks!; + expect(isMarkdownBlock(hIn1)).toBe(true); + expect(isNewlineBlock(hIn_nl1)).toBe(true); + expect(isCodeBlock(hIn2)).toBe(true); + expect(isNewlineBlock(hIn_nl2)).toBe(true); + expect(isMarkdownBlock(hIn3)).toBe(true); + + expect(hIn1.stringContent).toBe(` +This is a hint block with some **markdown** content. +`); + expect(hIn1.range.from).toBe(145); + expect(hIn1.range.to).toBe(199); + expect(hIn1.innerRange.from).toBe(145); + expect(hIn1.innerRange.to).toBe(199); + + expect(hIn2.stringContent).toBe('# Nested code block inside hint\nprint("This is a nested code block")'); + expect(hIn2.range.from).toBe(200); + expect(hIn2.range.to).toBe(282); + expect(hIn2.innerRange.from).toBe(210); + expect(hIn2.innerRange.to).toBe(278); + + expect(hIn3.stringContent).toBe('More hint text.\n'); + expect(hIn3.range.from).toBe(283); + expect(hIn3.range.to).toBe(299); + expect(hIn3.innerRange.from).toBe(283); + expect(hIn3.innerRange.to).toBe(299); + + const [iIn1, iIn2] = b4.innerBlocks!; + expect(isMarkdownBlock(iIn1)).toBe(true); + expect(isMathDisplayBlock(iIn2)).toBe(true); + + expect(iIn1.stringContent).toBe('Some concluding text.\n'); + expect(iIn1.range.from).toBe(318); + expect(iIn1.range.to).toBe(340); + expect(iIn1.innerRange.from).toBe(318); + expect(iIn1.innerRange.to).toBe(340); + + expect(iIn2.stringContent).toBe("\nE = mc^2\n"); + expect(iIn2.range.from).toBe(340); + expect(iIn2.range.to).toBe(354); + expect(iIn2.innerRange.from).toBe(342); + expect(iIn2.innerRange.to).toBe(352); +}); \ No newline at end of file diff --git a/__tests__/textupdate.test.ts b/__tests__/textupdate.test.ts new file mode 100644 index 0000000..85e4598 --- /dev/null +++ b/__tests__/textupdate.test.ts @@ -0,0 +1,110 @@ +import { Slice, Fragment } from "prosemirror-model"; +import { ReplaceStep } from "prosemirror-transform"; +import { DocChange, DocumentSerializer } from "../src/api"; +import { Mapping } from "../src/mapping"; +import { TextUpdate } from "../src/mapping/textUpdate"; +import { configuration, parse } from "../src/markdown-defaults"; +import { WaterproofSchema } from "../src/schema"; + + + +function createDocAndMapping(doc: string) { + const blocks = parse(doc, "coq"); + const mapping = new Mapping(blocks, 0, configuration("coq"), new DocumentSerializer(configuration("coq"))); + return mapping; +} + +test("ReplaceStep insert — inserts text into a block", () => { + const content = `Hello`; + const mapping = createDocAndMapping(content); + const slice: Slice = new Slice(Fragment.from(WaterproofSchema.text(" world")), 0, 0); + const step: ReplaceStep = new ReplaceStep(6, 6, slice); + console.log("here is the step", step); + const textUpdate = new TextUpdate(); + const {newTree, result} = textUpdate.textUpdate(step, mapping); + + const md = newTree.root.children[0]; + expect(md.innerRange.from).toBe(0); + expect(md.innerRange.to).toBe(11); + expect(md.range.from).toBe(0); + expect(md.range.to).toBe(11); + expect(md.prosemirrorStart).toBe(1); + expect(md.prosemirrorEnd).toBe(12); + + expect(result).toStrictEqual({ + finalText: " world", + startInFile: 5, + endInFile: 5 + }); +}); + +test("ReplaceStep insert — inserts text in the middle of a block", () => { + const content = "Hello world"; + const mapping = createDocAndMapping(content); + const slice: Slice = new Slice(Fragment.from(WaterproofSchema.text("big ")), 0, 0); + const step: ReplaceStep = new ReplaceStep(7, 7, slice); + const textUpdate = new TextUpdate(); + const {newTree, result} = textUpdate.textUpdate(step, mapping); + + const md = newTree.root.children[0]; + + expect(md.innerRange.from).toBe(0); + expect(md.innerRange.to).toBe(15); + expect(md.range.from).toBe(0); + expect(md.range.to).toBe(15); + expect(md.prosemirrorStart).toBe(1); + expect(md.prosemirrorEnd).toBe(16); + + expect(result).toStrictEqual({ + finalText: "big ", + startInFile: 6, + endInFile: 6 + }); +}); + +test("ReplaceStep delete — deletes part of a block", () => { + const content = `Hello world`; + const mapping = createDocAndMapping(content); + const step: ReplaceStep = new ReplaceStep(7, 12, Slice.empty); + const textUpdate = new TextUpdate(); + const {newTree, result} = textUpdate.textUpdate(step, mapping); + + const md = newTree.root.children[0]; + expect(md.innerRange.from).toBe(0); + expect(md.innerRange.to).toBe(6); + expect(md.range.from).toBe(0); + expect(md.range.to).toBe(6); + expect(md.prosemirrorStart).toBe(1); + expect(md.prosemirrorEnd).toBe(7); + + expect(result).toStrictEqual({ + finalText: "", + startInFile: 6, + endInFile: 11 + }) +}); + + +test("ReplaceStep replace - replaces part of a block", () => { + const originalContent = "Hello world"; + const mapping = createDocAndMapping(originalContent); + const slice: Slice = new Slice(Fragment.from(WaterproofSchema.text("there")), 0, 0); + const step: ReplaceStep = new ReplaceStep(7, 12, slice); + const textUpdate = new TextUpdate(); + const {newTree, result} = textUpdate.textUpdate(step, mapping); + + const md = newTree.root.children[0]; + expect(md.innerRange.from).toBe(0); + expect(md.innerRange.to).toBe(11); + expect(md.range.from).toBe(0); + expect(md.range.to).toBe(11); + expect(md.prosemirrorStart).toBe(1); + expect(md.prosemirrorEnd).toBe(12); + + // Check that the resulting document change has the correct type (is a DocChange) and has the correct properties. + expect(result).toStrictEqual({ + finalText: "there", + startInFile: 6, + endInFile: 11 + }); +}); \ No newline at end of file diff --git a/__tests__/tree.test.ts b/__tests__/tree.test.ts new file mode 100644 index 0000000..a663c96 --- /dev/null +++ b/__tests__/tree.test.ts @@ -0,0 +1,16 @@ +import { TreeNode } from "../src/mapping"; + +const type = ""; +const range = {from: 0, to: 0}; +const title = ""; + +test("nodesInRange", () => { + // const nodes: TreeNode[] = [ + // new TreeNode(type, range, range, title, ) + // ] + // const tree = new Tree(nodes); + // expect(tree.nodes.length).toBe(3); + // expect(tree.nodes[0].from).toBe(1); + // expect(tree.nodes[1].from).toBe(2); + // expect(tree.nodes[2].from).toBe(3); +}); \ No newline at end of file diff --git a/documentation/UsingWaterproofEditor.md b/documentation/UsingWaterproofEditor.md index 0dfcbdc..ed9b73f 100644 --- a/documentation/UsingWaterproofEditor.md +++ b/documentation/UsingWaterproofEditor.md @@ -32,10 +32,6 @@ NewlineBlock ::= A block that keeps track of signifcant newlines The schema `WaterproofSchema` defined in [`src/schema/schema.ts`](../src/schema/schema.ts) follows from the above grammar. -### WaterproofMapping - -The `WaterproofMapping` that is constructed is responsible for translating [ProseMirror positions](https://prosemirror.net/docs/guide/#doc.indexing) into an offset position into the document string. - ### WaterproofEditorConfig The `WaterproofEditorConfig` object is used to configure an `WaterproofEditor` instance. The user is required to supply: diff --git a/src/api/index.ts b/src/api/index.ts index 859f64c..496c4ea 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -13,9 +13,9 @@ export * from "./types"; export { WaterproofCompletion, WaterproofSymbol } from "./Completions"; export { Completion } from "@codemirror/autocomplete"; -export { Step, ReplaceStep, ReplaceAroundStep } from "prosemirror-transform"; -export { Fragment, Node, Slice } from "prosemirror-model"; export { ServerStatus, Idle, Busy } from "./ServerStatus"; -export { DocumentSerializer } from "../serialization/DocumentSerializer"; \ No newline at end of file +export { DocumentSerializer } from "../serialization/DocumentSerializer"; + +export { Mapping } from "../mapping"; \ No newline at end of file diff --git a/src/api/types.ts b/src/api/types.ts index 658477a..a62e205 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -1,6 +1,7 @@ -import { Step } from "prosemirror-transform"; -import { DocChange, WrappingDocChange, Severity, WaterproofCompletion, WaterproofSymbol, Node, DocumentSerializer } from "."; import { Block } from "../document"; +import { WaterproofCompletion, WaterproofSymbol } from "./Completions"; +import { DocChange, WrappingDocChange } from "./DocChange"; +import { Severity } from "./Severity"; export type Positioned
= { obj: A; @@ -38,13 +39,6 @@ export type WaterproofCallbacks = { viewportHint: (start: number, end: number) => void, } -export abstract class WaterproofMapping { - abstract get version(): number; - abstract findPosition: (index: number) => number; - abstract findInvPosition: (index: number) => number; - abstract update: (step: Step, doc: Node) => DocChange | WrappingDocChange; -} - /** * Type describing the open and close tag for a cell. */ @@ -112,9 +106,7 @@ export type WaterproofEditorConfig = { api: WaterproofCallbacks, /** Determines how the editor document gets constructed from a string input. */ documentConstructor: (document: string) => WaterproofDocument, - /** How to construct a mapping for this editor. The mapping is responsible for mapping changes from the underlying ProseMirror instance into changes that can be applied to the underlying document. */ - mapping: new (inputDocument: WaterproofDocument, versionNum: number, tagMap: TagConfiguration, serializer: DocumentSerializer) => WaterproofMapping, - + /** * The tag configuration to use for this editor. * diff --git a/src/document/blocks/index.ts b/src/document/blocks/index.ts index 36e496e..c167ef8 100644 --- a/src/document/blocks/index.ts +++ b/src/document/blocks/index.ts @@ -1,3 +1,4 @@ export { BlockRange, Block } from "./block"; -export * from "./blocktypes"; \ No newline at end of file +export * from "./blocktypes"; +export * from "./typeguards"; \ No newline at end of file diff --git a/src/editor.ts b/src/editor.ts index 4a78c91..3f486ff 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -27,13 +27,14 @@ import "./styles"; import { UPDATE_STATUS_PLUGIN_KEY, updateStatusPlugin } from "./qedStatus"; import { CodeBlockView } from "./codeview/nodeview"; import { OS } from "./osType"; -import { Positioned, WaterproofMapping, WaterproofEditorConfig, DiagnosticMessage, ThemeStyle } from "./api"; +import { Positioned, WaterproofEditorConfig, DiagnosticMessage, ThemeStyle } from "./api"; import { Completion } from "@codemirror/autocomplete"; import { setCurrentTheme } from "./themeStore"; import { ServerStatus } from "./api"; import { getCmdInsertCode, getCmdInsertLatex, getCmdInsertMarkdown } from "./commands/insert-command"; import { InsertionPlace } from "./commands"; import { deleteSelection } from "./commands/commands"; +import { Mapping } from "./mapping"; //@ts-expect-error Defined by esbuild. const debugMode = DEBUG; @@ -60,7 +61,7 @@ export class WaterproofEditor { private _view: EditorView | undefined; // The file document mapping - private _mapping: WaterproofMapping | undefined; + private _mapping: Mapping | undefined; // User operating system. private readonly _userOS; @@ -130,7 +131,7 @@ export class WaterproofEditor { const blocks = this._editorConfig.documentConstructor(content); const proseDoc = constructDocument(blocks); - this._mapping = new this._editorConfig.mapping(blocks, version, this._editorConfig.tagConfiguration, this._serializer); + this._mapping = new Mapping(blocks, version, this._editorConfig.tagConfiguration, this._serializer); this.createProseMirrorEditor(proseDoc); /** Ask for line numbers */ diff --git a/src/mapping/Tree.ts b/src/mapping/Tree.ts new file mode 100644 index 0000000..d50aa40 --- /dev/null +++ b/src/mapping/Tree.ts @@ -0,0 +1,198 @@ +export class TreeNode { + /** The type of this node, should be in the WaterproofSchema schema */ + type: string; + /** The inner range of the node, that is, the range of the content */ + innerRange: {to: number, from: number}; + /** The outer range of the node, that is, the range of the content including possible tags */ + range: {to: number, from: number}; + /** The title of a node, only relevant for hint nodes */ + title: string; + /** The computed start position in ProseMirror, this is the prosemirror position at which the content starts. + * Thus, for nodes with content this includes a +1 due to stepping in to the node. + * For newlines, there is no content, so the start points directly before the newline. + */ + prosemirrorStart: number; + /** The computed end position in ProseMirror */ + prosemirrorEnd: number; + pmRange: {from: number, to: number}; + /** Potential children of this tree node */ + children: TreeNode[]; + + constructor( + type: string, + innerRange: {to: number, from: number}, + range: {to: number, from: number}, + title: string, + prosemirrorStart: number, + prosemirrorEnd: number, + pmRange: {to: number, from: number}, + ) { + this.type = type; + this.innerRange = innerRange; + this.range = range; + this.title = title; + this.prosemirrorStart = prosemirrorStart; + this.prosemirrorEnd = prosemirrorEnd; + this.pmRange= pmRange; + this.children = []; + } + + addChild(child: TreeNode): void { + this.children.push(child); + // Sort children by originalStart to maintain order + this.children.sort((a, b) => a.innerRange.from - b.innerRange.from); + } + + removeChild(child: TreeNode): void { + this.children = this.children.filter(c => c != child); + } + + shiftCloseOffsets(offset: number, offsetProsemirror?: number): void { + this.prosemirrorEnd += offsetProsemirror !== undefined ? offsetProsemirror : offset; + this.pmRange.to += offsetProsemirror !== undefined ? offsetProsemirror : offset; + this.innerRange.to += offset; + this.range.to += offset; + } + + shiftOffsets(offset: number, offsetProsemirror?: number): void { + this.prosemirrorStart += offsetProsemirror !== undefined ? offsetProsemirror : offset; + this.prosemirrorEnd += offsetProsemirror !== undefined ? offsetProsemirror : offset; + this.pmRange.from += offsetProsemirror !== undefined ? offsetProsemirror : offset; + this.pmRange.to += offsetProsemirror !== undefined ? offsetProsemirror : offset; + this.innerRange.from += offset; + this.innerRange.to += offset; + this.range.from += offset; + this.range.to += offset; + } + + traverseDepthFirst(callback: (node: TreeNode) => void): void { + callback(this); + this.children.forEach(child => child.traverseDepthFirst(callback)); + } +} + +export class Tree { + root: TreeNode; + + constructor( + type: string = "", + innerRange: {from: number, to: number} = {from: 0, to: 0}, + range: {from: number, to: number} = {from: 0, to: 0}, + title: string = "", + prosemirrorStart: number = 0, + prosemirrorEnd: number = 0, + pmRange: {from: number, to: number} = {from: 0, to: 0} + ) { + this.root = new TreeNode(type, innerRange, range, title, prosemirrorStart, prosemirrorEnd, pmRange); + } + + traverseDepthFirst(callback: (node: TreeNode) => void, node: TreeNode = this.root): void { + callback(node); + node.children.forEach(child => this.traverseDepthFirst(callback, child)); + } + + traverseBreadthFirst(callback: (node: TreeNode) => void): void { + const queue: TreeNode[] = [this.root]; + while (queue.length > 0) { + const node = queue.shift(); + if (node) { + callback(node); + queue.push(...node.children); + } + } + } + + // Finds the highest (closest to root) node that contains the given prosemirror position + findHighestContainingNode(pos: number, node: TreeNode = this.root): TreeNode { + if (pos < node.prosemirrorStart || pos > node.prosemirrorEnd) { + throw new Error("Position out of bounds"); + } + for (const child of node.children) { + if (pos >= child.prosemirrorStart && pos <= child.prosemirrorEnd) { + return this.findHighestContainingNode(pos, child); + } + } + return node; + } + + + findParent(target: TreeNode, node: TreeNode | null = this.root, parent: TreeNode | null = null): TreeNode | null { + if (!node) return null; + if (node === target) return parent; + for (const child of node.children) { + const result = this.findParent(target, child, node); + if (result) return result; + } + return null; + } + + findNodeByOriginalPosition(pos: number, node: TreeNode | null = this.root): TreeNode | null { + if (!node) return null; + if (pos >= node.innerRange.from && pos <= node.innerRange.to) { + for (const child of node.children) { + const result = this.findNodeByOriginalPosition(pos, child); + if (result) return result; + } + return node; + } + return null; + } + + findNodeByProsemirrorPosition(pos: number, node: TreeNode | null = this.root): TreeNode | null { + if (!node) return null; + if (pos >= node.prosemirrorStart && pos <= node.prosemirrorEnd) { + for (const child of node.children) { + const result = this.findNodeByProsemirrorPosition(pos, child); + if (result) return result; + } + return node; + } + return null; + } + + findNodeByProsePos(pos: number, node: TreeNode | null = this.root): TreeNode | null { + if (!node) return null; + if (pos < node.pmRange.from || pos > node.pmRange.to) return null; + + // Binary search among children + let left = 0; + let right = node.children.length - 1; + while (left <= right) { + const mid = Math.floor((left + right) / 2); + const child = node.children[mid]; + if (pos > child.pmRange.from && pos < child.pmRange.to) { + return this.findNodeByProsePos(pos, child); + } else if (pos <= child.pmRange.to) { + right = mid - 1; + } else if (pos > child.pmRange.to) { + left = mid + 1; + } + } + // If no child contains pos, return current node + return node; + } + + nodesInProseRange(from: number, to: number, node: TreeNode | null = this.root): TreeNode[] { + const result: TreeNode[] = []; + if (!node) return result; + if (node.pmRange.to < from || node.pmRange.from > to) return result; + if (node.pmRange.from >= from && node.pmRange.to <= to) { + result.push(node); + } + result.push(...node.children.flatMap(child => this.nodesInProseRange(from, to, child))); + return result; + } + + insertByPosition(newNode: TreeNode): boolean { + if (!this.root) return false; + + for (const rootNode of this.root.children) { + if (newNode.innerRange.from >= rootNode.innerRange.from && newNode.innerRange.to <= rootNode.innerRange.to) { + rootNode.addChild(newNode); + return true; + } + } + this.root.addChild(newNode); + return true; + } +} diff --git a/src/mapping/helper-functions.ts b/src/mapping/helper-functions.ts new file mode 100644 index 0000000..f09c6cf --- /dev/null +++ b/src/mapping/helper-functions.ts @@ -0,0 +1,12 @@ +import { ReplaceAroundStep, ReplaceStep } from "prosemirror-transform"; +import { OperationType } from "./types"; + + +export function typeFromStep(step: ReplaceStep | ReplaceAroundStep): OperationType { + if (step.from == step.to) return OperationType.insert; + if (step.slice.content.firstChild == null) { + return OperationType.delete; + } else { + return OperationType.replace; + } +} \ No newline at end of file diff --git a/src/mapping/index.ts b/src/mapping/index.ts new file mode 100644 index 0000000..f1a02f9 --- /dev/null +++ b/src/mapping/index.ts @@ -0,0 +1,4 @@ +// Export the mapping +export { Mapping } from "./newmapping"; +export { Tree } from "./Tree"; +export { TreeNode } from "./Tree"; \ No newline at end of file diff --git a/src/mapping/newmapping.ts b/src/mapping/newmapping.ts new file mode 100644 index 0000000..5d384db --- /dev/null +++ b/src/mapping/newmapping.ts @@ -0,0 +1,243 @@ +import { Tree, TreeNode } from "./Tree"; +import { TextUpdate } from "./textUpdate"; +import { NodeUpdate } from "./nodeUpdate"; +import { ParsedStep } from "./types"; +import { Block, typeguards } from "../document"; +import { DocChange, DocumentSerializer, MappingError, TagConfiguration, WrappingDocChange } from "../api"; +import { WaterproofSchema } from "../schema"; +import { Node } from "prosemirror-model"; +import { ReplaceAroundStep, ReplaceStep, Step } from "prosemirror-transform"; + +/** + * This class is responsible for keeping track of the mapping between the prosemirror state and the vscode Text + * Document model + */ +export class Mapping { + /** This stores the String cells of the entire document */ + private tree: Tree; + /** The version of the underlying textDocument */ + private _version: number; + + private readonly nodeUpdate: NodeUpdate; + private readonly textUpdate: TextUpdate; + + /** + * Constructs a prosemirror view vscode mapping for the inputted prosemirror html element + * + * @param inputBlocks a string containing the prosemirror content html element + */ + constructor(inputBlocks: Block[], versionNum: number, tMap: TagConfiguration, serializer: DocumentSerializer) { + this.textUpdate = new TextUpdate(); + this.nodeUpdate = new NodeUpdate(tMap, serializer); + this._version = versionNum; + this.tree = new Tree(); + this.initTree(inputBlocks); + // console.log(inputBlocks); + console.log("MAPPED TREE", JSON.stringify(this.tree)); + } + + //// The getters of this class + + /** + * Returns the mapping to preserve integrity + */ + public getMapping() { + return this.tree; + } + + /** + * Get the version of the underlying text document + */ + public get version() { + return this._version; + } + + /** Returns the vscode document model index of prosemirror index */ + public findPosition(index: number) { + const node = this.tree.findNodeByProsePos(index); + if (node === null) throw new MappingError(` [findPosition] The vscode document offset for prosemirror index (${index}) could not be found `); + return (index - node.prosemirrorStart) + node.innerRange.from; + } + + /** Returns the prosemirror index of vscode document model index */ + public findInvPosition(index: number) { + const correctNode: TreeNode | null = this.tree.findNodeByOriginalPosition(index); + if (correctNode === null) throw new MappingError(` [findInvPosition] The prosemirror index for position (${index}) could not be found `); + return (index - correctNode.innerRange.from) + correctNode.prosemirrorStart; + } + + private inStringCell(step: ReplaceStep | ReplaceAroundStep): boolean { + const correctNode: TreeNode | null = this.tree.findNodeByProsemirrorPosition(step.from); + return correctNode !== null && step.to <= correctNode.prosemirrorEnd; + } + + public update(step: Step, doc: Node): DocChange | WrappingDocChange { + console.log("STEP IN UPDATE", step) + if (!(step instanceof ReplaceStep || step instanceof ReplaceAroundStep)) + throw new MappingError("Step update (in textDocMapping) should not be called with a non document changing step"); + + /** Check whether the edit is a text edit */ + let isText: boolean; + if (step.slice.content.firstChild?.type === WaterproofSchema.nodes.text) { + // Short circuit when the content is a text node. This is the case for simple text insertions + // 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; + isText = (step.slice.content.childCount === 0 && + (parentNodeType === WaterproofSchema.nodes.markdown || + parentNodeType === WaterproofSchema.nodes.code || + parentNodeType === WaterproofSchema.nodes.math_display)); + } + + let result: ParsedStep; + + /** Parse the step into a text document change */ + if (step instanceof ReplaceStep && isText) result = this.textUpdate.textUpdate(step, this); + else result = this.nodeUpdate.nodeUpdate(step, this); + + this.tree = result.newTree + + if ('finalText' in result.result) { + if (this.checkDocChange(result.result)) this._version++; + } else { + if (this.checkDocChange(result.result.firstEdit) || this.checkDocChange(result.result.secondEdit)) this._version++; + } + + return result.result; + } + + /** + * This checks if the doc change actually changed the document, since vscode + * does not register empty changes + */ + private checkDocChange(change: DocChange): boolean { + if (change.endInFile === change.startInFile && change.finalText.length == 0) return false; + return true; + } + + //// The methods used to manage the mapping + + + /** + * Initializes the mapping given the input document in the form of a Block array. + * @param blocks + */ + private initTree(blocks: Block[]): void { + // Create a root node with dummy values + + const root = new TreeNode( + "", // type + { from: 0, to: blocks.at(-1)!.range.to }, // innerRange + { from: 0, to: blocks.at(-1)!.range.to }, // range + "", // title + 0, // prosemirrorStart + 0, // prosemirrorEnd + { from: 0, to: 0 } + ); + + function buildSubtree(blocks: Block[]): TreeNode[] { + return blocks.map(block => { + + const title = typeguards.isHintBlock(block) ? block.title : ""; + + const node = new TreeNode( + block.type, + block.innerRange, + block.range, + title, + 0, // prosemirrorStart (to be calculated later) + 0, // prosemirrorEnd (to be calculated later) + {from: 0, to: 0}, // full prosemirror range (to be computed later) + ); + + if (block.innerBlocks && block.innerBlocks.length > 0) { + const children = buildSubtree(block.innerBlocks); + children.forEach(child => node.addChild(child)); + } + + return node; + }); + } + + const topLevelNodes = buildSubtree(blocks); + topLevelNodes.forEach(child => root.addChild(child)); + + // Set the tree root after mapping + this.tree.root = root; + // console.log(this.tree); + // Now compute the ProseMirror offsets after creating the tree structure + this.computeProsemirrorOffsets(this.tree.root); + } + + /** + * Recursively computes the prosemirrorStart and prosemirrorEnd offsets for each node. + * + * @param node The current node to compute the offsets for. + * @param startTagMap The start tag mapping for each block type. + * @param endTagMap The end tag mapping for each block type. + * @param currentOffset The current offset from where the computation should begin. + * @param level The current depth level in the tree (used for adjusting offsets). + * @returns The updated offset after computing the current node. + */ + private computeProsemirrorOffsets( + node: TreeNode | null, + currentOffset: number = 0, + level: number = 0 + ): number { + // INVARIANT: + // At the start of this function `offset` points exactly before the tag of `node` and at the end of the function `offset` points right after the tag. + // That is, if we are processing some document that looks like this: Test where the and denote the boundaries of the markdown node. + // We ensure that at the start of processing this node `offset` is at the position marked with A and at the end of the function `offset` is at + // the position marked with B. The prosemirror start and end of the markdown are at C and D, respectively: ACTestDB. + + if (!node) return currentOffset; + + let offset = currentOffset; + + // We handle the newline separately as this node has a size of just 1. + if (node.type === "newline") { + node.prosemirrorStart = offset; + node.prosemirrorEnd = offset; + node.pmRange.from = offset; + node.pmRange.to = offset + 1; + return offset + 1; + // return offset; + } + + node.pmRange.from = offset; + + if (node !== this.tree.root) { + // Add start tag and +1 for going one level deeper (entering the node) + offset += 1; + } + + // Record the ProseMirror start after entering this node + node.prosemirrorStart = offset; + + if (node.children.length === 0) { + // Leaf: add length of content + end tag + +1 for exiting level + offset += (node.innerRange.to - node.innerRange.from); + } else { + // Non-leaf: handle children and end tag + for (let i = 0; i < node.children.length; i++) { + offset = this.computeProsemirrorOffsets( + node.children[i], + offset, + level + 1 + ); + } + } + + // Record the ProseMirror end offset after all child nodes have been processed. + node.prosemirrorEnd = offset; + // To satisfy the invariant we add one to the offset to move outside of the current node again. + offset += 1; + node.pmRange.to = offset; + return offset; + } + +} diff --git a/src/mapping/nodeUpdate.ts b/src/mapping/nodeUpdate.ts new file mode 100644 index 0000000..94a8256 --- /dev/null +++ b/src/mapping/nodeUpdate.ts @@ -0,0 +1,475 @@ +import { Tree, TreeNode } from "./Tree"; +import { OperationType, ParsedStep } from "./types"; +import { Mapping } from "./newmapping"; +import { typeFromStep } from "./helper-functions"; +import { DocChange, DocumentSerializer, NodeUpdateError, TagConfiguration, WrappingDocChange } from "../api"; +import { WaterproofSchema } from "../schema"; +import { Node } from "prosemirror-model"; +import { ReplaceAroundStep, ReplaceStep } from "prosemirror-transform"; + +export class NodeUpdate { + // Store the tag configuration and serializer + constructor (private tagConf: TagConfiguration, private serializer: DocumentSerializer) {} + + // Utility to get the opening and closing tag for a given node type + nodeNameToTagPair(nodeName: string, title: string = ""): [string, string] { + switch (nodeName) { + case "markdown": + return [this.tagConf.markdown.openTag, this.tagConf.markdown.closeTag]; + case "code": + return [this.tagConf.code.openTag, this.tagConf.code.closeTag]; + case "hint": + return [this.tagConf.hint.openTag(title), this.tagConf.hint.closeTag]; + case "input": + return [this.tagConf.input.openTag, this.tagConf.input.closeTag]; + case "math_display": + return [this.tagConf.math.openTag, this.tagConf.math.closeTag]; + default: + throw new NodeUpdateError(`Unsupported node type: ${nodeName}`); + } + } + + // Handle a node update step + public nodeUpdate(step: ReplaceStep | ReplaceAroundStep, mapping: Mapping) : ParsedStep { + console.log("IN NODE UPDATE", step, mapping.getMapping()); + + let parsedStep; + if (step instanceof ReplaceStep) { + // The step is a ReplaceStep + parsedStep = this.doReplaceStep(step, mapping); + } else { + // The step is a ReplaceAroundStep (wrapping or unwrapping of nodes) + parsedStep = this.doReplaceAroundStep(step, mapping); + } + + console.log("TREEEE", JSON.stringify(parsedStep.newTree)); + return parsedStep; + } + + doReplaceStep(step: ReplaceStep, mapping: Mapping): 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()); + case OperationType.delete: + return this.replaceDelete(step, mapping.getMapping()); + 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) "); + } + } + + doReplaceAroundStep(step: ReplaceAroundStep, mapping: Mapping): ParsedStep { + // Determine operation type + const type = typeFromStep(step); + switch (type) { + case OperationType.insert: + throw new NodeUpdateError(" ReplaceAroundSteps with 'insert' operation type are not supported "); + case OperationType.delete: + // Delete when we are removing the tags around a node. + return this.replaceAroundDelete(step, mapping.getMapping()); + case OperationType.replace: + // Replace when we are adding tags around a node + return this.replaceAroundReplace(step, mapping.getMapping()); + } + } + + // 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 { + // 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 "); + } + + // We find the node in the tree that is at the position where we are inserting + const nodeInTree = tree.findNodeByProsePos(step.from); + if (!nodeInTree) throw new NodeUpdateError(" Could not find position to insert node in mapping "); + const parent = tree.findParent(nodeInTree); + if (!parent) throw new NodeUpdateError(" Could not find parent of insertion position in mapping "); + + + console.log("nodeInTree", JSON.stringify(nodeInTree)); + + const documentPos = nodeInTree.range.to; + + let offsetProse = nodeInTree.pmRange.to; + let offsetOriginal = nodeInTree.range.to; + + const nodes: TreeNode[] = []; + let serialized = ""; + step.slice.content.forEach(node => { + const output = this.serializer.serializeNode(node); + // console.log("OUTPUT", output); + console.log("node", node.type.name); + console.log("output", output); + serialized += output; + const builtNode = this.buildTreeFromNode(node, offsetOriginal, offsetProse); + nodes.push(builtNode); + offsetOriginal += output.length; + offsetProse += node.nodeSize; + }); + + console.log("SERIALIZED BY TEXT SERIALIZE\n", serialized); + console.log("NODES BY BUILD TREE\n", nodes); + + // throw new NodeUpdateError(" Insert not supported yet "); + + const docChange: DocChange = { + startInFile: documentPos, + endInFile: documentPos, + finalText: serialized + }; + + const proseOffset = step.slice.content.size; + const textOffset = serialized.length; + + // 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 >= nodeInTree.pmRange.to) { + thisNode.shiftOffsets(textOffset, proseOffset); + } + + // 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 < nodeInTree.pmRange.from && thisNode.pmRange.to > nodeInTree.pmRange.to) { + thisNode.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 }; + } + + // replaceInsert(step: ReplaceStep, tree: Tree): ParsedStep { + // const firstNode = step.slice.content.firstChild; + // if (!firstNode) throw new NodeUpdateError(" No nodes in slice content "); + + // // TODO: The plus 1 does not work when the insert is at the end of some block + // console.log("BLABLABLA", tree.findHighestContainingNode(step.from)); + // const nodeInTree = tree.findNodeByProsemirrorPosition(step.from + 1); + // console.log("nodeInTree", JSON.stringify(nodeInTree)); + // if (!nodeInTree) throw new NodeUpdateError(" Could not find position to insert node in mapping "); + // const parent = tree.findParent(nodeInTree); + // if (!parent) throw new NodeUpdateError(" Could not find parent of insertion position in mapping "); + + + // // let offsetOriginal = nodeInTree.range.to; + // let offsetProse = nodeInTree.prosemirrorEnd; + // let offsetOriginal = step.from; + // console.log("OffsetProse", offsetProse, "OffsetOriginal", offsetOriginal, "Step.from", step.from, "Step.to", step.to); + // const nodes: TreeNode[] = []; + // let serialized = ""; + // step.slice.content.forEach(node => { + // const output = this.serializer.serializeNode(node); + // // console.log("OUTPUT", output); + // console.log("node", node.type.name); + // console.log("output", output); + // serialized += output; + // const builtNode = this.buildTreeFromNode(node, offsetOriginal, offsetProse); + // nodes.push(builtNode); + // offsetOriginal += output.length; + // offsetProse += node.nodeSize + (builtNode.innerRange.to - builtNode.innerRange.from); + // }); + // console.log("SERIALIZED BY TEXT SERIALIZE\n", serialized); + // console.log("NODES BY BUILD TREE\n", nodes); + + // const docChange: DocChange = { + // startInFile: nodeInTree.range.to, + // endInFile: nodeInTree.range.to, + // finalText: serialized + // }; + + // const proseOffset = step.slice.content.size; + // const textOffset = serialized.length; + + // // now we need to update the tree + // tree.traverseDepthFirst((thisNode: TreeNode) => { + // if (thisNode.prosemirrorStart >= nodeInTree.prosemirrorEnd) { + // thisNode.shiftOffsets(textOffset, proseOffset); + // } + // }); + + // // We add the nodes 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)); + + // tree.root.shiftCloseOffsets(textOffset, proseOffset); + + // return { result: docChange, newTree: tree }; + // } + + + + buildTreeFromNode(node: Node, startOrig: number, startProse: number): TreeNode { + + // Shortcut for newline nodes + if (node.type == WaterproofSchema.nodes.newline) { + return new TreeNode( + "newline", + {from: startOrig, to: startOrig + 1}, + {from: startOrig, to: startOrig + 1}, + "", + startProse, startProse, + {from: startProse, to: startProse + node.nodeSize} + ); + } + + const [openTagForNode, closeTagForNode] = this.nodeNameToTagPair(node.type.name, node.attrs.title ? node.attrs.title : ""); + + 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 + startProse + 1, 0, // prosemirror start, end + {from: startProse, to: 0} + ); + + + let childOffsetOriginal = startOrig + openTagForNode.length; + let childOffsetProse = startProse + 1; // +1 for the opening tag + + node.forEach(child => { + const childTreeNode = this.buildTreeFromNode(child, childOffsetOriginal, childOffsetProse); + treeNode.children.push(childTreeNode); + + // Update the offsets for the next child + const serializedChild = this.serializer.serializeNode(child); + childOffsetOriginal += serializedChild.length; + childOffsetProse += child.nodeSize; + }); + + // Now fill in the to positions for innerRange and range + treeNode.innerRange.to = childOffsetOriginal; + treeNode.range.to = childOffsetOriginal + closeTagForNode.length; + treeNode.prosemirrorEnd = childOffsetProse; + treeNode.pmRange.to = childOffsetProse + 1; + return treeNode; + } + + /** + * Handles ReplaceSteps that delete content. + * @param step The ReplaceStep for which we determined that it is deletion of one or more nodes. + * @param tree The input tree + * @returns A ParsedStep containing the resulting DocChange and the updated tree. + */ + replaceDelete(step: ReplaceStep, tree: Tree): ParsedStep { + // Find all nodes that are fully in the deleted range + const nodesToDelete: TreeNode[] = []; + let from = Number.POSITIVE_INFINITY; + let to = Number.NEGATIVE_INFINITY; + tree.traverseDepthFirst((node: TreeNode) => { + if (node.prosemirrorStart >= step.from && node.prosemirrorEnd <= step.to) { + nodesToDelete.push(node); + + if (node.range.from < from) from = node.range.from; + if (node.range.to > to) to = node.range.to; + + // Remove from the tree immediately (saves an O(n) traversal over nodesToDelete later) + const parent = tree.findParent(node); + if (parent) { + parent.removeChild(node); + } + } + }); + + console.log("NODES TO DELETE", nodesToDelete); + + if (nodesToDelete.length == 0) { + throw new NodeUpdateError("Could not find any nodes to delete in the given step."); + } + + // Create the docChange, the range to remove is from the start of the first node to the end of the last node + const docChange: DocChange = { + startInFile: from, + endInFile: to, + finalText: "" + }; + + // The length of text removed from the original document + const originalRemovedLength = docChange.endInFile - docChange.startInFile; + // The total length (as prosemirror indexing) of the nodes removed + const proseRemovedLength = step.to - step.from; + + // Update positions of nodes after the deleted nodes + tree.traverseDepthFirst((thisNode: TreeNode) => { + // only shift nodes that come after the deleted nodes + if (thisNode.prosemirrorStart >= step.to) { + thisNode.shiftOffsets(-originalRemovedLength, -proseRemovedLength); + } + }); + tree.root.shiftCloseOffsets(-originalRemovedLength, -proseRemovedLength); + + return { result: docChange, newTree: tree }; + } + + // ReplaceAroundDelete is used when we unwrap nodes (remove the hint or input tags) + replaceAroundDelete(step: ReplaceAroundStep, tree: Tree): ParsedStep { + const firstNodeBeingUnwrapped = tree.findNodeByProsePos(step.gapFrom); + const lastNodeBeingUnwrapped = tree.findNodeByProsePos(step.gapTo); + if (!firstNodeBeingUnwrapped || !lastNodeBeingUnwrapped) { + throw new NodeUpdateError(" Could not find first or last node to unwrap in mapping "); + } + + // Get all nodes in the range (these are the nodes that will be unwrapped) + const nodesInRange = tree.nodesInProseRange(firstNodeBeingUnwrapped.pmRange.from, lastNodeBeingUnwrapped.pmRange.to); + + // The wrapperNode should be the parent of the nodes being unwrapped + const wrapperNode = tree.findParent(firstNodeBeingUnwrapped); + if (!wrapperNode) throw new NodeUpdateError(" Could not find parent of nodes being unwrapped "); + + const [wrappedOpenTag, wrappedCloseTag] = this.nodeNameToTagPair(wrapperNode.type, wrapperNode.title); + + // We remove the wrapper node from the tree + const wrapperParent = tree.findParent(wrapperNode); + if (!wrapperParent) throw new NodeUpdateError(" Could not find parent of wrapper node "); + wrapperParent.removeChild(wrapperNode); + + // Create document change + const docChange: WrappingDocChange = { + firstEdit: { + startInFile: wrapperNode.range.from, + endInFile: wrapperNode.innerRange.from, + finalText: "" + }, + secondEdit: { + startInFile: wrapperNode.innerRange.to, + endInFile: wrapperNode.range.to, + finalText: "" + } + }; + + // First we update all nodes that come totally after the unwrapped node + tree.traverseDepthFirst((thisNode: TreeNode) => { + if (thisNode.pmRange.from >= wrapperNode.pmRange.to) { + // The text positions shift by the length of the open and close tags that have just been removed + const textOffset = -wrappedOpenTag.length - wrappedCloseTag.length; + // The prosemirror positions shift by 2 (1 for the opening and 1 for the closing tag) + const proseOffset = -2; + thisNode.shiftOffsets(textOffset, proseOffset); + } + }); + + // Update the root node separately + tree.root.shiftCloseOffsets(-wrappedOpenTag.length - wrappedCloseTag.length, -2); + + // Now we need to update the nodes that were children of the wrapper node + nodesInRange.forEach(n => { + // We update their positions + n.shiftOffsets(-wrappedOpenTag.length, -1); + // and add them to the parent of the wrapper node + wrapperParent.addChild(n); + }); + + return { result: docChange, newTree: tree }; + } + + replaceAroundReplace(step: ReplaceAroundStep, tree: Tree): ParsedStep { + console.log("IN REPLACE AROUND REPLACE", step, tree); + + // We start by checking what kind of node we are wrapping with + const wrappingNode = step.slice.content.firstChild; + if (!wrappingNode) { + throw new NodeUpdateError(" ReplaceAroundStep replace has no wrapping node "); + } + + const pmSize = step.slice.size; + if (pmSize != 2) throw new NodeUpdateError(" Size of the slice is not equal to 2 "); + + if (step.slice.content.childCount != 1) { + 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) + const insertedNodeType = wrappingNode.type.name; + if (insertedNodeType !== "hint" && insertedNodeType !== "input") { + throw new NodeUpdateError(" We only support wrapping in hints or inputs "); + } + + // If we are wrapping in a hint node we need to have a title attribute + const title: string = insertedNodeType === "hint" ? wrappingNode.attrs.title : ""; + // 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); + 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 "); + + // Generate the document change (this is a wrapping document change) + const docChange: WrappingDocChange = { + firstEdit: { + finalText: openTag, + startInFile: nodesBeingWrappedStart.range.from, + endInFile: nodesBeingWrappedStart.range.from, + }, + secondEdit: { + finalText: closeTag, + startInFile: nodesBeingWrappedEnd.range.to, + endInFile: nodesBeingWrappedEnd.range.to + } + }; + + // We now update the tree + + const positions = { + startFrom: nodesBeingWrappedStart.range.from, + startTo: nodesBeingWrappedStart.range.to, + endFrom: nodesBeingWrappedEnd.range.from, + endTo: nodesBeingWrappedEnd.range.to, + proseStart: nodesBeingWrappedStart.pmRange.from, + proseEnd: nodesBeingWrappedEnd.pmRange.to + }; + + // Create the new wrapping node + const newNode = new TreeNode( + insertedNodeType, + {from: positions.startFrom + openTag.length, to: positions.endTo}, // inner range + {from: positions.startFrom, to: positions.endTo + closeTag.length}, // full range + title, + positions.proseStart + 1, positions.proseEnd + 1, // prosemirror start, end + {from: positions.proseStart, to: positions.proseEnd + 2} // pmRange + ); + + // We need to find the parent of the first node being wrapped + const parent = tree.findParent(nodesBeingWrappedStart); + if (!parent) throw new NodeUpdateError(" Could not find parent of nodes being wrapped "); + + const nodesInRange = tree.nodesInProseRange(positions.proseStart, positions.proseEnd); + console.log("NODES IN RANGE", nodesInRange); + + // Remove the nodes that are now children of the new wrapping node from their current parent + nodesInRange.forEach(n => { + parent.removeChild(n); + }); + + // Finally we need to update all nodes that come after the inserted wrapping node + tree.traverseDepthFirst((thisNode: TreeNode) => { + if (thisNode.pmRange.from >= positions.proseEnd) { + thisNode.shiftOffsets(openTag.length + closeTag.length, 2); + } + }); + + // Now we need to insert the new wrapping node in the right place in the tree + parent.addChild(newNode); + + nodesInRange.forEach(n => { + newNode.addChild(n); + n.shiftOffsets(openTag.length, 1); + }); + + tree.root.shiftCloseOffsets(openTag.length + closeTag.length, 2); + + return {result: docChange, newTree: tree}; + } + +} diff --git a/src/mapping/textUpdate.ts b/src/mapping/textUpdate.ts new file mode 100644 index 0000000..05b4cbc --- /dev/null +++ b/src/mapping/textUpdate.ts @@ -0,0 +1,80 @@ +import { Mapping } from "./newmapping"; +import { ParsedStep, OperationType } from "./types"; +import { Tree, TreeNode } from "./Tree"; +import { typeFromStep } from "./helper-functions"; +import { ReplaceStep } from "prosemirror-transform"; +import { TextUpdateError, DocChange } from "../api"; + +export class TextUpdate { + + /** This function is responsible for handling updates in prosemirror that happen exclusively as text edits and translating them to vscode text doc */ + textUpdate(step: ReplaceStep, mapping: Mapping) : ParsedStep { + // Determine operation type + const type = typeFromStep(step); + + // If there is more than one node in the fragment of step, throw an error + if(step.slice.content.childCount > 1) throw new TextUpdateError(" Text edit contained more text nodes than expected "); + + // Check that the slice conforms to our assumptions + if (step.slice.openStart != 0 || step.slice.openEnd != 0) throw new TextUpdateError(" We do not support partial slices for ReplaceSteps"); + + const tree = mapping.getMapping() + + const targetCell: TreeNode | null = tree.findNodeByProsemirrorPosition(step.from) + if (targetCell === null) throw new TextUpdateError(" Target cell is not in mapping!!! "); + + if (targetCell === tree.root) throw new TextUpdateError(" Text can not be inserted into the root "); + + /** Check that the change is, indeed, happening within a stringcell */ + if (targetCell.prosemirrorEnd < step.from) throw new TextUpdateError(" Step does not happen within cell "); + + /** The offset within the correct stringCell for the step action */ + const offsetBegin = step.from - targetCell.prosemirrorStart; + + /** The offset within the correct stringCell for the step action */ + const offsetEnd = step.to - targetCell.prosemirrorStart; + + const text = step.slice.content.firstChild && step.slice.content.firstChild.text ? step.slice.content.firstChild.text : ""; + + const offset = getTextOffset(type,step); + + /** The resulting document change to document model */ + const result: DocChange = { + startInFile: targetCell.innerRange.from + offsetBegin, + endInFile: targetCell.innerRange.from + offsetEnd, + finalText: text + } + + const target = {prosemirrorStart: targetCell.prosemirrorStart, prosemirrorEnd: targetCell.prosemirrorEnd} + tree.traverseDepthFirst((node: TreeNode) => { + console.log("To be updated?", node) + if (node.prosemirrorStart <= target.prosemirrorStart && target.prosemirrorEnd <= node.prosemirrorEnd) { + // This node is either the node we are making the text update in or a parent node + // We only have to update the closing ranges + node.shiftCloseOffsets(offset); + } else if (node.prosemirrorStart > target.prosemirrorStart && node.prosemirrorEnd > target.prosemirrorEnd) { + // This node is fully after the node in which we made the text update + // We update all the ranges + node.shiftOffsets(offset); + } + }); + + console.log(tree) + + let newTree = new Tree; + newTree = tree; + return {result, newTree}; + } +} + +/** This gets the offset in the vscode document that is being added (then >0) or removed (then <0) */ +function getTextOffset(type: OperationType, step: ReplaceStep) : number { + if (type == OperationType.delete) return step.from - step.to; + + /** Validate step if not a delete type */ + if (step.slice.content.firstChild === null || step.slice.content.firstChild.text === undefined) throw new TextUpdateError(" Invalid replace step " + step); + + if (type == OperationType.insert) return step.slice.content.firstChild.text?.length; + + return step.slice.content.firstChild.text?.length + step.from - step.to; +} \ No newline at end of file diff --git a/src/mapping/types.ts b/src/mapping/types.ts new file mode 100644 index 0000000..8ab833b --- /dev/null +++ b/src/mapping/types.ts @@ -0,0 +1,22 @@ +import { DocChange, WrappingDocChange } from "../api"; +import { Tree } from "./Tree"; +/** + * The type returned by the functions converting steps to Document Changes of the + * underlying vscode model of the document + */ +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 +} + +/** + * In prosemirror, every step is a replace step. This enum is used to classify + * the steps into the given 'pure' operations + */ +export enum OperationType { + insert = "insert", + delete = "delete", + replace = "replace" +}; \ No newline at end of file diff --git a/src/markdown-defaults/index.ts b/src/markdown-defaults/index.ts index 1fbe36d..c35de30 100644 --- a/src/markdown-defaults/index.ts +++ b/src/markdown-defaults/index.ts @@ -1,6 +1,6 @@ import { TagConfiguration } from "../api"; -export { parser } from "./statemachine"; +export { parse } from "./statemachine"; export function configuration(languageId: string): TagConfiguration { return { diff --git a/src/markdown-defaults/statemachine.ts b/src/markdown-defaults/statemachine.ts index 3836720..9c6ab9b 100644 --- a/src/markdown-defaults/statemachine.ts +++ b/src/markdown-defaults/statemachine.ts @@ -32,7 +32,7 @@ enum NestedState { * code block). Defaults to `""`. * @returns A array of `Block` that form a `WaterproofDocument`. */ -export function parser(document: string, language: string = ""): WaterproofDocument { +export function parse(document: string, language: string = ""): WaterproofDocument { // Stack to store the produced blocks const blocks: Block[] = []; @@ -165,10 +165,12 @@ export function parser(document: string, language: string = ""): WaterproofDocum function closeMarkdown() { // If there is content in the buffer range then we create a markdown block if (i > getRangeStart()) { - const range = { from: getRangeStart(), to: i }; + // const range = { from: getRangeStart(), to: i }; + const from = getRangeStart(); + const to = i; const markdownBlock = new MarkdownBlock( document.slice(getRangeStart(), i), - range, range); + {from, to}, {from, to}); pushBlock(markdownBlock); } } From d293d9b7a48843b3b5dec51bee42bab83391714c Mon Sep 17 00:00:00 2001 From: DikieDick <26772815+DikieDick@users.noreply.github.com> Date: Wed, 8 Oct 2025 12:14:36 +0200 Subject: [PATCH 30/50] Allow empty Math and Markdown nodes to be created --- src/document/blocks/blocktypes.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/document/blocks/blocktypes.ts b/src/document/blocks/blocktypes.ts index 41ed449..2feaec9 100644 --- a/src/document/blocks/blocktypes.ts +++ b/src/document/blocks/blocktypes.ts @@ -89,6 +89,10 @@ export class MathDisplayBlock implements Block { constructor( public stringContent: string, public range: BlockRange, public innerRange: BlockRange ) {}; toProseMirror() { + if (this.stringContent === "") { + // If the string content is empty, we create an empty math display node. + return WaterproofSchema.nodes.math_display.create(); + } return mathDisplay(this.stringContent); } @@ -110,6 +114,10 @@ export class MarkdownBlock implements Block { }; toProseMirror() { + if (this.stringContent === "") { + // If the string content is empty, we create an empty markdown node. + return WaterproofSchema.nodes.markdown.create(); + } return markdown(this.stringContent); } @@ -129,7 +137,7 @@ export class CodeBlock implements Block { toProseMirror() { if (this.stringContent === "") { - // If the string content is empty, we create an empty coqcode node. + // If the string content is empty, we create an empty code node. return WaterproofSchema.nodes.code.create(); } return code(this.stringContent); @@ -137,7 +145,7 @@ export class CodeBlock implements Block { // Debug print function. debugPrint(level: number): void { - console.log(`${indentation(level)}CoqCodeBlock {${debugInfo(this)}}: {${this.stringContent.replaceAll("\n", "\\n")}}`); + console.log(`${indentation(level)}CodeBlock {${debugInfo(this)}}: {${this.stringContent.replaceAll("\n", "\\n")}}`); } } From 96480e3f35f2a06ee095518de6cbdcdd27d1b9ac Mon Sep 17 00:00:00 2001 From: DikieDick <26772815+DikieDick@users.noreply.github.com> Date: Wed, 8 Oct 2025 12:57:48 +0200 Subject: [PATCH 31/50] Implement Sonar suggestions --- __tests__/mathinline.test.ts | 8 ++++++-- eslint.config.mjs | 2 +- src/commands/commands.ts | 4 ++-- src/mapping/Tree.ts | 29 +++++++++++++++-------------- src/mapping/newmapping.ts | 26 +++++++++----------------- src/mapping/textUpdate.ts | 6 +----- 6 files changed, 34 insertions(+), 41 deletions(-) diff --git a/__tests__/mathinline.test.ts b/__tests__/mathinline.test.ts index 26d1b77..48f46a4 100644 --- a/__tests__/mathinline.test.ts +++ b/__tests__/mathinline.test.ts @@ -1,9 +1,13 @@ import { defaultToMarkdown } from "../src/translation"; test("Replace $ inside of markdown", () => { - expect(defaultToMarkdown("$\\text{math-inline}$")).toBe("\\text{math-inline}"); + expect(defaultToMarkdown(String.raw`$\text{math-inline}$`)).toBe(String.raw`\text{math-inline}`); }); test("Replace $ inside of markdown with content", () => { - expect(defaultToMarkdown("Content\n$\\text{math-inline}$ content in the line\nMore content")).toBe("Content\n\\text{math-inline} content in the line\nMore content"); + expect(defaultToMarkdown(String.raw`Content +$\text{math-inline}$ content in the line +More content`)).toBe(String.raw`Content +\text{math-inline} content in the line +More content`); }); diff --git a/eslint.config.mjs b/eslint.config.mjs index 4d0c1de..2afd916 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -5,7 +5,7 @@ import tseslint from 'typescript-eslint'; const config = tseslint.config( { - ignores: ["dist/", "*.config.js", "esbuild*.mjs", "scripts/"], + ignores: ["dist/", "*.config.js", "esbuild*.mjs", "scripts/", "__tests__/"], }, { extends: [ diff --git a/src/commands/commands.ts b/src/commands/commands.ts index 876abad..d8d808d 100644 --- a/src/commands/commands.ts +++ b/src/commands/commands.ts @@ -31,8 +31,8 @@ export function wpLift(_tagConf: TagConfiguration): Command { - const beforeIsNewline = before !== null ? before.type === WaterproofSchema.nodes.newline : false; - const afterIsNewline = after !== null ? after.type === WaterproofSchema.nodes.newline : false; + 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); diff --git a/src/mapping/Tree.ts b/src/mapping/Tree.ts index d50aa40..1cf3c44 100644 --- a/src/mapping/Tree.ts +++ b/src/mapping/Tree.ts @@ -48,17 +48,17 @@ export class TreeNode { } shiftCloseOffsets(offset: number, offsetProsemirror?: number): void { - this.prosemirrorEnd += offsetProsemirror !== undefined ? offsetProsemirror : offset; - this.pmRange.to += offsetProsemirror !== undefined ? offsetProsemirror : offset; + this.prosemirrorEnd += offsetProsemirror ?? offset; + this.pmRange.to += offsetProsemirror ?? offset; this.innerRange.to += offset; this.range.to += offset; } shiftOffsets(offset: number, offsetProsemirror?: number): void { - this.prosemirrorStart += offsetProsemirror !== undefined ? offsetProsemirror : offset; - this.prosemirrorEnd += offsetProsemirror !== undefined ? offsetProsemirror : offset; - this.pmRange.from += offsetProsemirror !== undefined ? offsetProsemirror : offset; - this.pmRange.to += offsetProsemirror !== undefined ? offsetProsemirror : offset; + this.prosemirrorStart += offsetProsemirror ?? offset; + this.prosemirrorEnd += offsetProsemirror ?? offset; + this.pmRange.from += offsetProsemirror ?? offset; + this.pmRange.to += offsetProsemirror ?? offset; this.innerRange.from += offset; this.innerRange.to += offset; this.range.from += offset; @@ -75,15 +75,16 @@ export class Tree { root: TreeNode; constructor( - type: string = "", - innerRange: {from: number, to: number} = {from: 0, to: 0}, - range: {from: number, to: number} = {from: 0, to: 0}, - title: string = "", - prosemirrorStart: number = 0, - prosemirrorEnd: number = 0, - pmRange: {from: number, to: number} = {from: 0, to: 0} + type: string, + innerRange: {from: number, to: number}, + range: {from: number, to: number}, + title: string, + prosemirrorStart: number, + prosemirrorEnd: number, + pmRange: {from: number, to: number} ) { - this.root = new TreeNode(type, innerRange, range, title, prosemirrorStart, prosemirrorEnd, pmRange); + // Explicitly create new ranges for the TreeNode to avoid shared references + this.root = new TreeNode(type, {from: innerRange.from, to: innerRange.to}, {from: range.from, to: range.to}, title, prosemirrorStart, prosemirrorEnd, {from: pmRange.from, to: pmRange.to}); } traverseDepthFirst(callback: (node: TreeNode) => void, node: TreeNode = this.root): void { diff --git a/src/mapping/newmapping.ts b/src/mapping/newmapping.ts index 5d384db..643297a 100644 --- a/src/mapping/newmapping.ts +++ b/src/mapping/newmapping.ts @@ -30,7 +30,14 @@ export class Mapping { this.textUpdate = new TextUpdate(); this.nodeUpdate = new NodeUpdate(tMap, serializer); this._version = versionNum; - this.tree = new Tree(); + this.tree = new Tree( + "", // type + { from: 0, to: inputBlocks.at(-1)!.range.to }, // innerRange + { from: 0, to: inputBlocks.at(-1)!.range.to }, // range + "", // title + 0, // prosemirrorStart + 0, // prosemirrorEnd + { from: 0, to: 0 }); this.initTree(inputBlocks); // console.log(inputBlocks); console.log("MAPPED TREE", JSON.stringify(this.tree)); @@ -127,18 +134,6 @@ export class Mapping { * @param blocks */ private initTree(blocks: Block[]): void { - // Create a root node with dummy values - - const root = new TreeNode( - "", // type - { from: 0, to: blocks.at(-1)!.range.to }, // innerRange - { from: 0, to: blocks.at(-1)!.range.to }, // range - "", // title - 0, // prosemirrorStart - 0, // prosemirrorEnd - { from: 0, to: 0 } - ); - function buildSubtree(blocks: Block[]): TreeNode[] { return blocks.map(block => { @@ -164,11 +159,8 @@ export class Mapping { } const topLevelNodes = buildSubtree(blocks); - topLevelNodes.forEach(child => root.addChild(child)); + topLevelNodes.forEach(child => this.tree.root.addChild(child)); - // Set the tree root after mapping - this.tree.root = root; - // console.log(this.tree); // Now compute the ProseMirror offsets after creating the tree structure this.computeProsemirrorOffsets(this.tree.root); } diff --git a/src/mapping/textUpdate.ts b/src/mapping/textUpdate.ts index 05b4cbc..466cfe0 100644 --- a/src/mapping/textUpdate.ts +++ b/src/mapping/textUpdate.ts @@ -59,11 +59,7 @@ export class TextUpdate { } }); - console.log(tree) - - let newTree = new Tree; - newTree = tree; - return {result, newTree}; + return {result, newTree: tree}; } } From 7535615adcadaa491c5e2ae991d3e067ef02cc0b Mon Sep 17 00:00:00 2001 From: DikieDick <26772815+DikieDick@users.noreply.github.com> Date: Thu, 9 Oct 2025 10:25:19 +0200 Subject: [PATCH 32/50] Node insertions now work --- __tests__/tree.test.ts | 336 ++++++++++++++++++++++++++++++-- src/commands/command-helpers.ts | 40 ++-- src/commands/commands.ts | 14 +- src/commands/utils.ts | 47 ++--- src/mapping/Tree.ts | 29 ++- src/mapping/nodeUpdate.ts | 82 +------- 6 files changed, 405 insertions(+), 143 deletions(-) diff --git a/__tests__/tree.test.ts b/__tests__/tree.test.ts index a663c96..19e1789 100644 --- a/__tests__/tree.test.ts +++ b/__tests__/tree.test.ts @@ -1,16 +1,322 @@ -import { TreeNode } from "../src/mapping"; - -const type = ""; -const range = {from: 0, to: 0}; -const title = ""; - -test("nodesInRange", () => { - // const nodes: TreeNode[] = [ - // new TreeNode(type, range, range, title, ) - // ] - // const tree = new Tree(nodes); - // expect(tree.nodes.length).toBe(3); - // expect(tree.nodes[0].from).toBe(1); - // expect(tree.nodes[1].from).toBe(2); - // expect(tree.nodes[2].from).toBe(3); +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Tree, TreeNode } from "../src/mapping"; + +function fromJSON(json: any): Tree { + const parseNode = (obj: any): TreeNode => { + const node = new TreeNode( + obj.type, + obj.innerRange, + obj.range, + obj.title, + obj.prosemirrorStart, + obj.prosemirrorEnd, + obj.pmRange + ); + if (obj.children && Array.isArray(obj.children)) { + obj.children.forEach((childObj: any) => { + node.addChild(parseNode(childObj)); + }); + } + return node; + } + const rootObj = json.root; + const tree = new Tree( + rootObj.type, + rootObj.innerRange, + rootObj.range, + rootObj.title, + rootObj.prosemirrorStart, + rootObj.prosemirrorEnd, + rootObj.pmRange + ); + rootObj.children.forEach((childObj: any) => { + tree.root.addChild(parseNode(childObj)); + }); + return tree; +} + +const treeJSON = { + "root": { + "type": "", + "innerRange": { + "from": 0, + "to": 70 + }, + "range": { + "from": 0, + "to": 70 + }, + "title": "", + "prosemirrorStart": 0, + "prosemirrorEnd": 54, + "pmRange": { + "from": 0, + "to": 55 + }, + "children": [ + { + "type": "markdown", + "innerRange": { + "from": 0, + "to": 4 + }, + "range": { + "from": 0, + "to": 4 + }, + "title": "", + "prosemirrorStart": 1, + "prosemirrorEnd": 5, + "pmRange": { + "from": 0, + "to": 6 + }, + "children": [] + }, + { + "type": "newline", + "innerRange": { + "from": 4, + "to": 5 + }, + "range": { + "from": 4, + "to": 5 + }, + "title": "", + "prosemirrorStart": 6, + "prosemirrorEnd": 6, + "pmRange": { + "from": 6, + "to": 7 + }, + "children": [] + }, + { + "type": "code", + "innerRange": { + "from": 12, + "to": 31 + }, + "range": { + "from": 5, + "to": 35 + }, + "title": "", + "prosemirrorStart": 8, + "prosemirrorEnd": 27, + "pmRange": { + "from": 7, + "to": 28 + }, + "children": [] + }, + { + "type": "newline", + "innerRange": { + "from": 35, + "to": 36 + }, + "range": { + "from": 35, + "to": 36 + }, + "title": "", + "prosemirrorStart": 28, + "prosemirrorEnd": 28, + "pmRange": { + "from": 28, + "to": 29 + }, + "children": [] + }, + { + "type": "code", + "innerRange": { + "from": 43, + "to": 66 + }, + "range": { + "from": 36, + "to": 70 + }, + "title": "", + "prosemirrorStart": 30, + "prosemirrorEnd": 53, + "pmRange": { + "from": 29, + "to": 54 + }, + "children": [] + } + ] + } +}; + +test("treeFromJSON", () => { + const tree = fromJSON(treeJSON); + // console.log(JSON.stringify(tree, null, 1)); + + expect(tree.root.children.length).toBe(5); + expect(tree.root.children[0].type).toBe("markdown"); + expect(tree.root.children[1].type).toBe("newline"); + expect(tree.root.children[2].type).toBe("code"); + expect(tree.root.children[3].type).toBe("newline"); + expect(tree.root.children[4].type).toBe("code"); +}); + +test("findByProsemirrorPosition", () => { + const tree = fromJSON(treeJSON); + // Position clearly within the first markdown node + const node1 = tree.findNodeByProsePos(4); + expect(node1).not.toBeNull(); + expect(node1?.type).toBe("markdown"); + + // This is on the boundary + const node2 = tree.findNodeByProsePos(6); + expect(node2).not.toBeNull(); + expect(node2?.type).toBe("markdown"); + + const node3 = tree.findNodeByProsePos(28); + expect(node3).not.toBeNull(); + expect(node3?.type).toBe("code"); + + const node4 = tree.findNodeByProsePos(29); + expect(node4).not.toBeNull(); + expect(node4?.type).toBe("newline"); +}); + +const secondTreeJSON = { + "root": { + "type": "", + "innerRange": { + "from": 0, + "to": 61 + }, + "range": { + "from": 0, + "to": 61 + }, + "title": "", + "prosemirrorStart": 0, + "prosemirrorEnd": 31, + "pmRange": { + "from": 0, + "to": 32 + }, + "children": [ + { + "type": "markdown", + "innerRange": { + "from": 0, + "to": 4 + }, + "range": { + "from": 0, + "to": 4 + }, + "title": "", + "prosemirrorStart": 1, + "prosemirrorEnd": 5, + "pmRange": { + "from": 0, + "to": 6 + }, + "children": [] + }, + { + "type": "input", + "innerRange": { + "from": 16, + "to": 48 + }, + "range": { + "from": 4, + "to": 61 + }, + "title": "", + "prosemirrorStart": 7, + "prosemirrorEnd": 30, + "pmRange": { + "from": 6, + "to": 31 + }, + "children": [ + { + "type": "newline", + "innerRange": { + "from": 16, + "to": 17 + }, + "range": { + "from": 16, + "to": 17 + }, + "title": "", + "prosemirrorStart": 7, + "prosemirrorEnd": 7, + "pmRange": { + "from": 7, + "to": 8 + }, + "children": [] + }, + { + "type": "code", + "innerRange": { + "from": 24, + "to": 43 + }, + "range": { + "from": 17, + "to": 47 + }, + "title": "", + "prosemirrorStart": 9, + "prosemirrorEnd": 28, + "pmRange": { + "from": 8, + "to": 29 + }, + "children": [] + }, + { + "type": "newline", + "innerRange": { + "from": 47, + "to": 48 + }, + "range": { + "from": 47, + "to": 48 + }, + "title": "", + "prosemirrorStart": 29, + "prosemirrorEnd": 29, + "pmRange": { + "from": 29, + "to": 30 + }, + "children": [] + } + ] + } + ] + } +}; + +test("findByProsemirrorPosition with nested nodes", () => { + const tree = fromJSON(secondTreeJSON); + + const node1 = tree.findNodeByProsePos(7); + expect(node1).not.toBeNull(); + expect(node1?.type).toBe("newline"); + + // expect(tree.findNodeByProsePos(7)?.type).toBe("newline"); + + expect(tree.findNodeByProsePos(8)?.type).toBe("newline"); + + expect(tree.findNodeByProsePos(6)?.type).toBe("markdown"); + + expect(tree.findNodeByProsePos(31)?.type).toBe("input"); }); \ No newline at end of file diff --git a/src/commands/command-helpers.ts b/src/commands/command-helpers.ts index 41ee3b7..a3844ae 100644 --- a/src/commands/command-helpers.ts +++ b/src/commands/command-helpers.ts @@ -5,7 +5,7 @@ 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 { getSurroundingNodes } from "./utils"; +import { getParentAndIndex } from "./utils"; /////// Helper functions ///////// @@ -13,15 +13,19 @@ import { getSurroundingNodes } from "./utils"; * Helper function for inserting a new node above the currently selected one. * @param state The current editor state. * @param tr The current transaction for the state of the editor. - * @param nodeType ? + * @param nodeType TODO * @returns An insertion transaction. */ export function insertAbove(state: EditorState, tr: Transaction, nodeType: NodeType, insertNewlineBeforeIfNotExists: boolean, insertNewlineAfterIfNotExists: boolean): Transaction | undefined { const sel = state.selection; let trans: Transaction = tr; - const {before} = getSurroundingNodes(sel.$from); - const beforeIsNewline = before !== null ? (before.type === WaterproofSchema.nodes.newline) : false; + const parentAndIndex = getParentAndIndex(sel); + if (parentAndIndex === null) return; + const {parent, index} = parentAndIndex; + + const nodeAboveSelection = parent.maybeChild(index - 1); + const beforeIsNewline = nodeAboveSelection === null ? false : (nodeAboveSelection.type === WaterproofSchema.nodes.newline); let pos; @@ -36,17 +40,17 @@ export function insertAbove(state: EditorState, tr: Transaction, nodeType: NodeT return; } - if (beforeIsNewline) { // Assumption: If a newline appears before a node the current node wants that. - pos -= 1; // We are going to insert befofre + pos -= 1; // We are going to insert before the newline node } - const newBefore = getSurroundingNodes(state.doc.resolve(pos)).before; + const beforeNewline = parent.maybeChild(index - 2); + const hasNewlineBefore = beforeNewline === null ? false : beforeNewline.type === WaterproofSchema.nodes.newline; const toInsert: PNode[] = []; - if (insertNewlineBeforeIfNotExists && newBefore !== null && newBefore.type !== WaterproofSchema.nodes.newline) { + if (insertNewlineBeforeIfNotExists && !hasNewlineBefore && beforeIsNewline) { toInsert.push(newline()); } toInsert.push(nodeType.create()); @@ -63,16 +67,20 @@ export function insertAbove(state: EditorState, tr: Transaction, nodeType: NodeT * Helper function for inserting a new node below the currently selected one. * @param state The current editor state. * @param tr The current transaction for the state of the editor. - * @param nodeType ? + * @param nodeType TODO * @returns An insertion transaction. */ export function insertBelow(state: EditorState, tr: Transaction, nodeType: NodeType, insertNewlineBeforeIfNotExists: boolean, insertNewlineAfterIfNotExists: boolean): Transaction | undefined { const sel = state.selection; let trans: Transaction = tr; - const {after} = getSurroundingNodes(sel.$from); - const afterIsNewline = after !== null ? (after.type === WaterproofSchema.nodes.newline) : false; - // console.log("After", after?.type.name); + const parentAndIndex = getParentAndIndex(sel); + if (parentAndIndex === null) return; + const {parent, index} = parentAndIndex; + + const nodeBelowSelection = parent.maybeChild(index + 1); + const afterIsNewline = nodeBelowSelection === null ? false : (nodeBelowSelection.type === WaterproofSchema.nodes.newline); + let pos; if (sel instanceof NodeSelection) { @@ -89,16 +97,16 @@ export function insertBelow(state: EditorState, tr: Transaction, nodeType: NodeT pos += 1; // We are going to insert after } - // console.log("Node at", state.doc.nodeAt(pos)); - const newAfter = getSurroundingNodes(state.doc.resolve(pos)).after; - // console.log("newafter", newAfter); + const afterNewline = parent.maybeChild(index + 2); + const hasNewlineAfter = afterNewline === null ? false : afterNewline.type === WaterproofSchema.nodes.newline; + const toInsert: PNode[] = []; if (insertNewlineBeforeIfNotExists && !afterIsNewline) { toInsert.push(newline()); } toInsert.push(nodeType.create()); - if (insertNewlineAfterIfNotExists && newAfter !== null && newAfter.type !== WaterproofSchema.nodes.newline) { + if (insertNewlineAfterIfNotExists && !hasNewlineAfter && afterIsNewline) { toInsert.push(newline()); } diff --git a/src/commands/commands.ts b/src/commands/commands.ts index d8d808d..8e130dc 100644 --- a/src/commands/commands.ts +++ b/src/commands/commands.ts @@ -79,12 +79,16 @@ export function wpLift(_tagConf: TagConfiguration): Command { export function deleteSelection(tagConf: TagConfiguration): Command { return (state, dispatch) => { - if (state.selection.empty) return false; - if (state.selection instanceof TextSelection) { + const sel = state.selection; + if (sel.empty) return false; + if (sel instanceof TextSelection) { if (dispatch) dispatch(state.tr.deleteSelection().scrollIntoView()); return true; - } else if (state.selection instanceof NodeSelection) { - const {parent, index} = getParentAndIndex(state.selection.$from); + } else if (sel instanceof NodeSelection) { + // const {parent, index} = getParentAndIndex(state.selection.$from); + const parentAndIndex = getParentAndIndex(sel); + if (!parentAndIndex) return false; + const {parent, index} = parentAndIndex; const before = parent.maybeChild(index - 1); const after = parent.maybeChild(index + 1); @@ -103,7 +107,7 @@ export function deleteSelection(tagConf: TagConfiguration): Command { // We need to keep one of the newlines, so we delete the node and the after newline if (dispatch) dispatch(state.tr.delete(state.selection.from, state.selection.to + afterSize).scrollIntoView()); return true; - } else if (afterIsNewline && afteer !== null && needsNewlineBefore(state.selection.node.type, tagConf)) { + } else if (afterIsNewline && afteer !== null && needsNewlineBefore(sel.node.type, tagConf)) { // After is newline and afteer needs newline before // We need to keep the after newline, so we delete the node and the before newline if (dispatch) dispatch(state.tr.delete(state.selection.from - beforeSize, state.selection.to).scrollIntoView()); diff --git a/src/commands/utils.ts b/src/commands/utils.ts index 58a333c..4a5c633 100644 --- a/src/commands/utils.ts +++ b/src/commands/utils.ts @@ -1,36 +1,29 @@ -import { ResolvedPos, Node, NodeType } from "prosemirror-model"; +import { Node, NodeType } from "prosemirror-model"; import { TagConfiguration } from "../api"; import { WaterproofSchema } from "../schema"; +import { NodeSelection, Selection, TextSelection } from "prosemirror-state"; -export function getSurroundingNodes($pos: ResolvedPos): {before: Node | null; after: Node | null} { - const depth = $pos.depth; - let parent; - let index; - if (depth === 0) { - parent = $pos.parent; - index = $pos.index(0); - } else { - parent = $pos.node(1); - index = $pos.index(1); - } - const before = index > 0 ? parent.child(index - 1) : null; - const after = index < parent.childCount - 1 ? parent.child(index + 1) : null; - return {before, after}; +export function getSurroundingNodes(sel: Selection): {before: Node | null; after: Node | null} { + const parentAndIndex = getParentAndIndex(sel); + if (parentAndIndex === null) return {before: null, after: null}; + const {parent, index} = parentAndIndex; + return { + before: parent.maybeChild(index - 1), + after: parent.maybeChild(index + 1) + }; } -export function getParentAndIndex($pos: ResolvedPos): {parent: Node; index: number} { - const depth = $pos.depth; - let parent; - let index; - if (depth === 0) { - parent = $pos.parent; - index = $pos.index(0); +export function getParentAndIndex(sel: Selection): {parent: Node; index: number} | null { + if (sel instanceof NodeSelection) { + const depth = sel.$from.depth; + return {parent: sel.$from.parent, index: sel.$from.index(depth)}; + } else if (sel instanceof TextSelection) { + const depth = sel.$from.depth; + return {parent: sel.$from.node(depth - 1), index: sel.$from.index(depth - 1)}; } - else { - parent = $pos.node(1); - index = $pos.index(1); - } - return {parent, index}; + // TODO: If the selection is not a node or text selection then it still can be AllSelection, which + // we will ignore for now. + return null; } export function needsNewlineBefore(nodeType: NodeType, tagConf: TagConfiguration): boolean { diff --git a/src/mapping/Tree.ts b/src/mapping/Tree.ts index 1cf3c44..b95e4eb 100644 --- a/src/mapping/Tree.ts +++ b/src/mapping/Tree.ts @@ -39,8 +39,8 @@ export class TreeNode { addChild(child: TreeNode): void { this.children.push(child); - // Sort children by originalStart to maintain order - this.children.sort((a, b) => a.innerRange.from - b.innerRange.from); + // Sort children by pmRange to maintain order + this.children.sort((a, b) => a.pmRange.from - b.pmRange.from); } removeChild(child: TreeNode): void { @@ -67,7 +67,9 @@ export class TreeNode { traverseDepthFirst(callback: (node: TreeNode) => void): void { callback(this); - this.children.forEach(child => child.traverseDepthFirst(callback)); + for(const child of this.children) { + child.traverseDepthFirst(callback); + } } } @@ -89,7 +91,10 @@ export class Tree { traverseDepthFirst(callback: (node: TreeNode) => void, node: TreeNode = this.root): void { callback(node); - node.children.forEach(child => this.traverseDepthFirst(callback, child)); + for(const child of node.children) { + // this.traverseDepthFirst(callback, child); + child.traverseDepthFirst(callback); + } } traverseBreadthFirst(callback: (node: TreeNode) => void): void { @@ -151,8 +156,15 @@ export class Tree { return null; } - findNodeByProsePos(pos: number, node: TreeNode | null = this.root): TreeNode | null { - if (!node) return null; + /** + * 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. + * @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 + */ + findNodeByProsePos(pos: number, node: TreeNode = this.root): TreeNode | null { if (pos < node.pmRange.from || pos > node.pmRange.to) return null; // Binary search among children @@ -161,7 +173,10 @@ export class Tree { while (left <= right) { const mid = Math.floor((left + right) / 2); const child = node.children[mid]; - if (pos > child.pmRange.from && pos < child.pmRange.to) { + if (pos === child.pmRange.from && mid > 0) { + return node.children[mid-1]; + } else if (pos >= child.pmRange.from && pos <= child.pmRange.to) { + if (child.children.length === 0) return child; return this.findNodeByProsePos(pos, child); } else if (pos <= child.pmRange.to) { right = mid - 1; diff --git a/src/mapping/nodeUpdate.ts b/src/mapping/nodeUpdate.ts index 94a8256..770d289 100644 --- a/src/mapping/nodeUpdate.ts +++ b/src/mapping/nodeUpdate.ts @@ -90,19 +90,21 @@ export class NodeUpdate { const parent = tree.findParent(nodeInTree); if (!parent) throw new NodeUpdateError(" Could not find parent of insertion position in mapping "); + // When we are inserting at zero we manually set the document pos to zero + const atZero = step.from === 0; + // Should we use the to position of the node we found? + const useTo = nodeInTree.pmRange.to === step.from; - console.log("nodeInTree", JSON.stringify(nodeInTree)); + const documentPos = atZero ? 0 : (useTo ? nodeInTree.range.to : nodeInTree.range.from); - const documentPos = nodeInTree.range.to; - - let offsetProse = nodeInTree.pmRange.to; - let offsetOriginal = nodeInTree.range.to; + let offsetProse = atZero ? 0 : (useTo ? nodeInTree.pmRange.to : nodeInTree.pmRange.from); + let offsetOriginal = documentPos; const nodes: TreeNode[] = []; let serialized = ""; step.slice.content.forEach(node => { const output = this.serializer.serializeNode(node); - // console.log("OUTPUT", output); + console.log("OUTPUT", output); console.log("node", node.type.name); console.log("output", output); serialized += output; @@ -112,11 +114,6 @@ export class NodeUpdate { offsetProse += node.nodeSize; }); - console.log("SERIALIZED BY TEXT SERIALIZE\n", serialized); - console.log("NODES BY BUILD TREE\n", nodes); - - // throw new NodeUpdateError(" Insert not supported yet "); - const docChange: DocChange = { startInFile: documentPos, endInFile: documentPos, @@ -135,7 +132,7 @@ export class NodeUpdate { // 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 < nodeInTree.pmRange.from && thisNode.pmRange.to > nodeInTree.pmRange.to) { + if (thisNode.pmRange.from <= nodeInTree.pmRange.from && thisNode.pmRange.to >= nodeInTree.pmRange.to) { thisNode.shiftCloseOffsets(textOffset, proseOffset); } }); @@ -147,65 +144,6 @@ export class NodeUpdate { return { result: docChange, newTree: tree }; } - // replaceInsert(step: ReplaceStep, tree: Tree): ParsedStep { - // const firstNode = step.slice.content.firstChild; - // if (!firstNode) throw new NodeUpdateError(" No nodes in slice content "); - - // // TODO: The plus 1 does not work when the insert is at the end of some block - // console.log("BLABLABLA", tree.findHighestContainingNode(step.from)); - // const nodeInTree = tree.findNodeByProsemirrorPosition(step.from + 1); - // console.log("nodeInTree", JSON.stringify(nodeInTree)); - // if (!nodeInTree) throw new NodeUpdateError(" Could not find position to insert node in mapping "); - // const parent = tree.findParent(nodeInTree); - // if (!parent) throw new NodeUpdateError(" Could not find parent of insertion position in mapping "); - - - // // let offsetOriginal = nodeInTree.range.to; - // let offsetProse = nodeInTree.prosemirrorEnd; - // let offsetOriginal = step.from; - // console.log("OffsetProse", offsetProse, "OffsetOriginal", offsetOriginal, "Step.from", step.from, "Step.to", step.to); - // const nodes: TreeNode[] = []; - // let serialized = ""; - // step.slice.content.forEach(node => { - // const output = this.serializer.serializeNode(node); - // // console.log("OUTPUT", output); - // console.log("node", node.type.name); - // console.log("output", output); - // serialized += output; - // const builtNode = this.buildTreeFromNode(node, offsetOriginal, offsetProse); - // nodes.push(builtNode); - // offsetOriginal += output.length; - // offsetProse += node.nodeSize + (builtNode.innerRange.to - builtNode.innerRange.from); - // }); - // console.log("SERIALIZED BY TEXT SERIALIZE\n", serialized); - // console.log("NODES BY BUILD TREE\n", nodes); - - // const docChange: DocChange = { - // startInFile: nodeInTree.range.to, - // endInFile: nodeInTree.range.to, - // finalText: serialized - // }; - - // const proseOffset = step.slice.content.size; - // const textOffset = serialized.length; - - // // now we need to update the tree - // tree.traverseDepthFirst((thisNode: TreeNode) => { - // if (thisNode.prosemirrorStart >= nodeInTree.prosemirrorEnd) { - // thisNode.shiftOffsets(textOffset, proseOffset); - // } - // }); - - // // We add the nodes 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)); - - // tree.root.shiftCloseOffsets(textOffset, proseOffset); - - // return { result: docChange, newTree: tree }; - // } - - - buildTreeFromNode(node: Node, startOrig: number, startProse: number): TreeNode { // Shortcut for newline nodes @@ -279,8 +217,6 @@ export class NodeUpdate { } }); - console.log("NODES TO DELETE", nodesToDelete); - if (nodesToDelete.length == 0) { throw new NodeUpdateError("Could not find any nodes to delete in the given step."); } From 718f5e53cabe1a07d61c0e5c392f7cc9c36dfdba Mon Sep 17 00:00:00 2001 From: DikieDick <26772815+DikieDick@users.noreply.github.com> Date: Thu, 9 Oct 2025 10:50:38 +0200 Subject: [PATCH 33/50] Update tests and implement sonar suggestions --- __tests__/mapping-blockat.test.ts | 3 -- __tests__/newmapping.test.ts | 87 +++++++++++++++---------------- __tests__/statemachine.test.ts | 2 - src/mapping/newmapping.ts | 7 +-- src/mapping/nodeUpdate.ts | 10 +--- src/mapping/textUpdate.ts | 9 ++-- 6 files changed, 51 insertions(+), 67 deletions(-) diff --git a/__tests__/mapping-blockat.test.ts b/__tests__/mapping-blockat.test.ts index 040ecba..69cb4fb 100644 --- a/__tests__/mapping-blockat.test.ts +++ b/__tests__/mapping-blockat.test.ts @@ -5,8 +5,6 @@ import { configuration, parse } from "../src/markdown-defaults"; import { WaterproofSchema } from "../src/schema"; import { Node as ProseNode } from "prosemirror-model"; - - function root (childNodes: ProseNode[]) { return WaterproofSchema.nodes.doc.create({}, childNodes); } @@ -19,7 +17,6 @@ function constructDocument(blocks: Block[]): ProseNode { test("BlockAt with simple .mv file", () => { const doc = "# Test\n```coq\nTest.\n```\n\n```coq\nTestingtest.\n```\n"; - // const doc = "# Test\n"; const blocks = parse(doc, "coq"); const mapping = new Mapping(blocks, 0, configuration("coq"), new DocumentSerializer(configuration("coq"))); diff --git a/__tests__/newmapping.test.ts b/__tests__/newmapping.test.ts index cab6359..b546f68 100644 --- a/__tests__/newmapping.test.ts +++ b/__tests__/newmapping.test.ts @@ -1,63 +1,58 @@ -/* eslint-disable @typescript-eslint/ban-ts-comment */ -// Disable because the @ts-expect-error clashes with the tests import { DocumentSerializer, Mapping } from "../src/api"; -import { TreeNode } from "../src/mapping"; import { configuration, parse } from "../src/markdown-defaults"; const config = configuration("coq"); const serializer = new DocumentSerializer(config); -function createTestMapping(content: string): TreeNode[] { +function createTestMapping(content: string) { const blocks = parse(content, "coq"); const mapping = new Mapping(blocks, 1, config, serializer) const tree = mapping.getMapping(); - const nodes: TreeNode[] = []; - tree.traverseDepthFirst((node: TreeNode) => { - nodes.push(node); - }); - return nodes; + return tree; } -// // Not sure about the values for prosemirrorStart and prosemirrorEnd -// test("testMapping", () => { -// const content = `Hello`; -// const nodes = createTestMapping(content); -// expect(nodes.length).toBe(2); -// const markdownNode = nodes[1]; -// console.log(markdownNode) -// expect(markdownNode.type).toBe("markdown"); -// expect(markdownNode.innerRange.from).toBe(0); -// expect(markdownNode.innerRange.to).toBe(5); -// expect(markdownNode.prosemirrorStart).toBe(1); -// expect(markdownNode.prosemirrorEnd).toBe(6); -// expect(markdownNode.stringContent).toBe("Hello"); -// }) +test("testMapping markdown only", () => { + const content = "Hello"; + const nodes = createTestMapping(content); -// test("testMapping coqblock with code", () => { -// const content = "```coq\nLemma test\n```"; -// const nodes = createTestMapping(content); + expect(nodes.root.type).toBe(""); + + expect(nodes.root.children.length).toBe(1); + const markdownNode = nodes.root.children[0]; + + expect(markdownNode.type).toBe("markdown"); + expect(markdownNode.innerRange).toStrictEqual<{from: number, to: number}>({from: 0, to: 5}); + expect(markdownNode.range).toStrictEqual<{from: number, to: number}>({from: 0, to: 5}); + expect(markdownNode.prosemirrorStart).toBe(1); + expect(markdownNode.prosemirrorEnd).toBe(6); + expect(markdownNode.pmRange).toStrictEqual<{from: number, to: number}>({from: 0, to: 7}); +}); + +test("testMapping coqblock with code", () => { + const content = "```coq\nLemma test\n```"; + const nodes = createTestMapping(content).root.children; -// expect(nodes.length).toBe(2); + expect(nodes.length).toBe(1); -// // Parent coqblock -// const coqblockNode = nodes[1]; -// expect(coqblockNode.type).toBe("code"); -// expect(coqblockNode.innerRange.from).toBe(7); -// expect(coqblockNode.innerRange.to).toBe(17); -// expect(coqblockNode.prosemirrorStart).toBe(1); -// expect(coqblockNode.prosemirrorEnd).toBe(11); -// expect(coqblockNode.stringContent).toBe("Lemma test"); -// }); + // Parent coqblock + const coqblockNode = nodes[0]; + expect(coqblockNode.type).toBe("code"); + expect(coqblockNode.innerRange).toStrictEqual<{from: number, to: number}>({from: 7, to: 17}); + expect(coqblockNode.range).toStrictEqual<{from: number, to: number}>({from: 0, to: content.length}); + expect(coqblockNode.prosemirrorStart).toBe(1); + expect(coqblockNode.prosemirrorEnd).toBe(11); + expect(coqblockNode.pmRange).toStrictEqual<{from: number, to: number}>({from: 0, to: 12}); +}); test("Input-area with nested coqblock", () => { const content = "\n```coq\nTest\n```\nHello"; - const nodes = createTestMapping(content); + const nodes = createTestMapping(content).root.children; - expect(nodes.length).toBe(6); + expect(nodes.length).toBe(2); // Input-area node - const inputAreaNode = nodes[1]; + const inputAreaNode = nodes[0]; expect(inputAreaNode.type).toBe("input"); expect(inputAreaNode.innerRange.from).toBe(12); expect(inputAreaNode.innerRange.to).toBe(29); @@ -65,13 +60,13 @@ test("Input-area with nested coqblock", () => { expect(inputAreaNode.prosemirrorEnd).toBe(9); // Nested coqblock - const coqblockNode = nodes[3]; - console.log(nodes) - expect(coqblockNode.type).toBe("code"); - expect(coqblockNode.innerRange.from).toBe(20); - expect(coqblockNode.innerRange.to).toBe(24); - expect(coqblockNode.prosemirrorStart).toBe(3); - expect(coqblockNode.prosemirrorEnd).toBe(7); + // const coqblockNode = nodes[3]; + // console.log(nodes) + // expect(coqblockNode.type).toBe("code"); + // expect(coqblockNode.innerRange.from).toBe(20); + // expect(coqblockNode.innerRange.to).toBe(24); + // expect(coqblockNode.prosemirrorStart).toBe(3); + // expect(coqblockNode.prosemirrorEnd).toBe(7); }); diff --git a/__tests__/statemachine.test.ts b/__tests__/statemachine.test.ts index 6c20b16..417e19d 100644 --- a/__tests__/statemachine.test.ts +++ b/__tests__/statemachine.test.ts @@ -1,5 +1,3 @@ -/* eslint-disable no-useless-escape */ - import { parse } from "../src/markdown-defaults"; import { isMarkdownBlock, isCodeBlock, isHintBlock, isInputAreaBlock, isMathDisplayBlock, isNewlineBlock } from "../src/document/blocks"; import { HintBlock } from "../src/document"; diff --git a/src/mapping/newmapping.ts b/src/mapping/newmapping.ts index 643297a..b327dd5 100644 --- a/src/mapping/newmapping.ts +++ b/src/mapping/newmapping.ts @@ -40,7 +40,7 @@ export class Mapping { { from: 0, to: 0 }); this.initTree(inputBlocks); // console.log(inputBlocks); - console.log("MAPPED TREE", JSON.stringify(this.tree)); + console.log("MAPPED TREE", JSON.stringify(this.tree, null, 1)); } //// The getters of this class @@ -141,8 +141,9 @@ export class Mapping { const node = new TreeNode( block.type, - block.innerRange, - block.range, + // Explicit dereferencing of object properties to avoid shared references to innerRange and range + {from: block.innerRange.from, to: block.innerRange.to}, + {from: block.range.from, to: block.range.to}, title, 0, // prosemirrorStart (to be calculated later) 0, // prosemirrorEnd (to be calculated later) diff --git a/src/mapping/nodeUpdate.ts b/src/mapping/nodeUpdate.ts index 770d289..269b52a 100644 --- a/src/mapping/nodeUpdate.ts +++ b/src/mapping/nodeUpdate.ts @@ -9,7 +9,7 @@ import { ReplaceAroundStep, ReplaceStep } from "prosemirror-transform"; export class NodeUpdate { // Store the tag configuration and serializer - constructor (private tagConf: TagConfiguration, private serializer: DocumentSerializer) {} + constructor (private readonly tagConf: TagConfiguration, private readonly serializer: DocumentSerializer) {} // Utility to get the opening and closing tag for a given node type nodeNameToTagPair(nodeName: string, title: string = ""): [string, string] { @@ -104,9 +104,6 @@ export class NodeUpdate { let serialized = ""; step.slice.content.forEach(node => { const output = this.serializer.serializeNode(node); - console.log("OUTPUT", output); - console.log("node", node.type.name); - console.log("output", output); serialized += output; const builtNode = this.buildTreeFromNode(node, offsetOriginal, offsetProse); nodes.push(builtNode); @@ -306,9 +303,7 @@ export class NodeUpdate { return { result: docChange, newTree: tree }; } - replaceAroundReplace(step: ReplaceAroundStep, tree: Tree): ParsedStep { - console.log("IN REPLACE AROUND REPLACE", step, tree); - + 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) { @@ -381,7 +376,6 @@ export class NodeUpdate { if (!parent) throw new NodeUpdateError(" Could not find parent of nodes being wrapped "); const nodesInRange = tree.nodesInProseRange(positions.proseStart, positions.proseEnd); - console.log("NODES IN RANGE", nodesInRange); // Remove the nodes that are now children of the new wrapping node from their current parent nodesInRange.forEach(n => { diff --git a/src/mapping/textUpdate.ts b/src/mapping/textUpdate.ts index 466cfe0..787428a 100644 --- a/src/mapping/textUpdate.ts +++ b/src/mapping/textUpdate.ts @@ -1,6 +1,6 @@ import { Mapping } from "./newmapping"; import { ParsedStep, OperationType } from "./types"; -import { Tree, TreeNode } from "./Tree"; +import { TreeNode } from "./Tree"; import { typeFromStep } from "./helper-functions"; import { ReplaceStep } from "prosemirror-transform"; import { TextUpdateError, DocChange } from "../api"; @@ -20,7 +20,7 @@ export class TextUpdate { const tree = mapping.getMapping() - const targetCell: TreeNode | null = tree.findNodeByProsemirrorPosition(step.from) + const targetCell: TreeNode | null = tree.findNodeByProsePos(step.from) if (targetCell === null) throw new TextUpdateError(" Target cell is not in mapping!!! "); if (targetCell === tree.root) throw new TextUpdateError(" Text can not be inserted into the root "); @@ -34,7 +34,7 @@ export class TextUpdate { /** The offset within the correct stringCell for the step action */ const offsetEnd = step.to - targetCell.prosemirrorStart; - const text = step.slice.content.firstChild && step.slice.content.firstChild.text ? step.slice.content.firstChild.text : ""; + const text = step.slice.content.firstChild?.text === undefined ? "" : step.slice.content.firstChild.text; const offset = getTextOffset(type,step); @@ -47,7 +47,6 @@ export class TextUpdate { const target = {prosemirrorStart: targetCell.prosemirrorStart, prosemirrorEnd: targetCell.prosemirrorEnd} tree.traverseDepthFirst((node: TreeNode) => { - console.log("To be updated?", node) if (node.prosemirrorStart <= target.prosemirrorStart && target.prosemirrorEnd <= node.prosemirrorEnd) { // This node is either the node we are making the text update in or a parent node // We only have to update the closing ranges @@ -68,7 +67,7 @@ function getTextOffset(type: OperationType, step: ReplaceStep) : number { if (type == OperationType.delete) return step.from - step.to; /** Validate step if not a delete type */ - if (step.slice.content.firstChild === null || step.slice.content.firstChild.text === undefined) throw new TextUpdateError(" Invalid replace step " + step); + if (step.slice.content.firstChild?.text === undefined) throw new TextUpdateError(" Invalid replace step " + step); if (type == OperationType.insert) return step.slice.content.firstChild.text?.length; From 17639b016cdcbb8b294a11223ce7c836dac3a69a Mon Sep 17 00:00:00 2001 From: DikieDick <26772815+DikieDick@users.noreply.github.com> Date: Mon, 13 Oct 2025 10:30:21 +0200 Subject: [PATCH 34/50] Mapping tests based off blocks, some sonar fixes --- .../{ => mapping}/mapping-blockat.test.ts | 10 ++-- __tests__/{ => mapping}/newmapping.test.ts | 59 +++++++++---------- __tests__/mapping/nodeupdate.test.ts | 55 +++++++++++++++++ __tests__/{ => mapping}/textupdate.test.ts | 35 +++++------ __tests__/nodeupdate.test.ts | 5 -- src/commands/commands.ts | 12 ++-- src/mapping/Tree.ts | 1 - src/mapping/newmapping.ts | 25 ++++---- 8 files changed, 122 insertions(+), 80 deletions(-) rename __tests__/{ => mapping}/mapping-blockat.test.ts (97%) rename __tests__/{ => mapping}/newmapping.test.ts (72%) create mode 100644 __tests__/mapping/nodeupdate.test.ts rename __tests__/{ => mapping}/textupdate.test.ts (74%) delete mode 100644 __tests__/nodeupdate.test.ts diff --git a/__tests__/mapping-blockat.test.ts b/__tests__/mapping/mapping-blockat.test.ts similarity index 97% rename from __tests__/mapping-blockat.test.ts rename to __tests__/mapping/mapping-blockat.test.ts index 69cb4fb..8de20c0 100644 --- a/__tests__/mapping-blockat.test.ts +++ b/__tests__/mapping/mapping-blockat.test.ts @@ -1,8 +1,8 @@ -import { DocumentSerializer } from "../src/api"; -import { Block } from "../src/document"; -import { Mapping } from "../src/mapping"; -import { configuration, parse } from "../src/markdown-defaults"; -import { WaterproofSchema } from "../src/schema"; +import { DocumentSerializer } from "../../src/api"; +import { Block } from "../../src/document"; +import { Mapping } from "../../src/mapping"; +import { configuration, parse } from "../../src/markdown-defaults"; +import { WaterproofSchema } from "../../src/schema"; import { Node as ProseNode } from "prosemirror-model"; function root (childNodes: ProseNode[]) { diff --git a/__tests__/newmapping.test.ts b/__tests__/mapping/newmapping.test.ts similarity index 72% rename from __tests__/newmapping.test.ts rename to __tests__/mapping/newmapping.test.ts index b546f68..51b9682 100644 --- a/__tests__/newmapping.test.ts +++ b/__tests__/mapping/newmapping.test.ts @@ -1,20 +1,19 @@ -import { DocumentSerializer, Mapping } from "../src/api"; -import { configuration, parse } from "../src/markdown-defaults"; +import { DocumentSerializer, Mapping, WaterproofDocument } from "../../src/api"; +import { CodeBlock, MarkdownBlock } from "../../src/document"; +import { configuration } from "../../src/markdown-defaults"; const config = configuration("coq"); const serializer = new DocumentSerializer(config); -function createTestMapping(content: string) { - const blocks = parse(content, "coq"); - +function createTestMapping(blocks: WaterproofDocument) { const mapping = new Mapping(blocks, 1, config, serializer) const tree = mapping.getMapping(); return tree; } test("testMapping markdown only", () => { - const content = "Hello"; - const nodes = createTestMapping(content); + const blocks = [new MarkdownBlock("Hello", {from: 0, to: 5}, {from: 0, to: 5})]; + const nodes = createTestMapping(blocks); expect(nodes.root.type).toBe(""); @@ -30,8 +29,8 @@ test("testMapping markdown only", () => { }); test("testMapping coqblock with code", () => { - const content = "```coq\nLemma test\n```"; - const nodes = createTestMapping(content).root.children; + const blocks = [new CodeBlock("Lemma test", {from: 0, to: 21}, {from: 7, to: 17})]; + const nodes = createTestMapping(blocks).root.children; expect(nodes.length).toBe(1); @@ -39,36 +38,36 @@ test("testMapping coqblock with code", () => { const coqblockNode = nodes[0]; expect(coqblockNode.type).toBe("code"); expect(coqblockNode.innerRange).toStrictEqual<{from: number, to: number}>({from: 7, to: 17}); - expect(coqblockNode.range).toStrictEqual<{from: number, to: number}>({from: 0, to: content.length}); + expect(coqblockNode.range).toStrictEqual<{from: number, to: number}>({from: 0, to: 21}); expect(coqblockNode.prosemirrorStart).toBe(1); expect(coqblockNode.prosemirrorEnd).toBe(11); expect(coqblockNode.pmRange).toStrictEqual<{from: number, to: number}>({from: 0, to: 12}); }); -test("Input-area with nested coqblock", () => { - const content = "\n```coq\nTest\n```\nHello"; - const nodes = createTestMapping(content).root.children; +// test("Input-area with nested coqblock", () => { +// const content = "\n```coq\nTest\n```\nHello"; +// const nodes = createTestMapping(content).root.children; - expect(nodes.length).toBe(2); +// expect(nodes.length).toBe(2); - // Input-area node - const inputAreaNode = nodes[0]; - expect(inputAreaNode.type).toBe("input"); - expect(inputAreaNode.innerRange.from).toBe(12); - expect(inputAreaNode.innerRange.to).toBe(29); - expect(inputAreaNode.prosemirrorStart).toBe(1); - expect(inputAreaNode.prosemirrorEnd).toBe(9); +// // Input-area node +// const inputAreaNode = nodes[0]; +// expect(inputAreaNode.type).toBe("input"); +// expect(inputAreaNode.innerRange.from).toBe(12); +// expect(inputAreaNode.innerRange.to).toBe(29); +// expect(inputAreaNode.prosemirrorStart).toBe(1); +// expect(inputAreaNode.prosemirrorEnd).toBe(9); - // Nested coqblock - // const coqblockNode = nodes[3]; - // console.log(nodes) - // expect(coqblockNode.type).toBe("code"); - // expect(coqblockNode.innerRange.from).toBe(20); - // expect(coqblockNode.innerRange.to).toBe(24); - // expect(coqblockNode.prosemirrorStart).toBe(3); - // expect(coqblockNode.prosemirrorEnd).toBe(7); +// // Nested coqblock +// // const coqblockNode = nodes[3]; +// // console.log(nodes) +// // expect(coqblockNode.type).toBe("code"); +// // expect(coqblockNode.innerRange.from).toBe(20); +// // expect(coqblockNode.innerRange.to).toBe(24); +// // expect(coqblockNode.prosemirrorStart).toBe(3); +// // expect(coqblockNode.prosemirrorEnd).toBe(7); -}); +// }); // test("Hint block with coqblock and markdown inside", () => { // const content = "\n```coq\nRequire Import Rbase.\n```\n"; diff --git a/__tests__/mapping/nodeupdate.test.ts b/__tests__/mapping/nodeupdate.test.ts new file mode 100644 index 0000000..f6b63dc --- /dev/null +++ b/__tests__/mapping/nodeupdate.test.ts @@ -0,0 +1,55 @@ +import { Fragment, Slice } from "prosemirror-model"; +import { DocChange, DocumentSerializer, Mapping, WaterproofDocument } from "../../src/api"; +import { configuration } from "../../src/markdown-defaults"; +import { ReplaceStep } from "prosemirror-transform"; +import { WaterproofSchema } from "../../src/schema"; +import { NodeUpdate } from "../../src/mapping/nodeUpdate"; +import { InputAreaBlock, MarkdownBlock } from "../../src/document"; + +const config = configuration("coq"); +const serializer = new DocumentSerializer(config); + +function createMapping(blocks: WaterproofDocument) { + const mapping = new Mapping(blocks, 0, config, serializer); + return mapping; +} + +test("Insert code underneath markdown", () => { + // # Hello + const mapping = createMapping([new MarkdownBlock("# Hello", {from: 0, to: 7}, {from: 0, to: 7})]); + 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); + + expect(result).toStrictEqual({ + finalText: "\n```coq\n\n```", + startInFile: 7, + endInFile: 7 + }); + + expect(newTree.root.children.length).toBe(3); +}); + +test("Insert code underneath markdown inside input area", () => { + // # Hello + const mapping = createMapping([ + new InputAreaBlock("# Hello", + {from: 0, to: 32}, + {from: 12, to: 19}, + [ + new MarkdownBlock("# Hello", {from: 12, to: 19}, {from: 12, to: 19}) + ])]); + 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); + + expect(result).toStrictEqual({ + finalText: "\n```coq\n\n```", + startInFile: 19, + endInFile: 19 + }); +}); \ No newline at end of file diff --git a/__tests__/textupdate.test.ts b/__tests__/mapping/textupdate.test.ts similarity index 74% rename from __tests__/textupdate.test.ts rename to __tests__/mapping/textupdate.test.ts index 85e4598..a7ae1bf 100644 --- a/__tests__/textupdate.test.ts +++ b/__tests__/mapping/textupdate.test.ts @@ -1,22 +1,20 @@ import { Slice, Fragment } from "prosemirror-model"; import { ReplaceStep } from "prosemirror-transform"; -import { DocChange, DocumentSerializer } from "../src/api"; -import { Mapping } from "../src/mapping"; -import { TextUpdate } from "../src/mapping/textUpdate"; -import { configuration, parse } from "../src/markdown-defaults"; -import { WaterproofSchema } from "../src/schema"; +import { DocChange, DocumentSerializer, WaterproofDocument } from "../../src/api"; +import { Mapping } from "../../src/mapping"; +import { TextUpdate } from "../../src/mapping/textUpdate"; +import { configuration } from "../../src/markdown-defaults"; +import { WaterproofSchema } from "../../src/schema"; +import { MarkdownBlock } from "../../src/document"; - - -function createDocAndMapping(doc: string) { - const blocks = parse(doc, "coq"); - const mapping = new Mapping(blocks, 0, configuration("coq"), new DocumentSerializer(configuration("coq"))); +function createMapping(doc: WaterproofDocument) { + const mapping = new Mapping(doc, 0, configuration("coq"), new DocumentSerializer(configuration("coq"))); return mapping; } test("ReplaceStep insert — inserts text into a block", () => { - const content = `Hello`; - const mapping = createDocAndMapping(content); + const blocks = [new MarkdownBlock("Hello", {from: 0, to: 5}, {from: 0, to: 5})]; + const mapping = createMapping(blocks); const slice: Slice = new Slice(Fragment.from(WaterproofSchema.text(" world")), 0, 0); const step: ReplaceStep = new ReplaceStep(6, 6, slice); console.log("here is the step", step); @@ -38,9 +36,10 @@ test("ReplaceStep insert — inserts text into a block", () => { }); }); +const helloWorldMarkdownBlock = new MarkdownBlock("Hello world", {from: 0, to: 11}, {from: 0, to: 11}); + test("ReplaceStep insert — inserts text in the middle of a block", () => { - const content = "Hello world"; - const mapping = createDocAndMapping(content); + const mapping = createMapping([helloWorldMarkdownBlock]); const slice: Slice = new Slice(Fragment.from(WaterproofSchema.text("big ")), 0, 0); const step: ReplaceStep = new ReplaceStep(7, 7, slice); const textUpdate = new TextUpdate(); @@ -63,8 +62,7 @@ test("ReplaceStep insert — inserts text in the middle of a block", () => { }); test("ReplaceStep delete — deletes part of a block", () => { - const content = `Hello world`; - const mapping = createDocAndMapping(content); + const mapping = createMapping([helloWorldMarkdownBlock]); const step: ReplaceStep = new ReplaceStep(7, 12, Slice.empty); const textUpdate = new TextUpdate(); const {newTree, result} = textUpdate.textUpdate(step, mapping); @@ -85,9 +83,8 @@ test("ReplaceStep delete — deletes part of a block", () => { }); -test("ReplaceStep replace - replaces part of a block", () => { - const originalContent = "Hello world"; - const mapping = createDocAndMapping(originalContent); +test("ReplaceStep replace — replaces part of a block", () => { + const mapping = createMapping([helloWorldMarkdownBlock]); const slice: Slice = new Slice(Fragment.from(WaterproofSchema.text("there")), 0, 0); const step: ReplaceStep = new ReplaceStep(7, 12, slice); const textUpdate = new TextUpdate(); diff --git a/__tests__/nodeupdate.test.ts b/__tests__/nodeupdate.test.ts deleted file mode 100644 index 30d0ecc..0000000 --- a/__tests__/nodeupdate.test.ts +++ /dev/null @@ -1,5 +0,0 @@ - - -test("empty", () => { - -}); \ No newline at end of file diff --git a/src/commands/commands.ts b/src/commands/commands.ts index 8e130dc..f1baf45 100644 --- a/src/commands/commands.ts +++ b/src/commands/commands.ts @@ -92,15 +92,15 @@ export function deleteSelection(tagConf: TagConfiguration): Command { const before = parent.maybeChild(index - 1); const after = parent.maybeChild(index + 1); - const beforeSize = before !== null ? before.nodeSize : 0; - const afterSize = after !== null ? after.nodeSize : 0; + const beforeSize = before === null ? 0 : before.nodeSize; + const afterSize = after === null ? 0 : after.nodeSize; // node before before const befoore = parent.maybeChild(index - 2); // node after after const afteer = parent.maybeChild(index + 2); - const beforeIsNewline = before !== null ? before.type === WaterproofSchema.nodes.newline : false; - const afterIsNewline = after !== null ? after.type === WaterproofSchema.nodes.newline : false; + const beforeIsNewline = before === null ? false : before.type === WaterproofSchema.nodes.newline; + const afterIsNewline = after === null ? false : after.type === WaterproofSchema.nodes.newline; if (beforeIsNewline && afterIsNewline && befoore !== null && afteer !== null && needsNewlineAfter(befoore.type, tagConf) && needsNewlineBefore(afteer.type, tagConf)) { // Before and after are newlines, and befoore needs newline after and afteer needs newline before @@ -148,8 +148,8 @@ function wpWrapIn(nodeType: NodeType, tagConf: TagConfiguration): Command { const after = sel.$to.nodeAfter; if (dispatch) { - const beforeIsNewline = before !== null ? before.type === WaterproofSchema.nodes.newline : false; - const afterIsNewline = after !== null ? after.type === WaterproofSchema.nodes.newline : false; + const beforeIsNewline = before === null ? false : before.type === WaterproofSchema.nodes.newline; + const afterIsNewline = after === null ? false : after.type === WaterproofSchema.nodes.newline; const nodeBeingWrapped = sel.node; const needsBefore = needsNewlineBefore(nodeBeingWrapped.type, tagConf); const needsAfter = needsNewlineAfter(nodeBeingWrapped.type, tagConf); diff --git a/src/mapping/Tree.ts b/src/mapping/Tree.ts index b95e4eb..fb31d01 100644 --- a/src/mapping/Tree.ts +++ b/src/mapping/Tree.ts @@ -92,7 +92,6 @@ export class Tree { traverseDepthFirst(callback: (node: TreeNode) => void, node: TreeNode = this.root): void { callback(node); for(const child of node.children) { - // this.traverseDepthFirst(callback, child); child.traverseDepthFirst(callback); } } diff --git a/src/mapping/newmapping.ts b/src/mapping/newmapping.ts index b327dd5..8ee3a56 100644 --- a/src/mapping/newmapping.ts +++ b/src/mapping/newmapping.ts @@ -39,7 +39,6 @@ export class Mapping { 0, // prosemirrorEnd { from: 0, to: 0 }); this.initTree(inputBlocks); - // console.log(inputBlocks); console.log("MAPPED TREE", JSON.stringify(this.tree, null, 1)); } @@ -66,20 +65,18 @@ export class Mapping { return (index - node.prosemirrorStart) + node.innerRange.from; } - /** Returns the prosemirror index of vscode document model index */ - public findInvPosition(index: number) { - const correctNode: TreeNode | null = this.tree.findNodeByOriginalPosition(index); - if (correctNode === null) throw new MappingError(` [findInvPosition] The prosemirror index for position (${index}) could not be found `); - return (index - correctNode.innerRange.from) + correctNode.prosemirrorStart; - } - - private inStringCell(step: ReplaceStep | ReplaceAroundStep): boolean { - const correctNode: TreeNode | null = this.tree.findNodeByProsemirrorPosition(step.from); - return correctNode !== null && step.to <= correctNode.prosemirrorEnd; + /** + * Returns the prosemirror index corresponding to the given document offset. + * @param offset The offset (in characters) in the document. + * @returns The corresponding prosemirror index. + */ + public findInvPosition(offset: number) { + const correctNode: TreeNode | null = this.tree.findNodeByOriginalPosition(offset); + if (correctNode === null) throw new MappingError(` [findInvPosition] The prosemirror index for offset (${offset}) could not be found `); + return (offset - correctNode.innerRange.from) + correctNode.prosemirrorStart; } public update(step: Step, doc: Node): DocChange | WrappingDocChange { - console.log("STEP IN UPDATE", step) if (!(step instanceof ReplaceStep || step instanceof ReplaceAroundStep)) throw new MappingError("Step update (in textDocMapping) should not be called with a non document changing step"); @@ -216,9 +213,9 @@ export class Mapping { offset += (node.innerRange.to - node.innerRange.from); } else { // Non-leaf: handle children and end tag - for (let i = 0; i < node.children.length; i++) { + for (const child of node.children) { offset = this.computeProsemirrorOffsets( - node.children[i], + child, offset, level + 1 ); From 9c8328e9d4d09660066346526a8bacf0575ef755 Mon Sep 17 00:00:00 2001 From: DikieDick <26772815+DikieDick@users.noreply.github.com> Date: Mon, 13 Oct 2025 10:39:27 +0200 Subject: [PATCH 35/50] Allow disabling markdown features by name --- src/api/types.ts | 4 ++++ src/markup-views/SwitchableViewPlugin.ts | 8 +++----- src/markup-views/switchable-view/RenderedView.ts | 4 ++-- src/markup-views/switchable-view/SwitchableView.ts | 7 ++++--- 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/api/types.ts b/src/api/types.ts index a62e205..3c806be 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -128,6 +128,10 @@ export type WaterproofEditorConfig = { * @returns The output string should be valid Markdown, with possible inline LaTeX wrapped in the tags as described above. */ toMarkdown?: (inputString: string) => string, + /** + * Disables MarkdownIt features. Will likely be removed in the future once there is a nice way to support non markdown markup languages. + */ + disableMarkdownFeatures?: Array } export enum HistoryChange { diff --git a/src/markup-views/SwitchableViewPlugin.ts b/src/markup-views/SwitchableViewPlugin.ts index 4a88119..7c26483 100644 --- a/src/markup-views/SwitchableViewPlugin.ts +++ b/src/markup-views/SwitchableViewPlugin.ts @@ -29,16 +29,14 @@ export const SWITCHABLE_VIEW_PLUGIN_KEY = new PluginKey number | undefined)): SwitchableView => { - /** @todo is this necessary? - * Docs says that for any function proprs, the current plugin instance - * will be bound to `this`. However, the typings don't reflect this. - */ const pluginState = SWITCHABLE_VIEW_PLUGIN_KEY.getState(view.state); if(!pluginState){ throw new Error("no realtime markdown plugin!"); } const nodeViews = pluginState.activeNodeViews; // set up NodeView - const nodeView = new SwitchableView(getPos, view, node.textContent, node, SWITCHABLE_VIEW_PLUGIN_KEY, editorConfig.markdownName ?? "markdown", editorConfig.toMarkdown ?? ((input) => toMathInline(input))); + const nodeView = new SwitchableView(getPos, view, node.textContent, node, SWITCHABLE_VIEW_PLUGIN_KEY, + editorConfig.markdownName ?? "markdown", editorConfig.toMarkdown ?? ((input) => toMathInline(input)), + editorConfig.disableMarkdownFeatures ?? []); nodeViews.push(nodeView); return nodeView; diff --git a/src/markup-views/switchable-view/RenderedView.ts b/src/markup-views/switchable-view/RenderedView.ts index 8516419..4da5026 100644 --- a/src/markup-views/switchable-view/RenderedView.ts +++ b/src/markup-views/switchable-view/RenderedView.ts @@ -17,11 +17,11 @@ export class RenderedView { outerView: EditorView, parent: SwitchableView, _getPos: (() => number | undefined), - + disableMarkdownFeatures: Array, ) { // Create a new MarkdownIt renderer with support for html (this allows // for the math-inline nodes to be passed through) - const mdit = new MarkdownIt({html: true}); + const mdit = new MarkdownIt({html: true}).disable(disableMarkdownFeatures); // Render the markdown (converts it into a HTML string) const mditOutput = mdit.render(content); // Create a container element. diff --git a/src/markup-views/switchable-view/SwitchableView.ts b/src/markup-views/switchable-view/SwitchableView.ts index 6fd5514..a026b67 100644 --- a/src/markup-views/switchable-view/SwitchableView.ts +++ b/src/markup-views/switchable-view/SwitchableView.ts @@ -42,7 +42,8 @@ export class SwitchableView implements NodeView { getPos: (() => number | undefined), outerView: EditorView, content: string, node: PNode, pluginKey: PluginKey, viewName: string, - private processForRendering: (input: string) => string + private readonly processForRendering: (input: string) => string, + private readonly disableMarkdownFeatures: Array, ) { // Store parameters this._node = node; @@ -72,7 +73,7 @@ export class SwitchableView implements NodeView { } // We start with a rendered markdown view. const processedContent = this.processForRendering(this._node.textContent); - this.view = new RenderedView(this._place, processedContent, this._outerView, this, this._getPos); + this.view = new RenderedView(this._place, processedContent, this._outerView, this, this._getPos, this.disableMarkdownFeatures); // eventHandler for the onclick event. // Creates a new node selection that selects 'this' node. @@ -123,7 +124,7 @@ export class SwitchableView implements NodeView { this.dom.classList.remove(this._editorClassName); this.dom.classList.add(this._renderedClassName); // Create the new rendered view and set it as the current view - this.view = new RenderedView(this._place, inputContent, this._outerView, this, this._getPos); + this.view = new RenderedView(this._place, inputContent, this._outerView, this, this._getPos, this.disableMarkdownFeatures); } /** From f9407ae5c75e79cc9c03612f93dccda4b2a20d0a Mon Sep 17 00:00:00 2001 From: XyntaxCS <82953362+XyntaxCS@users.noreply.github.com> Date: Mon, 13 Oct 2025 12:08:10 +0200 Subject: [PATCH 36/50] Fix the start of document codecell cannot edit and no transaction problem --- src/embedded-codemirror/embeddedCodemirror.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/embedded-codemirror/embeddedCodemirror.ts b/src/embedded-codemirror/embeddedCodemirror.ts index 2c2d864..28964e7 100644 --- a/src/embedded-codemirror/embeddedCodemirror.ts +++ b/src/embedded-codemirror/embeddedCodemirror.ts @@ -120,7 +120,7 @@ export class EmbeddedCodeMirrorEditor implements NodeView { // Get the current cursor position. const pos = this._getPos(); // If there is no position we are done. - if (!pos) return; + if (pos === undefined) return; // If we are updating or we don't have focus then we should return early. if (this.updating || !this._codemirror?.hasFocus) return; @@ -165,7 +165,7 @@ export class EmbeddedCodeMirrorEditor implements NodeView { // Get the current position. const pos = this._getPos(); // If there is none, we can't escape this view, - if (!pos) return false; + if (pos === undefined) return false; // Get the current state and the main selection related to this state. const _state = targetView.state; From 237fe7392f875bb668a46cf9e00591cede8a1b23 Mon Sep 17 00:00:00 2001 From: DikieDick <26772815+DikieDick@users.noreply.github.com> Date: Thu, 16 Oct 2025 19:03:57 +0200 Subject: [PATCH 37/50] Allow custom serializers --- __tests__/mapping/mapping-blockat.test.ts | 5 +- __tests__/mapping/newmapping.test.ts | 5 +- __tests__/mapping/nodeupdate.test.ts | 5 +- __tests__/mapping/textupdate.test.ts | 5 +- src/api/types.ts | 8 ++ src/editor.ts | 3 +- src/mapping/nodeUpdate.ts | 15 ++- src/serialization/DocumentSerializer.ts | 149 ++++++++++++++++------ 8 files changed, 142 insertions(+), 53 deletions(-) diff --git a/__tests__/mapping/mapping-blockat.test.ts b/__tests__/mapping/mapping-blockat.test.ts index 8de20c0..43d4314 100644 --- a/__tests__/mapping/mapping-blockat.test.ts +++ b/__tests__/mapping/mapping-blockat.test.ts @@ -4,6 +4,7 @@ import { Mapping } from "../../src/mapping"; import { configuration, parse } from "../../src/markdown-defaults"; import { WaterproofSchema } from "../../src/schema"; import { Node as ProseNode } from "prosemirror-model"; +import { DefaultTagSerializer } from "../../src/serialization/DocumentSerializer"; function root (childNodes: ProseNode[]) { return WaterproofSchema.nodes.doc.create({}, childNodes); @@ -19,7 +20,7 @@ test("BlockAt with simple .mv file", () => { const blocks = parse(doc, "coq"); - const mapping = new Mapping(blocks, 0, configuration("coq"), new DocumentSerializer(configuration("coq"))); + const mapping = new Mapping(blocks, 0, configuration("coq"), new DefaultTagSerializer(configuration("coq"))); const proseDoc = constructDocument(blocks); const tree = mapping.getMapping(); @@ -601,7 +602,7 @@ Qed. ` const blocks = parse(tutorial, "coq"); - const mapping = new Mapping(blocks, 0, configuration("coq"), new DocumentSerializer(configuration("coq"))); + const mapping = new Mapping(blocks, 0, configuration("coq"), new DefaultTagSerializer(configuration("coq"))); const proseDoc = constructDocument(blocks); const tree = mapping.getMapping(); diff --git a/__tests__/mapping/newmapping.test.ts b/__tests__/mapping/newmapping.test.ts index 51b9682..a1c2f75 100644 --- a/__tests__/mapping/newmapping.test.ts +++ b/__tests__/mapping/newmapping.test.ts @@ -1,9 +1,10 @@ -import { DocumentSerializer, Mapping, WaterproofDocument } from "../../src/api"; +import { Mapping, WaterproofDocument } from "../../src/api"; import { CodeBlock, MarkdownBlock } from "../../src/document"; import { configuration } from "../../src/markdown-defaults"; +import { DefaultTagSerializer } from "../../src/serialization/DocumentSerializer"; const config = configuration("coq"); -const serializer = new DocumentSerializer(config); +const serializer = new DefaultTagSerializer(config); function createTestMapping(blocks: WaterproofDocument) { const mapping = new Mapping(blocks, 1, config, serializer) diff --git a/__tests__/mapping/nodeupdate.test.ts b/__tests__/mapping/nodeupdate.test.ts index f6b63dc..0a0c366 100644 --- a/__tests__/mapping/nodeupdate.test.ts +++ b/__tests__/mapping/nodeupdate.test.ts @@ -1,13 +1,14 @@ import { Fragment, Slice } from "prosemirror-model"; -import { DocChange, DocumentSerializer, Mapping, WaterproofDocument } from "../../src/api"; +import { DocChange, Mapping, WaterproofDocument } from "../../src/api"; import { configuration } from "../../src/markdown-defaults"; import { ReplaceStep } from "prosemirror-transform"; import { WaterproofSchema } from "../../src/schema"; import { NodeUpdate } from "../../src/mapping/nodeUpdate"; import { InputAreaBlock, MarkdownBlock } from "../../src/document"; +import { DefaultTagSerializer } from "../../src/serialization/DocumentSerializer"; const config = configuration("coq"); -const serializer = new DocumentSerializer(config); +const serializer = new DefaultTagSerializer(config); function createMapping(blocks: WaterproofDocument) { const mapping = new Mapping(blocks, 0, config, serializer); diff --git a/__tests__/mapping/textupdate.test.ts b/__tests__/mapping/textupdate.test.ts index a7ae1bf..654a5b8 100644 --- a/__tests__/mapping/textupdate.test.ts +++ b/__tests__/mapping/textupdate.test.ts @@ -1,14 +1,15 @@ import { Slice, Fragment } from "prosemirror-model"; import { ReplaceStep } from "prosemirror-transform"; -import { DocChange, DocumentSerializer, WaterproofDocument } from "../../src/api"; +import { DocChange, WaterproofDocument } from "../../src/api"; import { Mapping } from "../../src/mapping"; import { TextUpdate } from "../../src/mapping/textUpdate"; import { configuration } from "../../src/markdown-defaults"; import { WaterproofSchema } from "../../src/schema"; import { MarkdownBlock } from "../../src/document"; +import { DefaultTagSerializer } from "../../src/serialization/DocumentSerializer"; function createMapping(doc: WaterproofDocument) { - const mapping = new Mapping(doc, 0, configuration("coq"), new DocumentSerializer(configuration("coq"))); + const mapping = new Mapping(doc, 0, configuration("coq"), new DefaultTagSerializer(configuration("coq"))); return mapping; } diff --git a/src/api/types.ts b/src/api/types.ts index 3c806be..ae6e5be 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -1,4 +1,5 @@ import { Block } from "../document"; +import { DocumentSerializer } from "../serialization/DocumentSerializer"; import { WaterproofCompletion, WaterproofSymbol } from "./Completions"; import { DocChange, WrappingDocChange } from "./DocChange"; import { Severity } from "./Severity"; @@ -114,6 +115,13 @@ export type WaterproofEditorConfig = { */ tagConfiguration: TagConfiguration, + /** + * The serializer object is used to translate the ProseMirror nodes into + * text. When none is given we will create a default one from the given {@linkcode TagConfiguration}. + * The serializer should extend the {@linkcode DocumentSerializer} class. + */ + serializer?: DocumentSerializer, + /** The name of the markdown node view, defaults to `"Markdown"`. * This name will show up in the editor when editing text in 'Markdown' cells. */ diff --git a/src/editor.ts b/src/editor.ts index 3f486ff..1986902 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -19,6 +19,7 @@ import { MENU_PLUGIN_KEY } from "./menubar/menubar"; import { PROGRESS_PLUGIN_KEY, progressBarPlugin } from "./progressBar"; import { DOCUMENT_PROGRESS_DECORATOR_KEY, documentProgressDecoratorPlugin } from "./documentProgressDecorator"; import { createContextMenuHTML } from "./context-menu"; +import { DefaultTagSerializer } from "./serialization/DocumentSerializer"; // CSS imports import "katex/dist/katex.min.css"; @@ -82,7 +83,7 @@ export class WaterproofEditor { this._editorElem = editorElement; this.currentProseDiagnostics = []; this._editorConfig = config; - this._serializer = new DocumentSerializer(this._editorConfig.tagConfiguration); + this._serializer = config.serializer === undefined ? new DefaultTagSerializer(config.tagConfiguration) : config.serializer; const userAgent = window.navigator.userAgent; this._userOS = OS.Unknown; diff --git a/src/mapping/nodeUpdate.ts b/src/mapping/nodeUpdate.ts index 269b52a..d7163bd 100644 --- a/src/mapping/nodeUpdate.ts +++ b/src/mapping/nodeUpdate.ts @@ -102,8 +102,12 @@ export class NodeUpdate { const nodes: TreeNode[] = []; let serialized = ""; - step.slice.content.forEach(node => { - const output = this.serializer.serializeNode(node); + step.slice.content.forEach((node, _, index) => { + const parent = step.slice.content; + const nodeBefore = parent.maybeChild(index - 1)?.type.name ?? null; + const nodeAfter = parent.maybeChild(index + 1)?.type.name ?? null; + // TODO: Here parent is null. + const output = this.serializer.serializeNode(node, null, nodeBefore, nodeAfter); serialized += output; const builtNode = this.buildTreeFromNode(node, offsetOriginal, offsetProse); nodes.push(builtNode); @@ -170,12 +174,15 @@ export class NodeUpdate { let childOffsetOriginal = startOrig + openTagForNode.length; let childOffsetProse = startProse + 1; // +1 for the opening tag - node.forEach(child => { + node.forEach((child, _, idx) => { const childTreeNode = this.buildTreeFromNode(child, childOffsetOriginal, childOffsetProse); treeNode.children.push(childTreeNode); + + const nodeBefore = node.maybeChild(idx - 1)?.type.name ?? null; + const nodeAfter = node.maybeChild(idx + 1)?.type.name ?? null; // Update the offsets for the next child - const serializedChild = this.serializer.serializeNode(child); + const serializedChild = this.serializer.serializeNode(child, node.type.name, nodeBefore, nodeAfter); childOffsetOriginal += serializedChild.length; childOffsetProse += child.nodeSize; }); diff --git a/src/serialization/DocumentSerializer.ts b/src/serialization/DocumentSerializer.ts index 1d4833e..f68ff8b 100644 --- a/src/serialization/DocumentSerializer.ts +++ b/src/serialization/DocumentSerializer.ts @@ -2,61 +2,130 @@ import { Node } from "prosemirror-model"; import { WaterproofSchema } from "../schema"; import { TagConfiguration } from "../api"; -export class DocumentSerializer { - constructor(private tagConf: TagConfiguration) {} +export abstract class DocumentSerializer { + /** + * Describes how to turn a code node into a string representation. + * @param codeNode The code node that is going to be serialized + * @param parentNode The parent node of this node (if it has one) + * @param nodeAbove The node above this node (if there is one) + * @param nodeBelow The node below this node (if there is one) + */ + abstract serializeCode(codeNode: Node, parentNode: string | null, nodeAbove: string | null, nodeBelow: string | null): string; + /** + * Describes how to turn a math node into a string representation. + * @param mathNode The math node that is going to be serialized + * @param parentNode The parent node of this node (if it has one) + * @param nodeAbove The node above this node (if there is one) + * @param nodeBelow The node below this node (if there is one) + */ + abstract serializeMath(mathNode: Node, parentNode: string | null, nodeAbove: string | null, nodeBelow: string | null): string; + /** + * Describes how to turn a markdown node into a string representation. + * @param codeNode The markdown node that is going to be serialized + * @param parentNode The parent node of this node (if it has one) + * @param nodeAbove The node above this node (if there is one) + * @param nodeBelow The node below this node (if there is one) + */ + abstract serializeMarkdown(markdownNode: Node, parentNode: string | null, nodeAbove: string | null, nodeBelow: string | null): string; + /** + * Describes how to turn a input node into a string representation. + * This node can have children, so you probably want to call `this.serializeNode` on every child node using + * `inputNde.forEach((child) => {...})` + * @param inputNode The input node that is going to be serialized + * @param parentNode The parent node of this node (if it has one) + * @param nodeAbove The node above this node (if there is one) + * @param nodeBelow The node below this node (if there is one) + */ + abstract serializeInput(inputNode: Node, parentNode: string | null, nodeAbove: string | null, nodeBelow: string | null): string; + /** + * Describes how to turn a hint node into a string representation. This function can query the title of the node via + * `hint.attrs.title`. This node can have children, so you probably want to call `this.serializeNode` on every child node using + * `hintNode.forEach((child) => {...})` + * @param hintNode The hint node that is going to be serialized + * @param parentNode The parent node of this node (if it has one) + * @param nodeAbove The node above this node (if there is one) + * @param nodeBelow The node below this node (if there is one) + */ + abstract serializeHint(hintNode: Node, parentNode: string | null, nodeAbove: string | null, nodeBelow: string | null): string; + + serializeText(node: Node): string { + return node.textContent; + } + + serializeNewline(): string { + return "\n"; + } /** * * @param node * @returns */ - serializeNode(node: Node): string { - - let serialized: string = ""; - if (node.type == WaterproofSchema.nodes.markdown) { - const serializerOutput = this.tagConf.markdown.openTag + node.textContent + this.tagConf.markdown.closeTag; - serialized = serializerOutput; - } else if (node.type == WaterproofSchema.nodes.code) { - const serializerOutput = this.tagConf.code.openTag + node.textContent + this.tagConf.code.closeTag; - serialized = serializerOutput; - } else if (node.type == WaterproofSchema.nodes.hint) { - const title = node.attrs.title; - // Has child content - const textContent: string[] = []; - node.forEach(child => { - const output = this.serializeNode(child); - textContent.push(output); - }); - serialized = this.tagConf.hint.openTag(title) + textContent.join("") + this.tagConf.hint.closeTag; - } else if (node.type == WaterproofSchema.nodes.input) { - // Has child content - const textContent: string[] = []; - node.forEach(child => { - const output = this.serializeNode(child); - textContent.push(output); - }); - serialized = this.tagConf.input.openTag + textContent.join("") + this.tagConf.input.closeTag; - } else if (node.type == WaterproofSchema.nodes.math_display) { - const serializerOutput = this.tagConf.math.openTag + node.textContent + this.tagConf.math.closeTag; - serialized += serializerOutput; - } else if (node.type == WaterproofSchema.nodes.newline) { - serialized = "\n"; - } else { - throw new Error(`[NodeSerializer] Encountered unsupported node type: ${node.type.name}`); + public serializeNode(node: Node, parent: string | null, nodeAbove: string | null, nodeBelow: string | null): string { + switch (node.type) { + case WaterproofSchema.nodes.markdown: return this.serializeMarkdown(node, parent, nodeAbove, nodeBelow); + case WaterproofSchema.nodes.code: return this.serializeCode(node, parent, nodeAbove, nodeBelow); + case WaterproofSchema.nodes.math_display: return this.serializeMath(node, parent, nodeAbove, nodeBelow); + case WaterproofSchema.nodes.input: return this.serializeInput(node, parent, nodeAbove, nodeBelow); + case WaterproofSchema.nodes.hint: return this.serializeHint(node, parent, nodeAbove, nodeBelow); + case WaterproofSchema.nodes.text: return this.serializeText(node); + case WaterproofSchema.nodes.newline: return this.serializeNewline(); + default: + throw Error(`[SerializeNode] Node of type "${node.type.name}" not supported.`); } - - return serialized; } /** * * @param node */ - serializeDocument(node: Node) { + public serializeDocument(node: Node) { const output: string[] = []; - node.content.forEach(child => { - output.push(this.serializeNode(child)); + node.content.forEach((child, _, idx) => { + const nodeAbove = node.maybeChild(idx - 1); + const nodeBelow = node.maybeChild(idx + 1); + output.push(this.serializeNode(child, node.type.name, nodeAbove?.type.name ?? null, nodeBelow?.type.name ?? null)); }); return output.join(""); } +} + +export class DefaultTagSerializer extends DocumentSerializer { + + constructor(private readonly tagConf: TagConfiguration) { + super(); + } + + serializeCode(node: Node): string { + return this.tagConf.code.openTag + node.textContent + this.tagConf.code.closeTag; + } + + serializeMath(node: Node): string { + return this.tagConf.math.openTag + node.textContent + this.tagConf.math.closeTag; + } + + serializeMarkdown(node: Node): string { + return this.tagConf.markdown.openTag + node.textContent + this.tagConf.markdown.closeTag; + } + + serializeInput(node: Node): string { + // Has child content + const textContent: string[] = []; + node.forEach(child => { + const output = this.serializeNode(child, null, null, null); + textContent.push(output); + }); + return this.tagConf.input.openTag + textContent.join("") + this.tagConf.input.closeTag; + } + + serializeHint(node: Node): string { + const title = node.attrs.title; + // Has child content + const textContent: string[] = []; + node.forEach(child => { + const output = this.serializeNode(child, null, null, null); + textContent.push(output); + }); + return this.tagConf.hint.openTag(title) + textContent.join("") + this.tagConf.hint.closeTag; + } } \ No newline at end of file From 410f5365c870cc0861ef06f753ca27b2bfcdd94e Mon Sep 17 00:00:00 2001 From: DikieDick <26772815+DikieDick@users.noreply.github.com> Date: Thu, 16 Oct 2025 19:55:25 +0200 Subject: [PATCH 38/50] Finalize newmapping test and quick sonar fixes --- __tests__/mapping/mapping-blockat.test.ts | 1 - __tests__/mapping/newmapping.test.ts | 297 +++++++++++++++------- __tests__/mapping/nodeupdate.test.ts | 6 +- __tests__/tree.test.ts | 11 +- src/api/types.ts | 2 + src/mapping/textUpdate.ts | 2 +- src/serialization/DocumentSerializer.ts | 2 +- 7 files changed, 213 insertions(+), 108 deletions(-) diff --git a/__tests__/mapping/mapping-blockat.test.ts b/__tests__/mapping/mapping-blockat.test.ts index 43d4314..ccc4fec 100644 --- a/__tests__/mapping/mapping-blockat.test.ts +++ b/__tests__/mapping/mapping-blockat.test.ts @@ -1,4 +1,3 @@ -import { DocumentSerializer } from "../../src/api"; import { Block } from "../../src/document"; import { Mapping } from "../../src/mapping"; import { configuration, parse } from "../../src/markdown-defaults"; diff --git a/__tests__/mapping/newmapping.test.ts b/__tests__/mapping/newmapping.test.ts index a1c2f75..d65f760 100644 --- a/__tests__/mapping/newmapping.test.ts +++ b/__tests__/mapping/newmapping.test.ts @@ -1,5 +1,5 @@ -import { Mapping, WaterproofDocument } from "../../src/api"; -import { CodeBlock, MarkdownBlock } from "../../src/document"; +import { Mapping, Range, WaterproofDocument } from "../../src/api"; +import { CodeBlock, HintBlock, InputAreaBlock, MarkdownBlock, NewlineBlock } from "../../src/document"; import { configuration } from "../../src/markdown-defaults"; import { DefaultTagSerializer } from "../../src/serialization/DocumentSerializer"; @@ -22,11 +22,11 @@ test("testMapping markdown only", () => { const markdownNode = nodes.root.children[0]; expect(markdownNode.type).toBe("markdown"); - expect(markdownNode.innerRange).toStrictEqual<{from: number, to: number}>({from: 0, to: 5}); - expect(markdownNode.range).toStrictEqual<{from: number, to: number}>({from: 0, to: 5}); + expect(markdownNode.innerRange).toStrictEqual({from: 0, to: 5}); + expect(markdownNode.range).toStrictEqual({from: 0, to: 5}); expect(markdownNode.prosemirrorStart).toBe(1); expect(markdownNode.prosemirrorEnd).toBe(6); - expect(markdownNode.pmRange).toStrictEqual<{from: number, to: number}>({from: 0, to: 7}); + expect(markdownNode.pmRange).toStrictEqual({from: 0, to: 7}); }); test("testMapping coqblock with code", () => { @@ -38,111 +38,214 @@ test("testMapping coqblock with code", () => { // Parent coqblock const coqblockNode = nodes[0]; expect(coqblockNode.type).toBe("code"); - expect(coqblockNode.innerRange).toStrictEqual<{from: number, to: number}>({from: 7, to: 17}); - expect(coqblockNode.range).toStrictEqual<{from: number, to: number}>({from: 0, to: 21}); + expect(coqblockNode.innerRange).toStrictEqual({from: 7, to: 17}); + expect(coqblockNode.range).toStrictEqual({from: 0, to: 21}); expect(coqblockNode.prosemirrorStart).toBe(1); expect(coqblockNode.prosemirrorEnd).toBe(11); - expect(coqblockNode.pmRange).toStrictEqual<{from: number, to: number}>({from: 0, to: 12}); + expect(coqblockNode.pmRange).toStrictEqual({from: 0, to: 12}); }); -// test("Input-area with nested coqblock", () => { -// const content = "\n```coq\nTest\n```\nHello"; -// const nodes = createTestMapping(content).root.children; +test("Input-area with nested coqblock", () => { + // \n```coq\nTest\n```\nHello + const blocks = [ + new InputAreaBlock("```coq\nTest\n```", {from: 0, to: 42}, {from: 12, to: 29}, [ + new NewlineBlock({from: 12, to: 13}, {from: 12, to: 13}), + new CodeBlock("Test", {from: 13, to: 28}, {from: 20, to: 24}), + new NewlineBlock({from: 28, to: 29}, {from: 28, to: 29}) + ]), + new MarkdownBlock("Hello", {from: 42, to: 47}, {from: 42, to: 47}) + ]; + const nodes = createTestMapping(blocks).root.children; + + expect(nodes.length).toBe(2); -// expect(nodes.length).toBe(2); + // Input-area node + const inputAreaNode = nodes[0]; + expect(inputAreaNode.type).toBe("input"); + expect(inputAreaNode.innerRange.from).toBe(12); + expect(inputAreaNode.innerRange.to).toBe(29); + expect(inputAreaNode.prosemirrorStart).toBe(1); + expect(inputAreaNode.prosemirrorEnd).toBe(9); + + // Markdown node + const markdownNode = nodes[1]; + expect(markdownNode.type).toBe("markdown"); + expect(markdownNode.innerRange).toStrictEqual({from: 42, to: 47}); + expect(markdownNode.range).toStrictEqual({from: 42, to: 47}); + expect(markdownNode.prosemirrorStart).toBe(11); + expect(markdownNode.prosemirrorEnd).toBe(16); + + // Should be 3 children in the input area: newline, coqblock, newline + expect(inputAreaNode.children.length).toBe(3); + const [first, second, third] = inputAreaNode.children; -// // Input-area node -// const inputAreaNode = nodes[0]; -// expect(inputAreaNode.type).toBe("input"); -// expect(inputAreaNode.innerRange.from).toBe(12); -// expect(inputAreaNode.innerRange.to).toBe(29); -// expect(inputAreaNode.prosemirrorStart).toBe(1); -// expect(inputAreaNode.prosemirrorEnd).toBe(9); + expect(first.type).toBe("newline"); + expect(first.innerRange).toStrictEqual({from: 12, to: 13}); + expect(first.range).toStrictEqual({from: 12, to: 13}); + expect(first.prosemirrorStart).toBe(1); + expect(first.prosemirrorEnd).toBe(1); + expect(first.pmRange).toStrictEqual({from: 1, to: 2}); + + expect(second.type).toBe("code"); + expect(second.innerRange).toStrictEqual({from: 20, to: 24}); + expect(second.range).toStrictEqual({from: 13, to: 28}); + expect(second.prosemirrorStart).toBe(3); + expect(second.prosemirrorEnd).toBe(7); + expect(second.pmRange).toStrictEqual({from: 2, to: 8}); -// // Nested coqblock -// // const coqblockNode = nodes[3]; -// // console.log(nodes) -// // expect(coqblockNode.type).toBe("code"); -// // expect(coqblockNode.innerRange.from).toBe(20); -// // expect(coqblockNode.innerRange.to).toBe(24); -// // expect(coqblockNode.prosemirrorStart).toBe(3); -// // expect(coqblockNode.prosemirrorEnd).toBe(7); - -// }); - -// test("Hint block with coqblock and markdown inside", () => { -// const content = "\n```coq\nRequire Import Rbase.\n```\n"; -// const nodes = createTestMapping(content); + expect(third.type).toBe("newline"); + expect(third.innerRange).toStrictEqual({from: 28, to: 29}); + expect(third.range).toStrictEqual({from: 28, to: 29}); + expect(third.prosemirrorStart).toBe(8); + expect(third.prosemirrorEnd).toBe(8); + expect(third.pmRange).toStrictEqual({from: 8, to: 9}); +}); + +test("Hint block with coqblock and markdown inside", () => { + // \n```coq\nRequire Import Rbase.\n```\n + const blocks = [ + new HintBlock("\n```coq\nRequire Import Rbase.\n```\n", "Import libraries", {from: 0, to: 72}, {from: 31, to: 65}, [ + new NewlineBlock({from: 31, to: 32}, {from: 31, to: 32}), + new CodeBlock("Require Import Rbase.", {from: 32, to: 64}, {from: 39, to: 60}), + new NewlineBlock({from: 60, to: 61}, {from: 60, to: 61}) + ]) + ]; + + const nodes = createTestMapping(blocks).root.children; -// expect(nodes.length).toBe(3); + expect(nodes.length).toBe(1); + + // Hint node + const hintNode = nodes[0]; + expect(hintNode.type).toBe("hint"); + expect(hintNode.innerRange).toStrictEqual({from: 31, to: 65}); + expect(hintNode.range).toStrictEqual({from: 0, to: 72}); + expect(hintNode.prosemirrorStart).toBe(1); + expect(hintNode.prosemirrorEnd).toBe(26); + expect(hintNode.pmRange).toStrictEqual({from: 0, to: 27}); -// // Hint node -// const hintNode = nodes[1]; -// expect(hintNode.type).toBe("hint"); -// expect(hintNode.innerRange.from).toBe(31); -// expect(hintNode.innerRange.to).toBe(65); -// expect(hintNode.prosemirrorStart).toBe(1); -// expect(hintNode.prosemirrorEnd).toBe(31); -// // Nested coqblock -// const coqblockNode = nodes[2]; -// expect(coqblockNode.innerRange.from).toBe(39); -// expect(coqblockNode.innerRange.to).toBe(60); -// }); - -// test("Mixed content section", () => { -// const content = `### Example: -// \`\`\`coq -// Lemma -// Test -// \`\`\` -// -// \`\`\`coq -// (* Your solution here *) -// \`\`\` -// `; -// const nodes = createTestMapping(content); -// console.log(nodes) + // Should be 3 children in the hint: newline, coqblock, newline + expect(hintNode.children.length).toBe(3); + const [first, second, third] = hintNode.children; -// // Expected nodes: markdown (header), coqblock, input-area (with coqblock) -// expect(nodes.length).toBe(5); + expect(first.type).toBe("newline"); + expect(first.innerRange).toStrictEqual({from: 31, to: 32}); + expect(first.range).toStrictEqual({from: 31, to: 32}); + expect(first.prosemirrorStart).toBe(1); + expect(first.prosemirrorEnd).toBe(1); + expect(first.pmRange).toStrictEqual({from: 1, to: 2}); -// // Verify markdown header -// const headerNode = nodes[1]; -// expect(headerNode.type).toBe("markdown"); -// expect(headerNode.stringContent).toContain("### Example:"); -// expect(headerNode.innerRange.from).toBe(0) -// expect(headerNode.innerRange.to).toBe(12) -// expect(headerNode.prosemirrorStart).toBe(1) -// expect(headerNode.prosemirrorEnd).toBe(13) + expect(second.type).toBe("code"); + expect(second.innerRange).toStrictEqual({from: 39, to: 60}); + expect(second.range).toStrictEqual({from: 32, to: 64}); + expect(second.prosemirrorStart).toBe(3); + expect(second.prosemirrorEnd).toBe(24); + expect(second.pmRange).toStrictEqual({from: 2, to: 25}); -// // Example coqblock -// const exampleCoqblock = nodes[2]; -// expect(exampleCoqblock.type).toBe("code"); -// expect(exampleCoqblock.innerRange.from).toBe(20) -// expect(exampleCoqblock.innerRange.to).toBe(30) -// expect(exampleCoqblock.prosemirrorStart).toBe(15) -// expect(exampleCoqblock.prosemirrorEnd).toBe(25) + expect(third.type).toBe("newline"); + expect(third.innerRange).toStrictEqual({from: 60, to: 61}); + expect(third.range).toStrictEqual({from: 60, to: 61}); + expect(third.prosemirrorStart).toBe(25); + expect(third.prosemirrorEnd).toBe(25); + expect(third.pmRange).toStrictEqual({from: 25, to: 26}); +}); + +test("Mixed content: markdown, coqblock, input-area, markdown", () => { + // ### Example:\n```coq\nLemma\nTest\n```\n\n```coq\n(* Your solution here *)\n```\n + const blocks = [ + new MarkdownBlock("### Example:", {from: 0, to: 12}, {from: 0, to: 12}), + new NewlineBlock({from: 12, to: 13}, {from: 12, to: 13}), + new CodeBlock("Lemma\nTest", {from: 13, to: 34}, {from: 20, to: 30}), + new NewlineBlock({from: 34, to: 35}, {from: 34, to: 35}), + new InputAreaBlock("```coq\n(* Your solution here *)\n```", {from: 35, to: 97}, {from: 47, to: 84}, [ + new NewlineBlock({from: 47, to: 48}, {from: 47, to: 48}), + new CodeBlock("(* Your solution here *)", {from: 48, to: 83}, {from: 55, to: 79}), + new NewlineBlock({from: 83, to: 84}, {from: 83, to: 84}) + ]) + ]; + const nodes = createTestMapping(blocks).root.children; + + expect(nodes.length).toBe(5); + + const [md1, nl1, code1, nl2, ia] = nodes; + + // Markdown node + expect(md1.type).toBe("markdown"); + expect(md1.innerRange).toStrictEqual({from: 0, to: 12}); + expect(md1.range).toStrictEqual({from: 0, to: 12}); + expect(md1.prosemirrorStart).toBe(1); + expect(md1.prosemirrorEnd).toBe(13); + expect(md1.pmRange).toStrictEqual({from: 0, to: 14}); + + // Newline node + expect(nl1.type).toBe("newline"); + expect(nl1.innerRange).toStrictEqual({from: 12, to: 13}); + expect(nl1.range).toStrictEqual({from: 12, to: 13}); + expect(nl1.prosemirrorStart).toBe(14); + expect(nl1.prosemirrorEnd).toBe(14); + expect(nl1.pmRange).toStrictEqual({from: 14, to: 15}); + + // Coqblock node + expect(code1.type).toBe("code"); + expect(code1.innerRange).toStrictEqual({from: 20, to: 30}); + expect(code1.range).toStrictEqual({from: 13, to: 34}); + expect(code1.prosemirrorStart).toBe(16); + expect(code1.prosemirrorEnd).toBe(26); + expect(code1.pmRange).toStrictEqual({from: 15, to: 27}); + + // Newline node + expect(nl2.type).toBe("newline"); + expect(nl2.innerRange).toStrictEqual({from: 34, to: 35}); + expect(nl2.range).toStrictEqual({from: 34, to: 35}); + expect(nl2.prosemirrorStart).toBe(27); + expect(nl2.prosemirrorEnd).toBe(27); + expect(nl2.pmRange).toStrictEqual({from: 27, to: 28}); + + // Input-area node + expect(ia.type).toBe("input"); + expect(ia.innerRange).toStrictEqual({from: 47, to: 84}); + expect(ia.range).toStrictEqual({from: 35, to: 97}); + expect(ia.prosemirrorStart).toBe(29); + expect(ia.prosemirrorEnd).toBe(57); + expect(ia.pmRange).toStrictEqual({from: 28, to: 58}); + + // The input area should have 3 children: newline, code, newline + expect(ia.children.length).toBe(3); + const [ia_nl1, ia_code, ia_nl2] = ia.children; -// // Input-area -// const inputAreaNode = nodes[3]; -// expect(inputAreaNode.type).toBe("input_area"); + expect(ia_nl1.type).toBe("newline"); + expect(ia_nl1.innerRange).toStrictEqual({from: 47, to: 48}); + expect(ia_nl1.range).toStrictEqual({from: 47, to: 48}); + expect(ia_nl1.prosemirrorStart).toBe(29); + expect(ia_nl1.prosemirrorEnd).toBe(29); + expect(ia_nl1.pmRange).toStrictEqual({from: 29, to: 30}); -// // Nested coqblock inside input-area -// const nestedCoqblock = nodes[4]; -// expect(nestedCoqblock.type).toBe("code"); -// expect(nestedCoqblock.innerRange.from).toBe(55) -// expect(nestedCoqblock.innerRange.to).toBe(79) -// expect(nestedCoqblock.prosemirrorStart).toBe(28) -// expect(nestedCoqblock.prosemirrorEnd).toBe(52) -// }); - -// test("Empty coqblock", () => { -// const content = "```coq\n```"; -// const nodes = createTestMapping(content); + expect(ia_code.type).toBe("code"); + expect(ia_code.innerRange).toStrictEqual({from: 55, to: 79}); + expect(ia_code.range).toStrictEqual({from: 48, to: 83}); + expect(ia_code.prosemirrorStart).toBe(31); + expect(ia_code.prosemirrorEnd).toBe(55); + expect(ia_code.pmRange).toStrictEqual({from: 30, to: 56}); -// expect(nodes.length).toBe(2); + expect(ia_nl2.type).toBe("newline"); + expect(ia_nl2.innerRange).toStrictEqual({from: 83, to: 84}); + expect(ia_nl2.range).toStrictEqual({from: 83, to: 84}); + expect(ia_nl2.prosemirrorStart).toBe(56); + expect(ia_nl2.prosemirrorEnd).toBe(56); + expect(ia_nl2.pmRange).toStrictEqual({from: 56, to: 57}); +}); + +test("Empty coqblock", () => { + // ```coq\n\n``` + const blocks = [new CodeBlock("", {from: 0, to: 11}, {from: 7, to: 7})]; + const nodes = createTestMapping(blocks).root.children; + expect(nodes.length).toBe(1); -// // Child coqcode (empty) -// const coqcodeNode = nodes[1]; -// expect(coqcodeNode.stringContent).toBe(""); -// }); \ No newline at end of file + const coq = nodes[0]; + expect(coq.type).toBe("code"); + expect(coq.innerRange).toStrictEqual({from: 7, to: 7}); + expect(coq.range).toStrictEqual({from: 0, to: 11}); + expect(coq.prosemirrorStart).toBe(1); + expect(coq.prosemirrorEnd).toBe(1); + expect(coq.pmRange).toStrictEqual({from: 0, to: 2}); +}); \ No newline at end of file diff --git a/__tests__/mapping/nodeupdate.test.ts b/__tests__/mapping/nodeupdate.test.ts index 0a0c366..6d62599 100644 --- a/__tests__/mapping/nodeupdate.test.ts +++ b/__tests__/mapping/nodeupdate.test.ts @@ -31,6 +31,8 @@ test("Insert code underneath markdown", () => { }); expect(newTree.root.children.length).toBe(3); + + // TODO: Check new tree structure }); test("Insert code underneath markdown inside input area", () => { @@ -46,11 +48,13 @@ test("Insert code underneath markdown inside input area", () => { const step: ReplaceStep = new ReplaceStep(10, 10, slice); const nodeUpdate = new NodeUpdate(config, serializer); - const {newTree, result} = nodeUpdate.nodeUpdate(step, mapping); + const {result} = nodeUpdate.nodeUpdate(step, mapping); expect(result).toStrictEqual({ finalText: "\n```coq\n\n```", startInFile: 19, endInFile: 19 }); + + // TODO: Check new tree structure }); \ No newline at end of file diff --git a/__tests__/tree.test.ts b/__tests__/tree.test.ts index 19e1789..1f2070f 100644 --- a/__tests__/tree.test.ts +++ b/__tests__/tree.test.ts @@ -13,9 +13,9 @@ function fromJSON(json: any): Tree { obj.pmRange ); if (obj.children && Array.isArray(obj.children)) { - obj.children.forEach((childObj: any) => { + for (const childObj of obj.children) { node.addChild(parseNode(childObj)); - }); + } } return node; } @@ -29,9 +29,9 @@ function fromJSON(json: any): Tree { rootObj.prosemirrorEnd, rootObj.pmRange ); - rootObj.children.forEach((childObj: any) => { + for (const childObj of rootObj.children) { tree.root.addChild(parseNode(childObj)); - }); + } return tree; } @@ -155,7 +155,6 @@ const treeJSON = { test("treeFromJSON", () => { const tree = fromJSON(treeJSON); - // console.log(JSON.stringify(tree, null, 1)); expect(tree.root.children.length).toBe(5); expect(tree.root.children[0].type).toBe("markdown"); @@ -312,8 +311,6 @@ test("findByProsemirrorPosition with nested nodes", () => { expect(node1).not.toBeNull(); expect(node1?.type).toBe("newline"); - // expect(tree.findNodeByProsePos(7)?.type).toBe("newline"); - expect(tree.findNodeByProsePos(8)?.type).toBe("newline"); expect(tree.findNodeByProsePos(6)?.type).toBe("markdown"); diff --git a/src/api/types.ts b/src/api/types.ts index ae6e5be..3752e98 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -9,6 +9,8 @@ export type Positioned = { pos: number | undefined; }; +export type Range = {from: number, to: number}; + /** * A `WaterproofDocument` is a collection of `Block`s. Every Block in this WaterproofDocument will get translated into some ProseMirror node. * diff --git a/src/mapping/textUpdate.ts b/src/mapping/textUpdate.ts index 787428a..8cde960 100644 --- a/src/mapping/textUpdate.ts +++ b/src/mapping/textUpdate.ts @@ -34,7 +34,7 @@ export class TextUpdate { /** The offset within the correct stringCell for the step action */ const offsetEnd = step.to - targetCell.prosemirrorStart; - const text = step.slice.content.firstChild?.text === undefined ? "" : step.slice.content.firstChild.text; + const text = step.slice.content.firstChild?.text ?? ""; const offset = getTextOffset(type,step); diff --git a/src/serialization/DocumentSerializer.ts b/src/serialization/DocumentSerializer.ts index f68ff8b..d2804f0 100644 --- a/src/serialization/DocumentSerializer.ts +++ b/src/serialization/DocumentSerializer.ts @@ -71,7 +71,7 @@ export abstract class DocumentSerializer { case WaterproofSchema.nodes.text: return this.serializeText(node); case WaterproofSchema.nodes.newline: return this.serializeNewline(); default: - throw Error(`[SerializeNode] Node of type "${node.type.name}" not supported.`); + throw new Error(`[SerializeNode] Node of type "${node.type.name}" not supported.`); } } From e34413253fe48f1734b14d3aae8be03bd7c53c07 Mon Sep 17 00:00:00 2001 From: DikieDick <26772815+DikieDick@users.noreply.github.com> Date: Thu, 16 Oct 2025 20:02:26 +0200 Subject: [PATCH 39/50] Set sonar sources and tests --- sonar-project.properties | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/sonar-project.properties b/sonar-project.properties index 2df81de..bbb517a 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -8,9 +8,10 @@ sonar.organization=impermeable # Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows. -#sonar.sources=. +sonar.sources=src/ +sonar.tests=__tests__/ # Encoding of the source code. Default is default system encoding #sonar.sourceEncoding=UTF-8 -sonar.javascript.lcov.reportPaths=./coverage/lcov.info \ No newline at end of file +sonar.javascript.lcov.reportPaths=./coverage/lcov.info From d0f344b784edde08a689853584b3a12e55c8e85f Mon Sep 17 00:00:00 2001 From: DikieDick <26772815+DikieDick@users.noreply.github.com> Date: Fri, 17 Oct 2025 19:02:56 +0200 Subject: [PATCH 40/50] Add `neighbors` function to serializers. --- src/mapping/nodeUpdate.ts | 47 ++++++++++++++---- src/serialization/DocumentSerializer.ts | 66 +++++++++++++++---------- 2 files changed, 78 insertions(+), 35 deletions(-) diff --git a/src/mapping/nodeUpdate.ts b/src/mapping/nodeUpdate.ts index d7163bd..37768e5 100644 --- a/src/mapping/nodeUpdate.ts +++ b/src/mapping/nodeUpdate.ts @@ -102,12 +102,26 @@ export class NodeUpdate { const nodes: TreeNode[] = []; let serialized = ""; - step.slice.content.forEach((node, _, index) => { - const parent = step.slice.content; - const nodeBefore = parent.maybeChild(index - 1)?.type.name ?? null; - const nodeAfter = parent.maybeChild(index + 1)?.type.name ?? null; - // TODO: Here parent is null. - const output = this.serializer.serializeNode(node, null, nodeBefore, nodeAfter); + 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 output = this.serializer.serializeNode(node, parent.type, func); serialized += output; const builtNode = this.buildTreeFromNode(node, offsetOriginal, offsetProse); nodes.push(builtNode); @@ -178,11 +192,26 @@ export class NodeUpdate { const childTreeNode = this.buildTreeFromNode(child, childOffsetOriginal, childOffsetProse); treeNode.children.push(childTreeNode); - const nodeBefore = node.maybeChild(idx - 1)?.type.name ?? null; - const nodeAfter = node.maybeChild(idx + 1)?.type.name ?? null; + // 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; + + return {nodeAbove: above, nodeBelow: below}; + }; // Update the offsets for the next child - const serializedChild = this.serializer.serializeNode(child, node.type.name, nodeBefore, nodeAfter); + const serializedChild = this.serializer.serializeNode(child, node.type.name, func); childOffsetOriginal += serializedChild.length; childOffsetProse += child.nodeSize; }); diff --git a/src/serialization/DocumentSerializer.ts b/src/serialization/DocumentSerializer.ts index d2804f0..193ec06 100644 --- a/src/serialization/DocumentSerializer.ts +++ b/src/serialization/DocumentSerializer.ts @@ -7,46 +7,46 @@ export abstract class DocumentSerializer { * Describes how to turn a code node into a string representation. * @param codeNode The code node that is going to be serialized * @param parentNode The parent node of this node (if it has one) - * @param nodeAbove The node above this node (if there is one) - * @param nodeBelow The node below this node (if there is one) + * @param neighbors Function that upon calling will return the neighbors of the node being serialized. When `skipNewlines` is set and the *direct* neighbors + * of the node are newline nodes they will be skipped and the next nodes will be returned (if they exist). */ - abstract serializeCode(codeNode: Node, parentNode: string | null, nodeAbove: string | null, nodeBelow: string | null): string; + abstract serializeCode(codeNode: Node, parentNode: string | null, neighbors: (skipNewlines: boolean) => {nodeAbove: string | null, nodeBelow: string | null}): string; /** * Describes how to turn a math node into a string representation. * @param mathNode The math node that is going to be serialized * @param parentNode The parent node of this node (if it has one) - * @param nodeAbove The node above this node (if there is one) - * @param nodeBelow The node below this node (if there is one) + * @param neighbors Function that upon calling will return the neighbors of the node being serialized. When `skipNewlines` is set and the *direct* neighbors + * of the node are newline nodes they will be skipped and the next nodes will be returned (if they exist). */ - abstract serializeMath(mathNode: Node, parentNode: string | null, nodeAbove: string | null, nodeBelow: string | null): string; + abstract serializeMath(mathNode: Node, parentNode: string | null, neighbors: (skipNewlines: boolean) => {nodeAbove: string | null, nodeBelow: string | null}): string; /** * Describes how to turn a markdown node into a string representation. * @param codeNode The markdown node that is going to be serialized * @param parentNode The parent node of this node (if it has one) - * @param nodeAbove The node above this node (if there is one) - * @param nodeBelow The node below this node (if there is one) + * @param neighbors Function that upon calling will return the neighbors of the node being serialized. When `skipNewlines` is set and the *direct* neighbors + * of the node are newline nodes they will be skipped and the next nodes will be returned (if they exist). */ - abstract serializeMarkdown(markdownNode: Node, parentNode: string | null, nodeAbove: string | null, nodeBelow: string | null): string; + abstract serializeMarkdown(markdownNode: Node, parentNode: string | null, neighbors: (skipNewlines: boolean) => {nodeAbove: string | null, nodeBelow: string | null}): string; /** * Describes how to turn a input node into a string representation. * This node can have children, so you probably want to call `this.serializeNode` on every child node using * `inputNde.forEach((child) => {...})` * @param inputNode The input node that is going to be serialized * @param parentNode The parent node of this node (if it has one) - * @param nodeAbove The node above this node (if there is one) - * @param nodeBelow The node below this node (if there is one) + * @param neighbors Function that upon calling will return the neighbors of the node being serialized. When `skipNewlines` is set and the *direct* neighbors + * of the node are newline nodes they will be skipped and the next nodes will be returned (if they exist). */ - abstract serializeInput(inputNode: Node, parentNode: string | null, nodeAbove: string | null, nodeBelow: string | null): string; + abstract serializeInput(inputNode: Node, parentNode: string | null, neighbors: (skipNewlines: boolean) => {nodeAbove: string | null, nodeBelow: string | null}): string; /** * Describes how to turn a hint node into a string representation. This function can query the title of the node via * `hint.attrs.title`. This node can have children, so you probably want to call `this.serializeNode` on every child node using * `hintNode.forEach((child) => {...})` * @param hintNode The hint node that is going to be serialized * @param parentNode The parent node of this node (if it has one) - * @param nodeAbove The node above this node (if there is one) - * @param nodeBelow The node below this node (if there is one) + * @param neighbors Function that upon calling will return the neighbors of the node being serialized. When `skipNewlines` is set and the *direct* neighbors + * 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, nodeAbove: string | null, nodeBelow: string | null): string; + abstract serializeHint(hintNode: Node, parentNode: string | null, neighbors: (skipNewlines: boolean) => {nodeAbove: string | null, nodeBelow: string | null}): string; serializeText(node: Node): string { return node.textContent; @@ -61,13 +61,13 @@ export abstract class DocumentSerializer { * @param node * @returns */ - public serializeNode(node: Node, parent: string | null, nodeAbove: string | null, nodeBelow: string | null): string { + public serializeNode(node: Node, parent: string | null, neighbors: (skipNewlines: boolean) => {nodeAbove: string | null, nodeBelow: string | null}): string { switch (node.type) { - case WaterproofSchema.nodes.markdown: return this.serializeMarkdown(node, parent, nodeAbove, nodeBelow); - case WaterproofSchema.nodes.code: return this.serializeCode(node, parent, nodeAbove, nodeBelow); - case WaterproofSchema.nodes.math_display: return this.serializeMath(node, parent, nodeAbove, nodeBelow); - case WaterproofSchema.nodes.input: return this.serializeInput(node, parent, nodeAbove, nodeBelow); - case WaterproofSchema.nodes.hint: return this.serializeHint(node, parent, nodeAbove, nodeBelow); + case WaterproofSchema.nodes.markdown: return this.serializeMarkdown(node, parent, neighbors); + case WaterproofSchema.nodes.code: return this.serializeCode(node, parent, neighbors); + 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.text: return this.serializeText(node); case WaterproofSchema.nodes.newline: return this.serializeNewline(); default: @@ -82,9 +82,23 @@ export abstract class DocumentSerializer { public serializeDocument(node: Node) { const output: string[] = []; node.content.forEach((child, _, idx) => { - const nodeAbove = node.maybeChild(idx - 1); - const nodeBelow = node.maybeChild(idx + 1); - output.push(this.serializeNode(child, node.type.name, nodeAbove?.type.name ?? null, nodeBelow?.type.name ?? null)); + 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)); }); return output.join(""); } @@ -112,7 +126,7 @@ export class DefaultTagSerializer extends DocumentSerializer { // Has child content const textContent: string[] = []; node.forEach(child => { - const output = this.serializeNode(child, null, null, null); + const output = this.serializeNode(child, "input", () => {return {nodeAbove: null, nodeBelow: null}} ); textContent.push(output); }); return this.tagConf.input.openTag + textContent.join("") + this.tagConf.input.closeTag; @@ -123,7 +137,7 @@ export class DefaultTagSerializer extends DocumentSerializer { // Has child content const textContent: string[] = []; node.forEach(child => { - const output = this.serializeNode(child, null, null, null); + const output = this.serializeNode(child, "hint", () => {return {nodeAbove: null, nodeBelow: null}}); textContent.push(output); }); return this.tagConf.hint.openTag(title) + textContent.join("") + this.tagConf.hint.closeTag; From 51f0729554b84dfe6c05973690b2b31ce5e82965 Mon Sep 17 00:00:00 2001 From: DikieDick <26772815+DikieDick@users.noreply.github.com> Date: Mon, 27 Oct 2025 17:59:31 +0100 Subject: [PATCH 41/50] Export default tag serializer --- src/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index d842740..8630e23 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,9 @@ + // Export the Editor class export { WaterproofEditor } from "./editor"; export { WaterproofSchema } from "./schema"; export * from "./document"; export * from "./api"; export { defaultToMarkdown } from "./translation"; -export * as "markdown" from "./markdown-defaults"; \ No newline at end of file +export * as "markdown" from "./markdown-defaults"; +export { DefaultTagSerializer } from "./serialization/DocumentSerializer"; \ No newline at end of file From a2a3ce4cc4e67b7f238195845ead2f2a6e0b9263 Mon Sep 17 00:00:00 2001 From: DikieDick <26772815+DikieDick@users.noreply.github.com> Date: Fri, 5 Dec 2025 08:20:00 +0100 Subject: [PATCH 42/50] Rename innerRange and range to contentRange and tagRange respectively --- __tests__/mapping/newmapping.test.ts | 80 +++++++++++------------ __tests__/mapping/textupdate.test.ts | 32 ++++----- src/editor.ts | 10 +-- src/mapping/Tree.ts | 32 ++++----- src/mapping/index.ts | 2 +- src/mapping/{newmapping.ts => mapping.ts} | 14 ++-- src/mapping/nodeUpdate.ts | 36 +++++----- src/mapping/textUpdate.ts | 6 +- 8 files changed, 106 insertions(+), 106 deletions(-) rename src/mapping/{newmapping.ts => mapping.ts} (95%) diff --git a/__tests__/mapping/newmapping.test.ts b/__tests__/mapping/newmapping.test.ts index d65f760..eae5b1f 100644 --- a/__tests__/mapping/newmapping.test.ts +++ b/__tests__/mapping/newmapping.test.ts @@ -22,8 +22,8 @@ test("testMapping markdown only", () => { const markdownNode = nodes.root.children[0]; expect(markdownNode.type).toBe("markdown"); - expect(markdownNode.innerRange).toStrictEqual({from: 0, to: 5}); - expect(markdownNode.range).toStrictEqual({from: 0, to: 5}); + expect(markdownNode.contentRange).toStrictEqual({from: 0, to: 5}); + expect(markdownNode.tagRange).toStrictEqual({from: 0, to: 5}); expect(markdownNode.prosemirrorStart).toBe(1); expect(markdownNode.prosemirrorEnd).toBe(6); expect(markdownNode.pmRange).toStrictEqual({from: 0, to: 7}); @@ -38,8 +38,8 @@ test("testMapping coqblock with code", () => { // Parent coqblock const coqblockNode = nodes[0]; expect(coqblockNode.type).toBe("code"); - expect(coqblockNode.innerRange).toStrictEqual({from: 7, to: 17}); - expect(coqblockNode.range).toStrictEqual({from: 0, to: 21}); + expect(coqblockNode.contentRange).toStrictEqual({from: 7, to: 17}); + expect(coqblockNode.tagRange).toStrictEqual({from: 0, to: 21}); expect(coqblockNode.prosemirrorStart).toBe(1); expect(coqblockNode.prosemirrorEnd).toBe(11); expect(coqblockNode.pmRange).toStrictEqual({from: 0, to: 12}); @@ -62,16 +62,16 @@ test("Input-area with nested coqblock", () => { // Input-area node const inputAreaNode = nodes[0]; expect(inputAreaNode.type).toBe("input"); - expect(inputAreaNode.innerRange.from).toBe(12); - expect(inputAreaNode.innerRange.to).toBe(29); + expect(inputAreaNode.contentRange.from).toBe(12); + expect(inputAreaNode.contentRange.to).toBe(29); expect(inputAreaNode.prosemirrorStart).toBe(1); expect(inputAreaNode.prosemirrorEnd).toBe(9); // Markdown node const markdownNode = nodes[1]; expect(markdownNode.type).toBe("markdown"); - expect(markdownNode.innerRange).toStrictEqual({from: 42, to: 47}); - expect(markdownNode.range).toStrictEqual({from: 42, to: 47}); + expect(markdownNode.contentRange).toStrictEqual({from: 42, to: 47}); + expect(markdownNode.tagRange).toStrictEqual({from: 42, to: 47}); expect(markdownNode.prosemirrorStart).toBe(11); expect(markdownNode.prosemirrorEnd).toBe(16); @@ -80,22 +80,22 @@ test("Input-area with nested coqblock", () => { const [first, second, third] = inputAreaNode.children; expect(first.type).toBe("newline"); - expect(first.innerRange).toStrictEqual({from: 12, to: 13}); - expect(first.range).toStrictEqual({from: 12, to: 13}); + expect(first.contentRange).toStrictEqual({from: 12, to: 13}); + expect(first.tagRange).toStrictEqual({from: 12, to: 13}); expect(first.prosemirrorStart).toBe(1); expect(first.prosemirrorEnd).toBe(1); expect(first.pmRange).toStrictEqual({from: 1, to: 2}); expect(second.type).toBe("code"); - expect(second.innerRange).toStrictEqual({from: 20, to: 24}); - expect(second.range).toStrictEqual({from: 13, to: 28}); + expect(second.contentRange).toStrictEqual({from: 20, to: 24}); + expect(second.tagRange).toStrictEqual({from: 13, to: 28}); expect(second.prosemirrorStart).toBe(3); expect(second.prosemirrorEnd).toBe(7); expect(second.pmRange).toStrictEqual({from: 2, to: 8}); expect(third.type).toBe("newline"); - expect(third.innerRange).toStrictEqual({from: 28, to: 29}); - expect(third.range).toStrictEqual({from: 28, to: 29}); + expect(third.contentRange).toStrictEqual({from: 28, to: 29}); + expect(third.tagRange).toStrictEqual({from: 28, to: 29}); expect(third.prosemirrorStart).toBe(8); expect(third.prosemirrorEnd).toBe(8); expect(third.pmRange).toStrictEqual({from: 8, to: 9}); @@ -118,8 +118,8 @@ test("Hint block with coqblock and markdown inside", () => { // Hint node const hintNode = nodes[0]; expect(hintNode.type).toBe("hint"); - expect(hintNode.innerRange).toStrictEqual({from: 31, to: 65}); - expect(hintNode.range).toStrictEqual({from: 0, to: 72}); + expect(hintNode.contentRange).toStrictEqual({from: 31, to: 65}); + expect(hintNode.tagRange).toStrictEqual({from: 0, to: 72}); expect(hintNode.prosemirrorStart).toBe(1); expect(hintNode.prosemirrorEnd).toBe(26); expect(hintNode.pmRange).toStrictEqual({from: 0, to: 27}); @@ -129,22 +129,22 @@ test("Hint block with coqblock and markdown inside", () => { const [first, second, third] = hintNode.children; expect(first.type).toBe("newline"); - expect(first.innerRange).toStrictEqual({from: 31, to: 32}); - expect(first.range).toStrictEqual({from: 31, to: 32}); + expect(first.contentRange).toStrictEqual({from: 31, to: 32}); + expect(first.tagRange).toStrictEqual({from: 31, to: 32}); expect(first.prosemirrorStart).toBe(1); expect(first.prosemirrorEnd).toBe(1); expect(first.pmRange).toStrictEqual({from: 1, to: 2}); expect(second.type).toBe("code"); - expect(second.innerRange).toStrictEqual({from: 39, to: 60}); - expect(second.range).toStrictEqual({from: 32, to: 64}); + expect(second.contentRange).toStrictEqual({from: 39, to: 60}); + expect(second.tagRange).toStrictEqual({from: 32, to: 64}); expect(second.prosemirrorStart).toBe(3); expect(second.prosemirrorEnd).toBe(24); expect(second.pmRange).toStrictEqual({from: 2, to: 25}); expect(third.type).toBe("newline"); - expect(third.innerRange).toStrictEqual({from: 60, to: 61}); - expect(third.range).toStrictEqual({from: 60, to: 61}); + expect(third.contentRange).toStrictEqual({from: 60, to: 61}); + expect(third.tagRange).toStrictEqual({from: 60, to: 61}); expect(third.prosemirrorStart).toBe(25); expect(third.prosemirrorEnd).toBe(25); expect(third.pmRange).toStrictEqual({from: 25, to: 26}); @@ -171,40 +171,40 @@ test("Mixed content: markdown, coqblock, input-area, markdown", () => { // Markdown node expect(md1.type).toBe("markdown"); - expect(md1.innerRange).toStrictEqual({from: 0, to: 12}); - expect(md1.range).toStrictEqual({from: 0, to: 12}); + expect(md1.contentRange).toStrictEqual({from: 0, to: 12}); + expect(md1.tagRange).toStrictEqual({from: 0, to: 12}); expect(md1.prosemirrorStart).toBe(1); expect(md1.prosemirrorEnd).toBe(13); expect(md1.pmRange).toStrictEqual({from: 0, to: 14}); // Newline node expect(nl1.type).toBe("newline"); - expect(nl1.innerRange).toStrictEqual({from: 12, to: 13}); - expect(nl1.range).toStrictEqual({from: 12, to: 13}); + expect(nl1.contentRange).toStrictEqual({from: 12, to: 13}); + expect(nl1.tagRange).toStrictEqual({from: 12, to: 13}); expect(nl1.prosemirrorStart).toBe(14); expect(nl1.prosemirrorEnd).toBe(14); expect(nl1.pmRange).toStrictEqual({from: 14, to: 15}); // Coqblock node expect(code1.type).toBe("code"); - expect(code1.innerRange).toStrictEqual({from: 20, to: 30}); - expect(code1.range).toStrictEqual({from: 13, to: 34}); + expect(code1.contentRange).toStrictEqual({from: 20, to: 30}); + expect(code1.tagRange).toStrictEqual({from: 13, to: 34}); expect(code1.prosemirrorStart).toBe(16); expect(code1.prosemirrorEnd).toBe(26); expect(code1.pmRange).toStrictEqual({from: 15, to: 27}); // Newline node expect(nl2.type).toBe("newline"); - expect(nl2.innerRange).toStrictEqual({from: 34, to: 35}); - expect(nl2.range).toStrictEqual({from: 34, to: 35}); + expect(nl2.contentRange).toStrictEqual({from: 34, to: 35}); + expect(nl2.tagRange).toStrictEqual({from: 34, to: 35}); expect(nl2.prosemirrorStart).toBe(27); expect(nl2.prosemirrorEnd).toBe(27); expect(nl2.pmRange).toStrictEqual({from: 27, to: 28}); // Input-area node expect(ia.type).toBe("input"); - expect(ia.innerRange).toStrictEqual({from: 47, to: 84}); - expect(ia.range).toStrictEqual({from: 35, to: 97}); + expect(ia.contentRange).toStrictEqual({from: 47, to: 84}); + expect(ia.tagRange).toStrictEqual({from: 35, to: 97}); expect(ia.prosemirrorStart).toBe(29); expect(ia.prosemirrorEnd).toBe(57); expect(ia.pmRange).toStrictEqual({from: 28, to: 58}); @@ -214,22 +214,22 @@ test("Mixed content: markdown, coqblock, input-area, markdown", () => { const [ia_nl1, ia_code, ia_nl2] = ia.children; expect(ia_nl1.type).toBe("newline"); - expect(ia_nl1.innerRange).toStrictEqual({from: 47, to: 48}); - expect(ia_nl1.range).toStrictEqual({from: 47, to: 48}); + expect(ia_nl1.contentRange).toStrictEqual({from: 47, to: 48}); + expect(ia_nl1.tagRange).toStrictEqual({from: 47, to: 48}); expect(ia_nl1.prosemirrorStart).toBe(29); expect(ia_nl1.prosemirrorEnd).toBe(29); expect(ia_nl1.pmRange).toStrictEqual({from: 29, to: 30}); expect(ia_code.type).toBe("code"); - expect(ia_code.innerRange).toStrictEqual({from: 55, to: 79}); - expect(ia_code.range).toStrictEqual({from: 48, to: 83}); + expect(ia_code.contentRange).toStrictEqual({from: 55, to: 79}); + expect(ia_code.tagRange).toStrictEqual({from: 48, to: 83}); expect(ia_code.prosemirrorStart).toBe(31); expect(ia_code.prosemirrorEnd).toBe(55); expect(ia_code.pmRange).toStrictEqual({from: 30, to: 56}); expect(ia_nl2.type).toBe("newline"); - expect(ia_nl2.innerRange).toStrictEqual({from: 83, to: 84}); - expect(ia_nl2.range).toStrictEqual({from: 83, to: 84}); + expect(ia_nl2.contentRange).toStrictEqual({from: 83, to: 84}); + expect(ia_nl2.tagRange).toStrictEqual({from: 83, to: 84}); expect(ia_nl2.prosemirrorStart).toBe(56); expect(ia_nl2.prosemirrorEnd).toBe(56); expect(ia_nl2.pmRange).toStrictEqual({from: 56, to: 57}); @@ -243,8 +243,8 @@ test("Empty coqblock", () => { const coq = nodes[0]; expect(coq.type).toBe("code"); - expect(coq.innerRange).toStrictEqual({from: 7, to: 7}); - expect(coq.range).toStrictEqual({from: 0, to: 11}); + expect(coq.contentRange).toStrictEqual({from: 7, to: 7}); + expect(coq.tagRange).toStrictEqual({from: 0, to: 11}); expect(coq.prosemirrorStart).toBe(1); expect(coq.prosemirrorEnd).toBe(1); expect(coq.pmRange).toStrictEqual({from: 0, to: 2}); diff --git a/__tests__/mapping/textupdate.test.ts b/__tests__/mapping/textupdate.test.ts index 654a5b8..7691f79 100644 --- a/__tests__/mapping/textupdate.test.ts +++ b/__tests__/mapping/textupdate.test.ts @@ -23,10 +23,10 @@ test("ReplaceStep insert — inserts text into a block", () => { const {newTree, result} = textUpdate.textUpdate(step, mapping); const md = newTree.root.children[0]; - expect(md.innerRange.from).toBe(0); - expect(md.innerRange.to).toBe(11); - expect(md.range.from).toBe(0); - expect(md.range.to).toBe(11); + expect(md.contentRange.from).toBe(0); + expect(md.contentRange.to).toBe(11); + expect(md.tagRange.from).toBe(0); + expect(md.tagRange.to).toBe(11); expect(md.prosemirrorStart).toBe(1); expect(md.prosemirrorEnd).toBe(12); @@ -48,10 +48,10 @@ test("ReplaceStep insert — inserts text in the middle of a block", () => { const md = newTree.root.children[0]; - expect(md.innerRange.from).toBe(0); - expect(md.innerRange.to).toBe(15); - expect(md.range.from).toBe(0); - expect(md.range.to).toBe(15); + expect(md.contentRange.from).toBe(0); + expect(md.contentRange.to).toBe(15); + expect(md.tagRange.from).toBe(0); + expect(md.tagRange.to).toBe(15); expect(md.prosemirrorStart).toBe(1); expect(md.prosemirrorEnd).toBe(16); @@ -69,10 +69,10 @@ test("ReplaceStep delete — deletes part of a block", () => { const {newTree, result} = textUpdate.textUpdate(step, mapping); const md = newTree.root.children[0]; - expect(md.innerRange.from).toBe(0); - expect(md.innerRange.to).toBe(6); - expect(md.range.from).toBe(0); - expect(md.range.to).toBe(6); + expect(md.contentRange.from).toBe(0); + expect(md.contentRange.to).toBe(6); + expect(md.tagRange.from).toBe(0); + expect(md.tagRange.to).toBe(6); expect(md.prosemirrorStart).toBe(1); expect(md.prosemirrorEnd).toBe(7); @@ -92,10 +92,10 @@ test("ReplaceStep replace — replaces part of a block", () => { const {newTree, result} = textUpdate.textUpdate(step, mapping); const md = newTree.root.children[0]; - expect(md.innerRange.from).toBe(0); - expect(md.innerRange.to).toBe(11); - expect(md.range.from).toBe(0); - expect(md.range.to).toBe(11); + expect(md.contentRange.from).toBe(0); + expect(md.contentRange.to).toBe(11); + expect(md.tagRange.from).toBe(0); + expect(md.tagRange.to).toBe(11); expect(md.prosemirrorStart).toBe(1); expect(md.prosemirrorEnd).toBe(12); diff --git a/src/editor.ts b/src/editor.ts index f013fb7..ab56286 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -336,7 +336,7 @@ export class WaterproofEditor { // If this is not a cursor update return if (!(pos instanceof TextSelection)) return; if (this._mapping === undefined) throw new Error(" Mapping is undefined, cannot synchronize with vscode"); - this._editorConfig.api.cursorChange(this._mapping.findPosition(pos.$head.pos)); + this._editorConfig.api.cursorChange(this._mapping.pmIndexToTextOffset(pos.$head.pos)); } /** Called on every transaction update in which the textdocument was modified */ @@ -483,13 +483,13 @@ export class WaterproofEditor { // Translate postions to line/offset let offsetStart; try { - offsetStart = this._mapping?.findPosition(pmOffsetStart); + offsetStart = this._mapping?.pmIndexToTextOffset(pmOffsetStart); } catch { offsetStart = pmOffsetStart; } let offsetEnd; try { - offsetEnd = this._mapping?.findPosition(pmOffsetEnd); + offsetEnd = this._mapping?.pmIndexToTextOffset(pmOffsetEnd); } catch { offsetEnd = pmOffsetEnd; } @@ -660,8 +660,8 @@ export class WaterproofEditor { const doc = this._view.state.doc; this.currentProseDiagnostics = new Array(); for (const diag of diagnostics) { - const start = map.findInvPosition(diag.startOffset); - const end = map.findInvPosition(diag.endOffset); + const start = map.pmIndexToTextOffset(diag.startOffset); + const end = map.pmIndexToTextOffset(diag.endOffset); if (start >= end) continue; this.currentProseDiagnostics.push({ message: diag.message, diff --git a/src/mapping/Tree.ts b/src/mapping/Tree.ts index fb31d01..1ea273f 100644 --- a/src/mapping/Tree.ts +++ b/src/mapping/Tree.ts @@ -2,9 +2,9 @@ export class TreeNode { /** The type of this node, should be in the WaterproofSchema schema */ type: string; /** The inner range of the node, that is, the range of the content */ - innerRange: {to: number, from: number}; + contentRange: {to: number, from: number}; /** The outer range of the node, that is, the range of the content including possible tags */ - range: {to: number, from: number}; + tagRange: {to: number, from: number}; /** The title of a node, only relevant for hint nodes */ title: string; /** The computed start position in ProseMirror, this is the prosemirror position at which the content starts. @@ -20,16 +20,16 @@ export class TreeNode { constructor( type: string, - innerRange: {to: number, from: number}, - range: {to: number, from: number}, + contentRange: {to: number, from: number}, + tagRange: {to: number, from: number}, title: string, prosemirrorStart: number, prosemirrorEnd: number, pmRange: {to: number, from: number}, ) { this.type = type; - this.innerRange = innerRange; - this.range = range; + this.contentRange = contentRange; + this.tagRange = tagRange; this.title = title; this.prosemirrorStart = prosemirrorStart; this.prosemirrorEnd = prosemirrorEnd; @@ -50,8 +50,8 @@ export class TreeNode { shiftCloseOffsets(offset: number, offsetProsemirror?: number): void { this.prosemirrorEnd += offsetProsemirror ?? offset; this.pmRange.to += offsetProsemirror ?? offset; - this.innerRange.to += offset; - this.range.to += offset; + this.contentRange.to += offset; + this.tagRange.to += offset; } shiftOffsets(offset: number, offsetProsemirror?: number): void { @@ -59,10 +59,10 @@ export class TreeNode { this.prosemirrorEnd += offsetProsemirror ?? offset; this.pmRange.from += offsetProsemirror ?? offset; this.pmRange.to += offsetProsemirror ?? offset; - this.innerRange.from += offset; - this.innerRange.to += offset; - this.range.from += offset; - this.range.to += offset; + this.contentRange.from += offset; + this.contentRange.to += offset; + this.tagRange.from += offset; + this.tagRange.to += offset; } traverseDepthFirst(callback: (node: TreeNode) => void): void { @@ -78,7 +78,7 @@ export class Tree { constructor( type: string, - innerRange: {from: number, to: number}, + contentRange: {from: number, to: number}, range: {from: number, to: number}, title: string, prosemirrorStart: number, @@ -86,7 +86,7 @@ export class Tree { pmRange: {from: number, to: number} ) { // Explicitly create new ranges for the TreeNode to avoid shared references - this.root = new TreeNode(type, {from: innerRange.from, to: innerRange.to}, {from: range.from, to: range.to}, title, prosemirrorStart, prosemirrorEnd, {from: pmRange.from, to: pmRange.to}); + this.root = new TreeNode(type, {from: contentRange.from, to: contentRange.to}, {from: range.from, to: range.to}, title, prosemirrorStart, prosemirrorEnd, {from: pmRange.from, to: pmRange.to}); } traverseDepthFirst(callback: (node: TreeNode) => void, node: TreeNode = this.root): void { @@ -133,7 +133,7 @@ export class Tree { findNodeByOriginalPosition(pos: number, node: TreeNode | null = this.root): TreeNode | null { if (!node) return null; - if (pos >= node.innerRange.from && pos <= node.innerRange.to) { + if (pos >= node.contentRange.from && pos <= node.contentRange.to) { for (const child of node.children) { const result = this.findNodeByOriginalPosition(pos, child); if (result) return result; @@ -202,7 +202,7 @@ export class Tree { if (!this.root) return false; for (const rootNode of this.root.children) { - if (newNode.innerRange.from >= rootNode.innerRange.from && newNode.innerRange.to <= rootNode.innerRange.to) { + if (newNode.contentRange.from >= rootNode.contentRange.from && newNode.contentRange.to <= rootNode.contentRange.to) { rootNode.addChild(newNode); return true; } diff --git a/src/mapping/index.ts b/src/mapping/index.ts index f1a02f9..104a877 100644 --- a/src/mapping/index.ts +++ b/src/mapping/index.ts @@ -1,4 +1,4 @@ // Export the mapping -export { Mapping } from "./newmapping"; +export { Mapping } from "./mapping"; export { Tree } from "./Tree"; export { TreeNode } from "./Tree"; \ No newline at end of file diff --git a/src/mapping/newmapping.ts b/src/mapping/mapping.ts similarity index 95% rename from src/mapping/newmapping.ts rename to src/mapping/mapping.ts index 8ee3a56..3f8b79e 100644 --- a/src/mapping/newmapping.ts +++ b/src/mapping/mapping.ts @@ -32,8 +32,8 @@ export class Mapping { this._version = versionNum; this.tree = new Tree( "", // type - { from: 0, to: inputBlocks.at(-1)!.range.to }, // innerRange - { from: 0, to: inputBlocks.at(-1)!.range.to }, // range + { from: 0, to: inputBlocks.at(-1)!.range.to }, // contentRange + { from: 0, to: inputBlocks.at(-1)!.range.to }, // tagRange "", // title 0, // prosemirrorStart 0, // prosemirrorEnd @@ -59,10 +59,10 @@ export class Mapping { } /** Returns the vscode document model index of prosemirror index */ - public findPosition(index: number) { + public pmIndexToTextOffset(index: number) { const node = this.tree.findNodeByProsePos(index); if (node === null) throw new MappingError(` [findPosition] The vscode document offset for prosemirror index (${index}) could not be found `); - return (index - node.prosemirrorStart) + node.innerRange.from; + return (index - node.prosemirrorStart) + node.contentRange.from; } /** @@ -70,10 +70,10 @@ export class Mapping { * @param offset The offset (in characters) in the document. * @returns The corresponding prosemirror index. */ - public findInvPosition(offset: number) { + public textOffsetToPmIndex(offset: number) { const correctNode: TreeNode | null = this.tree.findNodeByOriginalPosition(offset); if (correctNode === null) throw new MappingError(` [findInvPosition] The prosemirror index for offset (${offset}) could not be found `); - return (offset - correctNode.innerRange.from) + correctNode.prosemirrorStart; + return (offset - correctNode.contentRange.from) + correctNode.prosemirrorStart; } public update(step: Step, doc: Node): DocChange | WrappingDocChange { @@ -210,7 +210,7 @@ export class Mapping { if (node.children.length === 0) { // Leaf: add length of content + end tag + +1 for exiting level - offset += (node.innerRange.to - node.innerRange.from); + offset += (node.contentRange.to - node.contentRange.from); } else { // Non-leaf: handle children and end tag for (const child of node.children) { diff --git a/src/mapping/nodeUpdate.ts b/src/mapping/nodeUpdate.ts index 37768e5..c30a1d9 100644 --- a/src/mapping/nodeUpdate.ts +++ b/src/mapping/nodeUpdate.ts @@ -1,6 +1,6 @@ import { Tree, TreeNode } from "./Tree"; import { OperationType, ParsedStep } from "./types"; -import { Mapping } from "./newmapping"; +import { Mapping } from "./mapping"; import { typeFromStep } from "./helper-functions"; import { DocChange, DocumentSerializer, NodeUpdateError, TagConfiguration, WrappingDocChange } from "../api"; import { WaterproofSchema } from "../schema"; @@ -95,7 +95,7 @@ export class NodeUpdate { // Should we use the to position of the node we found? const useTo = nodeInTree.pmRange.to === step.from; - const documentPos = atZero ? 0 : (useTo ? nodeInTree.range.to : nodeInTree.range.from); + const documentPos = atZero ? 0 : (useTo ? nodeInTree.tagRange.to : nodeInTree.tagRange.from); let offsetProse = atZero ? 0 : (useTo ? nodeInTree.pmRange.to : nodeInTree.pmRange.from); let offsetOriginal = documentPos; @@ -217,8 +217,8 @@ export class NodeUpdate { }); // Now fill in the to positions for innerRange and range - treeNode.innerRange.to = childOffsetOriginal; - treeNode.range.to = childOffsetOriginal + closeTagForNode.length; + treeNode.contentRange.to = childOffsetOriginal; + treeNode.tagRange.to = childOffsetOriginal + closeTagForNode.length; treeNode.prosemirrorEnd = childOffsetProse; treeNode.pmRange.to = childOffsetProse + 1; return treeNode; @@ -239,8 +239,8 @@ export class NodeUpdate { if (node.prosemirrorStart >= step.from && node.prosemirrorEnd <= step.to) { nodesToDelete.push(node); - if (node.range.from < from) from = node.range.from; - if (node.range.to > to) to = node.range.to; + 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); @@ -303,13 +303,13 @@ export class NodeUpdate { // Create document change const docChange: WrappingDocChange = { firstEdit: { - startInFile: wrapperNode.range.from, - endInFile: wrapperNode.innerRange.from, + startInFile: wrapperNode.tagRange.from, + endInFile: wrapperNode.contentRange.from, finalText: "" }, secondEdit: { - startInFile: wrapperNode.innerRange.to, - endInFile: wrapperNode.range.to, + startInFile: wrapperNode.contentRange.to, + endInFile: wrapperNode.tagRange.to, finalText: "" } }; @@ -376,23 +376,23 @@ export class NodeUpdate { const docChange: WrappingDocChange = { firstEdit: { finalText: openTag, - startInFile: nodesBeingWrappedStart.range.from, - endInFile: nodesBeingWrappedStart.range.from, + startInFile: nodesBeingWrappedStart.tagRange.from, + endInFile: nodesBeingWrappedStart.tagRange.from, }, secondEdit: { finalText: closeTag, - startInFile: nodesBeingWrappedEnd.range.to, - endInFile: nodesBeingWrappedEnd.range.to + startInFile: nodesBeingWrappedEnd.tagRange.to, + endInFile: nodesBeingWrappedEnd.tagRange.to } }; // We now update the tree const positions = { - startFrom: nodesBeingWrappedStart.range.from, - startTo: nodesBeingWrappedStart.range.to, - endFrom: nodesBeingWrappedEnd.range.from, - endTo: nodesBeingWrappedEnd.range.to, + startFrom: nodesBeingWrappedStart.tagRange.from, + startTo: nodesBeingWrappedStart.tagRange.to, + endFrom: nodesBeingWrappedEnd.tagRange.from, + endTo: nodesBeingWrappedEnd.tagRange.to, proseStart: nodesBeingWrappedStart.pmRange.from, proseEnd: nodesBeingWrappedEnd.pmRange.to }; diff --git a/src/mapping/textUpdate.ts b/src/mapping/textUpdate.ts index 8cde960..add14f4 100644 --- a/src/mapping/textUpdate.ts +++ b/src/mapping/textUpdate.ts @@ -1,4 +1,4 @@ -import { Mapping } from "./newmapping"; +import { Mapping } from "./mapping"; import { ParsedStep, OperationType } from "./types"; import { TreeNode } from "./Tree"; import { typeFromStep } from "./helper-functions"; @@ -40,8 +40,8 @@ export class TextUpdate { /** The resulting document change to document model */ const result: DocChange = { - startInFile: targetCell.innerRange.from + offsetBegin, - endInFile: targetCell.innerRange.from + offsetEnd, + startInFile: targetCell.contentRange.from + offsetBegin, + endInFile: targetCell.contentRange.from + offsetEnd, finalText: text } From 5bb69db94adbcc9d2386cf67dea0f963ce432bd2 Mon Sep 17 00:00:00 2001 From: DikieDick <26772815+DikieDick@users.noreply.github.com> Date: Fri, 5 Dec 2025 09:34:16 +0100 Subject: [PATCH 43/50] Update documentation --- src/mapping/mapping.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/mapping/mapping.ts b/src/mapping/mapping.ts index 3f8b79e..6572eed 100644 --- a/src/mapping/mapping.ts +++ b/src/mapping/mapping.ts @@ -22,9 +22,8 @@ export class Mapping { private readonly textUpdate: TextUpdate; /** - * Constructs a prosemirror view vscode mapping for the inputted prosemirror html element - * - * @param inputBlocks a string containing the prosemirror content html element + * Constructs the mapping instance given the source document in the form of a block array. + * @param inputBlocks Array containing the blocks that make up this document. */ constructor(inputBlocks: Block[], versionNum: number, tMap: TagConfiguration, serializer: DocumentSerializer) { this.textUpdate = new TextUpdate(); @@ -58,7 +57,11 @@ export class Mapping { return this._version; } - /** Returns the vscode document model index of prosemirror index */ + /** + * Map a ProseMirror index into the corresponding text offset. + * @param index A valid ProseMirror offset. + * @returns The corresponding text offset into the document. + */ public pmIndexToTextOffset(index: number) { const node = this.tree.findNodeByProsePos(index); if (node === null) throw new MappingError(` [findPosition] The vscode document offset for prosemirror index (${index}) could not be found `); @@ -66,9 +69,9 @@ export class Mapping { } /** - * Returns the prosemirror index corresponding to the given document offset. + * Map a text offset into the corresponding ProseMirror index. * @param offset The offset (in characters) in the document. - * @returns The corresponding prosemirror index. + * @returns The corresponding ProseMirror index into the ProseMirror view. */ public textOffsetToPmIndex(offset: number) { const correctNode: TreeNode | null = this.tree.findNodeByOriginalPosition(offset); @@ -80,7 +83,7 @@ export class Mapping { if (!(step instanceof ReplaceStep || step instanceof ReplaceAroundStep)) throw new MappingError("Step update (in textDocMapping) should not be called with a non document changing step"); - /** Check whether the edit is a text edit */ + // Check whether the edit is a text edit let isText: boolean; if (step.slice.content.firstChild?.type === WaterproofSchema.nodes.text) { // Short circuit when the content is a text node. This is the case for simple text insertions @@ -99,7 +102,7 @@ export class Mapping { let result: ParsedStep; - /** Parse the step into a text document change */ + // 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); From eddd26cbbdef21e3495c8646c0613ca21c171562 Mon Sep 17 00:00:00 2001 From: XyntaxCS Date: Sun, 7 Dec 2025 16:59:08 +0100 Subject: [PATCH 44/50] small fix for nodeupdate such that the position updates actually happen to the right nodes --- src/mapping/nodeUpdate.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/mapping/nodeUpdate.ts b/src/mapping/nodeUpdate.ts index c30a1d9..b6659e9 100644 --- a/src/mapping/nodeUpdate.ts +++ b/src/mapping/nodeUpdate.ts @@ -141,13 +141,13 @@ export class NodeUpdate { // 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 >= nodeInTree.pmRange.to) { + if (thisNode.pmRange.from > step.to) { thisNode.shiftOffsets(textOffset, proseOffset); } // 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 <= nodeInTree.pmRange.from && thisNode.pmRange.to >= nodeInTree.pmRange.to) { + if (thisNode.pmRange.from < step.from && thisNode.pmRange.to > step.to) { thisNode.shiftCloseOffsets(textOffset, proseOffset); } }); From 50d9cafec83e1f74e585527f2ce1312fd8643c4b Mon Sep 17 00:00:00 2001 From: DikieDick <26772815+DikieDick@users.noreply.github.com> Date: Mon, 8 Dec 2025 18:46:43 +0100 Subject: [PATCH 45/50] Use a better way to conditionally use debug functions --- __tests__/diagnostics.test.ts | 8 ++++---- esbuild.mjs | 4 +--- src/debugConfig.ts | 2 -- src/editor.ts | 14 +++++++------- src/menubar/menubar.ts | 7 +++---- 5 files changed, 15 insertions(+), 20 deletions(-) delete mode 100644 src/debugConfig.ts diff --git a/__tests__/diagnostics.test.ts b/__tests__/diagnostics.test.ts index 71862f5..ffcc05e 100644 --- a/__tests__/diagnostics.test.ts +++ b/__tests__/diagnostics.test.ts @@ -3,9 +3,6 @@ */ import { expect } from "@jest/globals"; -jest.mock('../src/debugConfig', () => ({ - debugMode: false -})); jest.mock("prosemirror-dev-tools", () => ({ applyDevTools: () => {} })); // We mock the mapping in order to not test against a possibly faulty mapping implementation @@ -18,9 +15,12 @@ jest.mock('../src/mapping/mapping', () => { pmIndexToTextOffset = (x: number) => x; textOffsetToPmIndex = (x: number) => x; } - }; +}; }); +// Note that this prevents console log statements from showing +jest.spyOn(global.console, "log").mockImplementation(); + import { DiagnosticObjectProse, WaterproofEditor } from "../src/editor"; import { configuration } from "../src/markdown-defaults"; import { OffsetDiagnostic, Severity, ThemeStyle, WaterproofEditorConfig } from "../src/api"; diff --git a/esbuild.mjs b/esbuild.mjs index 935a43b..f4c75b2 100644 --- a/esbuild.mjs +++ b/esbuild.mjs @@ -23,9 +23,7 @@ const sharedConfig = { ".ttf": fontLoader, ".grammar": "file" }, - define: { - "DEBUG": debugBuild ? "true" : "false" - }, + dropLabels: debugBuild ? [] : ["DEBUG"], minify, plugins: [ { diff --git a/src/debugConfig.ts b/src/debugConfig.ts deleted file mode 100644 index 4e8c983..0000000 --- a/src/debugConfig.ts +++ /dev/null @@ -1,2 +0,0 @@ -//@ts-expect-error Defined by esbuild at compile time -export const debugMode: boolean = DEBUG; \ No newline at end of file diff --git a/src/editor.ts b/src/editor.ts index 2d1ab11..4dc824f 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -36,10 +36,6 @@ import { InsertionPlace } from "./commands"; import { deleteSelection } from "./commands/commands"; import { Mapping } from "./mapping"; -//@ts-expect-error No types for this import, but only used in debug mode -import { applyDevTools } from "prosemirror-dev-tools"; -import { debugMode } from "./debugConfig"; - /** Type that contains a coq diagnostics object fit for use in the ProseMirror editor context. */ export type DiagnosticObjectProse = {message: string, start: number, end: number, severity: Severity}; @@ -228,10 +224,14 @@ export class WaterproofEditor { } }); this._view = view; - - if (debugMode) { + + // The DEBUG label will be dropped in case we are *not* in debug mode. + // eslint-disable-next-line no-unused-labels + DEBUG: { console.log("\x1b[33m[DEBUG]\x1b[0m Debug mode enabled. We will attach pm-dev-tools"); - applyDevTools(view); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const devTools = require("prosemirror-dev-tools"); + devTools.applyDevTools(view); } } diff --git a/src/menubar/menubar.ts b/src/menubar/menubar.ts index f4d2819..1066a9f 100644 --- a/src/menubar/menubar.ts +++ b/src/menubar/menubar.ts @@ -6,7 +6,6 @@ import { InsertionPlace, wrapInHint, wrapInInput, deleteSelection, wpLift } from import { OS } from "../osType"; import { getCmdInsertCode, getCmdInsertLatex, getCmdInsertMarkdown } from "../commands/insert-command"; import { TagConfiguration } from "../api"; -import { debugMode } from "../debugConfig"; /** MenuEntry type contains the DOM, whether to only show it in teacher mode and the command to execute on click */ type MenuEntry = { @@ -179,9 +178,9 @@ function createDefaultMenu(outerView: EditorView, os: OS, tagConf: TagConfigurat createMenuItem("🗑️", "Delete selection", teacherOnlyWrapper(deleteSelection(tagConf)), teacherOnly), ] - // If the DEBUG variable is set to `true` then we display a `dump` menu item, which outputs the current - // document in the console as a JSON object. - if (debugMode) { + // The DEBUG label will be dropped in case we are *not* in debug mode. + // eslint-disable-next-line no-unused-labels + DEBUG: { items.push(createMenuItem("DUMP DOC", "", (state, dispatch) => { if (dispatch) console.log("\x1b[33m[DEBUG]\x1b[0m dumped doc", JSON.stringify(state.doc.toJSON())); return true; From d11399be166d92475cd231d6866181fd9411a68b Mon Sep 17 00:00:00 2001 From: DikieDick <26772815+DikieDick@users.noreply.github.com> Date: Thu, 11 Dec 2025 10:25:14 +0100 Subject: [PATCH 46/50] Fix bug after rename of mapping functions --- src/editor.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/editor.ts b/src/editor.ts index 4dc824f..8a53569 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -347,11 +347,13 @@ export class WaterproofEditor { public sendLineNumbers() { if (!this._lineNumbersShown) return; if (!this._view || CODE_PLUGIN_KEY.getState(this._view.state) === undefined) return; + if (this._mapping === undefined) return; const linenumbers = Array(); // @ts-expect-error TODO: Fix me for (const codeCell of CODE_PLUGIN_KEY.getState(this._view.state).activeNodeViews) { - // @ts-expect-error TODO: Fix me - linenumbers.push(this._mapping?.findPosition(codeCell._getPos() + 1)); + linenumbers.push(this._mapping.pmIndexToTextOffset( + //@ts-expect-error Fix the fact that _getPos can return undefined + codeCell._getPos() + 1)); } if (this._mapping === undefined) { // Fail when the mapping is undefined From 2058fe2e73a5b1ff1ca6ea0a231ec06e61d93a28 Mon Sep 17 00:00:00 2001 From: DikieDick <26772815+DikieDick@users.noreply.github.com> Date: Fri, 12 Dec 2025 09:54:34 +0100 Subject: [PATCH 47/50] Add dump state option to menubar and first test for commands --- __tests__/commands/insert-commands.test.ts | 103 +++++++++++++++++++++ src/menubar/menubar.ts | 4 + 2 files changed, 107 insertions(+) create mode 100644 __tests__/commands/insert-commands.test.ts diff --git a/__tests__/commands/insert-commands.test.ts b/__tests__/commands/insert-commands.test.ts new file mode 100644 index 0000000..2d79e9d --- /dev/null +++ b/__tests__/commands/insert-commands.test.ts @@ -0,0 +1,103 @@ +/** + * @jest-environment jsdom +*/ + +import { EditorState, TextSelection } from "prosemirror-state"; +import { EditorView } from "prosemirror-view"; +import { WaterproofSchema } from "../../src/schema"; +import { getCmdInsertCode } from "../../src/commands/insert-command"; +import { InsertionPlace } from "../../src/commands"; +import { configuration } from "../../src/markdown-defaults"; + +const state = {"doc":{"type":"doc","content":[{"type":"code","content":[{"type":"text","text":"Goal True."}]},{"type":"newline"},{"type":"code","content":[{"type":"text","text":"Goal False."}]}]},"selection":{"type":"text","anchor":25,"head":25}}; +const tagConf = configuration("") + +// Mock the plugin key to always return state teacher=true +jest.mock('../../src/inputArea.ts', () => ({ + INPUT_AREA_PLUGIN_KEY: { + getState: jest.fn(() => ({ teacher: true })) + } +})); + +test("Insert code below twice (selection static)", () => { + const view = new EditorView(null, {state: EditorState.fromJSON({schema: WaterproofSchema}, state)}); + + const cmd = getCmdInsertCode(InsertionPlace.Below, tagConf); + const res = cmd(view.state, view.dispatch, view); + + // We expect this to be true. Could be false in the case we are not in teacher-mode and hence not allowed to insert or when + // something goes wrong with creating the editor. + expect(res).toBe(true); + + const newState = {"doc":{"type":"doc", + "content":[ + {"type":"code","content":[{"type":"text","text":"Goal True."}]}, + {"type":"newline"}, + {"type":"code","content":[{"type":"text","text":"Goal False."}]}, + {"type": "newline" }, {"type": "code" } // Should have added a newline node and a code node + ]}, + "selection":{"type":"text","anchor":25,"head":25}}; + expect(view.state.toJSON()).toStrictEqual(newState); + + const res2 = cmd(view.state, view.dispatch, view); + expect(res2).toBe(true); + const newState2 = {"doc":{"type":"doc", + "content":[ + {"type":"code","content":[{"type":"text","text":"Goal True."}]}, + {"type":"newline"}, + {"type":"code","content":[{"type":"text","text":"Goal False."}]}, + {"type": "newline" }, {"type": "code"}, // Should have added a newline node and a code node + {"type": "newline" }, {"type": "code"} + ]}, + "selection":{"type":"text","anchor":25,"head":25}}; + expect(view.state.toJSON()).toStrictEqual(newState2); +}); + +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); + const res = cmd(view.state, view.dispatch, view); + + // We expect this to be true. Could be false in the case we are not in teacher-mode and hence not allowed to insert or when + // something goes wrong with creating the editor. + expect(res).toBe(true); + + const newState = {"doc":{"type":"doc", + "content":[ + {"type":"code","content":[{"type":"text","text":"Goal True."}]}, + {"type":"newline"}, + {"type":"code","content":[{"type":"text","text":"Goal False."}]}, + {"type": "newline" }, {"type": "code" } // Should have added a newline node and a code node + ]}, + "selection":{"type":"text","anchor":25,"head":25}}; + expect(view.state.toJSON()).toStrictEqual(newState); + + const {tr, doc} = view.state; + const $from = doc.resolve(28); + view.dispatch(tr.setSelection(new TextSelection($from))); + + const stateAfterSelUpdate = {"doc":{"type":"doc", + "content":[ + {"type":"code","content":[{"type":"text","text":"Goal True."}]}, + {"type":"newline"}, + {"type":"code","content":[{"type":"text","text":"Goal False."}]}, + {"type": "newline" }, {"type": "code" } + ]}, + "selection":{"type":"text","anchor":28,"head":28}}; // Selection moved into the new code node + + expect(view.state.toJSON()).toStrictEqual(stateAfterSelUpdate); + + const res2 = cmd(view.state, view.dispatch, view); + expect(res2).toBe(true); + const newState2 = {"doc":{"type":"doc", + "content":[ + {"type":"code","content":[{"type":"text","text":"Goal True."}]}, + {"type":"newline"}, + {"type":"code","content":[{"type":"text","text":"Goal False."}]}, + {"type": "newline" }, {"type": "code"}, // Cursor was here + {"type": "newline" }, {"type": "code"} // Should have added a newline node and a code node + ]}, + "selection":{"type":"text","anchor":28,"head":28}}; + expect(view.state.toJSON()).toStrictEqual(newState2); +}); \ No newline at end of file diff --git a/src/menubar/menubar.ts b/src/menubar/menubar.ts index 1066a9f..d392178 100644 --- a/src/menubar/menubar.ts +++ b/src/menubar/menubar.ts @@ -189,6 +189,10 @@ function createDefaultMenu(outerView: EditorView, os: OS, tagConf: TagConfigurat if (dispatch) console.log("\x1b[33m[DEBUG]\x1b[0m Selection", state.selection); return true; }, {showByDefault: true})); + items.push(createMenuItem("DUMP STATE", "", (state, dispatch) => { + if (dispatch) console.log("\x1b[33m[DEBUG]\x1b[0m Editor State", JSON.stringify(state.toJSON())); + return true; + }, {showByDefault: true})); } // Return a new MenuView with the previously created items. From 5e532f40f511f2e9514007d177caa8b55490a485 Mon Sep 17 00:00:00 2001 From: DikieDick <26772815+DikieDick@users.noreply.github.com> Date: Fri, 12 Dec 2025 10:10:22 +0100 Subject: [PATCH 48/50] Misc sonar fixes --- src/commands/command-helpers.ts | 4 ++-- src/editor.ts | 22 ++++++++-------------- src/hinting/hint-plugin.ts | 7 ++++--- src/menubar/menubar.ts | 26 ++++++++++++++------------ 4 files changed, 28 insertions(+), 31 deletions(-) diff --git a/src/commands/command-helpers.ts b/src/commands/command-helpers.ts index a3844ae..1b3fbef 100644 --- a/src/commands/command-helpers.ts +++ b/src/commands/command-helpers.ts @@ -13,7 +13,7 @@ import { getParentAndIndex } from "./utils"; * Helper function for inserting a new node above the currently selected one. * @param state The current editor state. * @param tr The current transaction for the state of the editor. - * @param nodeType TODO + * @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 { @@ -67,7 +67,7 @@ export function insertAbove(state: EditorState, tr: Transaction, nodeType: NodeT * Helper function for inserting a new node below the currently selected one. * @param state The current editor state. * @param tr The current transaction for the state of the editor. - * @param nodeType TODO + * @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 { diff --git a/src/editor.ts b/src/editor.ts index 8a53569..3855db9 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -1,14 +1,14 @@ import { mathPlugin, mathSerializer } from "@benrbray/prosemirror-math"; import { selectParentNode } from "prosemirror-commands"; import { keymap } from "prosemirror-keymap"; -import { Schema, Node as ProseNode } from "prosemirror-model"; +import { Node as ProseNode } from "prosemirror-model"; import { 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"; import { constructDocument } from "./document/construct-document"; -import { DocChange, LineNumber, InputAreaStatus, SimpleProgressParams, WrappingDocChange, HistoryChange, Severity, OffsetDiagnostic, MappingError, NodeUpdateError, TextUpdateError, DocumentSerializer } from "./api"; +import { DocChange, LineNumber, InputAreaStatus, SimpleProgressParams, WrappingDocChange, HistoryChange, Severity, OffsetDiagnostic, MappingError, NodeUpdateError, TextUpdateError, DocumentSerializer, Positioned, ServerStatus, ThemeStyle, WaterproofEditorConfig } from "./api"; import { CODE_PLUGIN_KEY, codePlugin } from "./codeview"; import { createHintPlugin } from "./hinting"; import { INPUT_AREA_PLUGIN_KEY, inputAreaPlugin } from "./inputArea"; @@ -28,9 +28,7 @@ import "./styles"; import { UPDATE_STATUS_PLUGIN_KEY, updateStatusPlugin } from "./qedStatus"; import { CodeBlockView } from "./codeview/nodeview"; import { OS } from "./osType"; -import { Positioned, WaterproofEditorConfig, ThemeStyle } from "./api"; import { Completion } from "@codemirror/autocomplete"; -import { ServerStatus } from "./api"; import { getCmdInsertCode, getCmdInsertLatex, getCmdInsertMarkdown } from "./commands/insert-command"; import { InsertionPlace } from "./commands"; import { deleteSelection } from "./commands/commands"; @@ -44,13 +42,10 @@ export type DiagnosticObjectProse = {message: string, start: number, end: number */ export class WaterproofEditor { - private _editorConfig: WaterproofEditorConfig; - - // The schema used in this prosemirror editor. - private _schema: Schema; + private readonly _editorConfig: WaterproofEditorConfig; // The editor and content html elements. - private _editorElem: HTMLElement; + private readonly _editorElem: HTMLElement; // The prosemirror view private _view: EditorView | undefined; @@ -70,7 +65,7 @@ export class WaterproofEditor { private _lineNumbersShown: boolean = false; - private _serializer: DocumentSerializer; + private readonly _serializer: DocumentSerializer; /** * Create a new WaterproofEditor instance. @@ -78,11 +73,10 @@ export class WaterproofEditor { * @param config The configuration of the editor to use. */ constructor (editorElement: HTMLElement, config: WaterproofEditorConfig, private readonly initialThemeStyle: ThemeStyle) { - this._schema = WaterproofSchema; this._editorElem = editorElement; this.currentProseDiagnostics = []; this._editorConfig = config; - this._serializer = config.serializer === undefined ? new DefaultTagSerializer(config.tagConfiguration) : config.serializer; + this._serializer = config.serializer ?? new DefaultTagSerializer(config.tagConfiguration); const userAgent = window.navigator.userAgent; this._userOS = OS.Unknown; @@ -238,7 +232,7 @@ export class WaterproofEditor { /** Create initial prosemirror state */ createState(proseDoc: ProseNode): EditorState { return EditorState.create({ - schema: this._schema, + schema: WaterproofSchema, doc: proseDoc, plugins: this.createPluginsArray() }); @@ -248,7 +242,7 @@ export class WaterproofEditor { createPluginsArray(): Plugin[] { return [ history(), - createHintPlugin(this._schema), + createHintPlugin(), inputAreaPlugin, updateStatusPlugin(this), mathPlugin, diff --git a/src/hinting/hint-plugin.ts b/src/hinting/hint-plugin.ts index 2a4d4e8..000d138 100644 --- a/src/hinting/hint-plugin.ts +++ b/src/hinting/hint-plugin.ts @@ -1,16 +1,17 @@ -import { NodeType, Node as PNode, Schema } from "prosemirror-model"; +import { NodeType, Node as PNode } from "prosemirror-model"; import { EditorState, EditorStateConfig, Plugin, Transaction } from "prosemirror-state"; import { Decoration, DecorationSet, EditorView } from "prosemirror-view"; import { findDescendantsWithType } from "../utilities"; +import { WaterproofSchema } from "../schema"; /** * Function that returns the hint plugin. * @param schema The schema in use for the editor. * @returns A `Plugin` that enables the hint functionality. */ -export const createHintPlugin = (schema: Schema): Plugin => { +export const createHintPlugin = (): Plugin => { // Get the hint node type from the supplied schema - const hintNodeType = schema.nodes.hint; + const hintNodeType = WaterproofSchema.nodes.hint; // Create a new Plugin const plugin = new Plugin({ diff --git a/src/menubar/menubar.ts b/src/menubar/menubar.ts index d392178..ae09044 100644 --- a/src/menubar/menubar.ts +++ b/src/menubar/menubar.ts @@ -181,18 +181,20 @@ function createDefaultMenu(outerView: EditorView, os: OS, tagConf: TagConfigurat // The DEBUG label will be dropped in case we are *not* in debug mode. // eslint-disable-next-line no-unused-labels DEBUG: { - items.push(createMenuItem("DUMP DOC", "", (state, dispatch) => { - if (dispatch) console.log("\x1b[33m[DEBUG]\x1b[0m dumped doc", JSON.stringify(state.doc.toJSON())); - return true; - }, {showByDefault: true})); - items.push(createMenuItem("DUMP SELECTION", "", (state, dispatch) => { - if (dispatch) console.log("\x1b[33m[DEBUG]\x1b[0m Selection", state.selection); - return true; - }, {showByDefault: true})); - items.push(createMenuItem("DUMP STATE", "", (state, dispatch) => { - if (dispatch) console.log("\x1b[33m[DEBUG]\x1b[0m Editor State", JSON.stringify(state.toJSON())); - return true; - }, {showByDefault: true})); + items.push( + createMenuItem("DUMP DOC", "", (state, dispatch) => { + if (dispatch) console.log("\x1b[33m[DEBUG]\x1b[0m dumped doc", JSON.stringify(state.doc.toJSON())); + return true; + }, {showByDefault: true}), + createMenuItem("DUMP SELECTION", "", (state, dispatch) => { + if (dispatch) console.log("\x1b[33m[DEBUG]\x1b[0m Selection", state.selection); + return true; + }, {showByDefault: true}), + createMenuItem("DUMP STATE", "", (state, dispatch) => { + if (dispatch) console.log("\x1b[33m[DEBUG]\x1b[0m Editor State", JSON.stringify(state.toJSON())); + return true; + }, {showByDefault: true}) + ); } // Return a new MenuView with the previously created items. From 2356621521c52df16bd01eeb6d0660ac9cae4903 Mon Sep 17 00:00:00 2001 From: DikieDick <26772815+DikieDick@users.noreply.github.com> Date: Fri, 12 Dec 2025 10:50:35 +0100 Subject: [PATCH 49/50] Test all possible insertion combinations --- __tests__/commands/all-insertions.test.ts | 89 ++++++++++++++++++++++ __tests__/commands/insert-commands.test.ts | 2 +- 2 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 __tests__/commands/all-insertions.test.ts diff --git a/__tests__/commands/all-insertions.test.ts b/__tests__/commands/all-insertions.test.ts new file mode 100644 index 0000000..1f7cffc --- /dev/null +++ b/__tests__/commands/all-insertions.test.ts @@ -0,0 +1,89 @@ +/** + * @jest-environment jsdom +*/ +import { Command, EditorState } from "prosemirror-state"; +import { TagConfiguration } from "../../src/api"; +import { InsertionPlace } from "../../src/commands"; +import { getCmdInsertLatex, getCmdInsertCode, getCmdInsertMarkdown } from "../../src/commands/insert-command"; +import { configuration } from "../../src/markdown-defaults"; +import { EditorView } from "prosemirror-view"; +import { WaterproofSchema } from "../../src/schema"; + +const tagConf: TagConfiguration = { + markdown: { + openTag: "", closeTag: "", + openRequiresNewline: false, closeRequiresNewline: false, + }, + code: { + openRequiresNewline: false, + openTag: "```coq\n", + closeTag: "\n```", + closeRequiresNewline: false, + }, + hint: { + openTag: (title: string) => ``, + closeTag: "", + openRequiresNewline: false, closeRequiresNewline: false, + }, + input: { + openTag: "", closeTag: "", + openRequiresNewline: false, closeRequiresNewline: false, + }, + math: { + openTag: "$$", closeTag: "$$", + openRequiresNewline: false, closeRequiresNewline: false + } +} + +const initialStateCode = {"doc":{"type":"doc","content":[{"type":"code","content":[{"type":"text","text":"Content."}]}]},"selection":{"type":"text","anchor":9,"head":9}} +const initialStateMD = {"doc":{"type":"doc","content":[{"type":"markdown","content":[{"type":"text","text":"Content."}]}]},"selection":{"type":"node","anchor":0}} +const initialStateMath = {"doc":{"type":"doc","content":[{"type":"math_display","content":[{"type":"text","text":"Content."}]}]},"selection":{"type":"node","anchor":0}} + +const startingCell: Array<[string, any]> = [ + ["Latex", initialStateMath], + ["Markdown", initialStateMD], + ["Code", initialStateCode] +]; + +const insertableTypes: Array<[string, (place: InsertionPlace, conf: TagConfiguration) => Command, string]> = [ + ["Latex", getCmdInsertLatex, "math_display"], + ["Markdown", getCmdInsertMarkdown, "markdown"], + ["Code", getCmdInsertCode, "code"] +]; + +const places: Array<[string, InsertionPlace]> = [ + ["above", InsertionPlace.Above], + ["below", InsertionPlace.Below], +] + +// Mock the plugin key to always return state teacher=true +jest.mock('../../src/inputArea.ts', () => ({ + INPUT_AREA_PLUGIN_KEY: { + getState: jest.fn(() => ({ teacher: true })) + } +})); + +for (const cell of startingCell) { + for (const toInsert of insertableTypes) { + for (const place of places) { + const testName = `Insert ${toInsert[0]} ${place[0]} ${cell[0]}`; + test(testName, () => { + const cmd = toInsert[1](place[1], tagConf); + const view = new EditorView(null, {state: EditorState.fromJSON({schema: WaterproofSchema}, cell[1])}); + const res = cmd(view.state, view.dispatch, view); + expect(res).toBe(true); // Check that the command returns true, indicating that it 'could' be executed. + + const newState = view.state.toJSON(); + + const content = newState.doc.content; + + const oldContent = cell[1].doc.content; + if (place[1] === InsertionPlace.Above) { + expect(content).toStrictEqual([{type: toInsert[2]},...oldContent]); + } else { + expect(content).toStrictEqual([...oldContent, {type: toInsert[2]}]); + } + }); + } + } +} \ No newline at end of file diff --git a/__tests__/commands/insert-commands.test.ts b/__tests__/commands/insert-commands.test.ts index 2d79e9d..fef6640 100644 --- a/__tests__/commands/insert-commands.test.ts +++ b/__tests__/commands/insert-commands.test.ts @@ -100,4 +100,4 @@ test("Insert code below twice (selection moves down)", () => { ]}, "selection":{"type":"text","anchor":28,"head":28}}; expect(view.state.toJSON()).toStrictEqual(newState2); -}); \ No newline at end of file +}); From 8e2390b19a802c18213f2c0bfb9cfb31e71ffbab Mon Sep 17 00:00:00 2001 From: DikieDick <26772815+DikieDick@users.noreply.github.com> Date: Sun, 14 Dec 2025 18:22:13 +0100 Subject: [PATCH 50/50] Fix typo in UsingWaterproofEditor.md --- documentation/UsingWaterproofEditor.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/UsingWaterproofEditor.md b/documentation/UsingWaterproofEditor.md index ed9b73f..648ba8f 100644 --- a/documentation/UsingWaterproofEditor.md +++ b/documentation/UsingWaterproofEditor.md @@ -27,7 +27,7 @@ InputAreaBlock ::= Container of InnerBlock+ MarkdownBlock ::= A container with markdown content (supports inline LaTeX). CoqBlock ::= A container with code content. MathDisplayBlock ::= A container with LaTeX content that should be rendered in math display mode. -NewlineBlock ::= A block that keeps track of signifcant newlines +NewlineBlock ::= A block that keeps track of significant newlines ``` The schema `WaterproofSchema` defined in [`src/schema/schema.ts`](../src/schema/schema.ts) follows from the above grammar.