diff --git a/src/editor/block/block.js b/src/editor/block/block.js index 8e3cc07f..66cf1df0 100644 --- a/src/editor/block/block.js +++ b/src/editor/block/block.js @@ -1,6 +1,6 @@ import { ViewPlugin, EditorView, Decoration, WidgetType, lineNumbers } from "@codemirror/view" import { layer, RectangleMarker } from "@codemirror/view" -import { EditorState, RangeSetBuilder, StateField, Facet , StateEffect, RangeSet} from "@codemirror/state"; +import { EditorState, RangeSetBuilder, StateField, Facet , StateEffect, RangeSet, MapMode} from "@codemirror/state"; import { syntaxTree, ensureSyntaxTree } from "@codemirror/language" import { Note, Document, NoteDelimiter } from "../lang-heynote/parser.terms.js" import { IterMode } from "@lezer/common"; @@ -229,20 +229,33 @@ const blockLayer = layer({ const preventFirstBlockFromBeingDeleted = EditorState.changeFilter.of((tr) => { //console.log("change filter!", tr) const protect = [] - if (!tr.annotations.some(a => a.type === heynoteEvent) && firstBlockDelimiterSize) { + if (tr.annotation(heynoteEvent) === undefined && firstBlockDelimiterSize) { protect.push(0, firstBlockDelimiterSize) } // if the transaction is a search and replace, we want to protect all block delimiters - if (tr.annotations.some(a => a.value === "input.replace" || a.value === "input.replace.all")) { + // `isUserEvent` grabs any annotation that is equal to the specified or more specific too + // in this case, it returns true to "input.replace" and "input.replace.all". + if (tr.isUserEvent("input.replace")) { const blocks = tr.startState.facet(blockState) blocks.forEach(block => { protect.push(block.delimiter.from, block.delimiter.to) }) //console.log("protected ranges:", protect) } - if (protect.length > 0) { - return protect + return protect; +}) + +/** + * Fix for the default Codemirror keymap "Ctrl-Shift-K", which deletes + * lines, but breaks HeynoteBlock delimiter syntax when deleting entire blocks + */ +const preventBlockDelimiterDeletion = EditorState.changeFilter.of((tr)=>{ + // Regular line deletes dont actually trigger this + if(tr.isUserEvent("delete.line")){ + let activeBlock = getActiveNoteBlock(tr.startState); + return [activeBlock.delimiter.from, activeBlock.delimiter.to] } + return true; }) /** @@ -255,12 +268,16 @@ const preventSelectionBeforeFirstBlock = EditorState.transactionFilter.of((tr) = tr?.selection?.ranges.forEach(range => { // change the selection to after the first block if the transaction sets the selection before the first block if (range && range.from < firstBlockDelimiterSize) { - range.from = firstBlockDelimiterSize - //console.log("changing the from selection to", markerSize) + // `range.to` & `range.from` should be read-only properties, so we need a new range from the old one: + //range.from = firstBlockDelimiterSize + tr.selection.replaceRange(Object.assign(range, {from:firstBlockDelimiterSize})) + //console.log("changing the from selection to", tr.selection) } if (range && range.to < firstBlockDelimiterSize) { - range.to = firstBlockDelimiterSize - //console.log("changing the from selection to", markerSize) + // `range.to` & `range.from` should be read-only properties, so we need a new range from the old one: + //range.to = firstBlockDelimiterSize + tr.selection.replaceRange(Object.assign(range, {to:firstBlockDelimiterSize})) + //console.log("changing the from selection to", tr.selection) } }) return tr @@ -311,7 +328,7 @@ const emitCursorChange = (editor) => ViewPlugin.fromClass( update(update) { // if the selection changed or the language changed (can happen without selection change), // emit a selection change event - const langChange = update.transactions.some(tr => tr.annotations.some(a => a.value == LANGUAGE_CHANGE)) + const langChange = update.transactions.some(tr => tr.isUserEvent(LANGUAGE_CHANGE)) if (update.selectionSet || langChange) { const cursorLine = getBlockLineFromPos(update.state, update.state.selection.main.head) @@ -344,5 +361,6 @@ export const noteBlockExtension = (editor) => { emitCursorChange(editor), mathBlock, emptyBlockSelected, + preventBlockDelimiterDeletion ] } diff --git a/src/editor/copy-paste.js b/src/editor/copy-paste.js index dc0f1d3a..fab2dfe6 100644 --- a/src/editor/copy-paste.js +++ b/src/editor/copy-paste.js @@ -8,7 +8,14 @@ import { setEmacsMarkMode } from "./emacs.js" const languageTokensMatcher = LANGUAGES.map(l => l.token).join("|") const blockSeparatorRegex = new RegExp(`\\nāˆžāˆžāˆž(${languageTokensMatcher})(-a)?\\n`, "g") - +/** + * Given a `EditorState`, returns a object containing + * the current selection content as a `string`, alongside a array of ranges. + * @param {EditorState} state The state object of the editor. + * @returns {Object} object with:

+ * - `text`: the selection content + * - `ranges`: a array of selection ranges + */ function copiedRange(state) { let content = [], ranges = [] for (let range of state.selection.ranges) if (!range.empty) { diff --git a/src/editor/lang-heynote/nested-parser.js b/src/editor/lang-heynote/nested-parser.js index 4b5aa8e3..2134ae3a 100644 --- a/src/editor/lang-heynote/nested-parser.js +++ b/src/editor/lang-heynote/nested-parser.js @@ -15,7 +15,12 @@ import { LANGUAGES } from "../languages.js" const languageMapping = Object.fromEntries(LANGUAGES.map(l => [l.token, l.parser])) - +/** + * Creates a parse wrapper that, after parsing the HeyNote language syntax + * (such as language tokens `NoteDelimiter` and content `NoteContent`) applies + * another parser to the data, attaching the resulting nodes to the syntax tree, with all their props. + * @returns {ParseWrapper} a custom Heynote `ParseWrapper`. + */ export function configureNesting() { return parseMixed((node, input) => { let id = node.type.id