diff --git a/src/common/constants.js b/src/common/constants.js index 5667f08..c1b0511 100644 --- a/src/common/constants.js +++ b/src/common/constants.js @@ -20,3 +20,5 @@ export const UPDATE_DOWNLOAD_PROGRESS = "update-download-progress" export const UPDATE_START_DOWNLOAD = "auto-update:startDownload" export const UPDATE_INSTALL_AND_RESTART = "auto-update:installAndRestart" export const UPDATE_CHECK_FOR_UPDATES = "auto-update:checkForUpdates" + +export const FOLD_LABEL_LENGTH = 50 diff --git a/src/editor/commands.js b/src/editor/commands.js index 7f9b753..ea70bf4 100644 --- a/src/editor/commands.js +++ b/src/editor/commands.js @@ -11,7 +11,7 @@ import { insertNewlineAndIndent, toggleComment, toggleBlockComment, toggleLineComment, } from "@codemirror/commands" -import { foldCode, unfoldCode } from "@codemirror/language" +import { foldCode, unfoldCode, toggleFold } from "@codemirror/language" import { selectNextOccurrence } from "@codemirror/search" import { insertNewlineContinueMarkup } from "@codemirror/lang-markdown" @@ -33,6 +33,7 @@ import { cutCommand, copyCommand, pasteCommand } from "./copy-paste.js" import { markModeMoveCommand, toggleSelectionMarkMode, selectionMarkModeCancel } from "./mark-mode.js" import { insertDateAndTime } from "./date-time.js" +import { foldBlock, unfoldBlock, toggleBlockFold } from "./fold-gutter.js" const cursorPreviousBlock = markModeMoveCommand(gotoPreviousBlock, selectPreviousBlock) @@ -101,6 +102,9 @@ const HEYNOTE_COMMANDS = { openCreateNewBuffer: cmd(openCreateNewBuffer, "Buffer", "Create new buffer…"), cut: cmd(cutCommand, "Clipboard", "Cut selection"), copy: cmd(copyCommand, "Clipboard", "Copy selection"), + foldBlock: cmd(foldBlock, "Block", "Fold block"), + unfoldBlock: cmd(unfoldBlock, "Block", "Unfold block"), + toggleBlockFold: cmd(toggleBlockFold, "Block", "Toggle block fold"), // commands without editor context paste: cmdLessContext(pasteCommand, "Clipboard", "Paste from clipboard"), @@ -127,6 +131,7 @@ const HEYNOTE_COMMANDS = { indentLess: cmdLessContext(indentLess, "Edit", "Indent less"), foldCode: cmdLessContext(foldCode, "Edit", "Fold code"), unfoldCode: cmdLessContext(unfoldCode, "Edit", "Unfold code"), + toggleFold: cmdLessContext(toggleFold, "Edit", "Toggle fold"), selectNextOccurrence: cmdLessContext(selectNextOccurrence, "Cursor", "Select next occurrence"), deleteCharBackward: cmdLessContext(deleteCharBackward, "Edit", "Delete character backward"), deleteCharForward: cmdLessContext(deleteCharForward, "Edit", "Delete character forward"), diff --git a/src/editor/editor.js b/src/editor/editor.js index 2d0f4e2..75c5954 100644 --- a/src/editor/editor.js +++ b/src/editor/editor.js @@ -1,6 +1,6 @@ import { Annotation, EditorState, Compartment, Facet, EditorSelection, Transaction, Prec } from "@codemirror/state" import { EditorView, keymap as cmKeymap, drawSelection, ViewPlugin, lineNumbers } from "@codemirror/view" -import { foldGutter, ensureSyntaxTree } from "@codemirror/language" +import { ensureSyntaxTree } from "@codemirror/language" import { markdown, markdownKeymap } from "@codemirror/lang-markdown" import { undo, redo } from "@codemirror/commands" @@ -27,6 +27,7 @@ import { NoteFormat } from "../common/note-format.js" import { AUTO_SAVE_INTERVAL } from "../common/constants.js" import { useHeynoteStore } from "../stores/heynote-store.js"; import { useErrorStore } from "../stores/error-store.js"; +import { foldGutterExtension } from "./fold-gutter.js" // Turn off the use of EditContext, since Chrome has a bug (https://issues.chromium.org/issues/351029417) @@ -85,7 +86,7 @@ export class HeynoteEditor { //minimalSetup, this.lineNumberCompartment.of(showLineNumberGutter ? blockLineNumbers : []), customSetup, - this.foldGutterCompartment.of(showFoldGutter ? [foldGutter()] : []), + this.foldGutterCompartment.of(showFoldGutter ? [foldGutterExtension()] : []), this.closeBracketsCompartment.of(bracketClosing ? [getCloseBracketsExtensions()] : []), this.readOnlyCompartment.of([]), @@ -375,7 +376,7 @@ export class HeynoteEditor { setFoldGutter(show) { this.view.dispatch({ - effects: this.foldGutterCompartment.reconfigure(show ? [foldGutter()] : []), + effects: this.foldGutterCompartment.reconfigure(show ? foldGutterExtension : []), }) } diff --git a/src/editor/fold-gutter.js b/src/editor/fold-gutter.js new file mode 100644 index 0000000..642fbe5 --- /dev/null +++ b/src/editor/fold-gutter.js @@ -0,0 +1,169 @@ +import { codeFolding, foldGutter, foldState, unfoldEffect, foldEffect } from "@codemirror/language" +import { EditorView } from "@codemirror/view" +import { RangeSet } from "@codemirror/state" + +import { FOLD_LABEL_LENGTH } from "@/src/common/constants.js" +import { getNoteBlockFromPos } from "./block/block.js" + + +// This extension fixes so that a folded region is automatically unfolded if any changes happen +// on either the start line or the end line of the folded region (even if the change is not within the folded region) +const autoUnfoldOnEdit = () => { + return EditorView.updateListener.of((update) => { + if (!update.docChanged){ + return + } + + const { state, view } = update; + const foldRanges = state.field(foldState, false); + + if (!foldRanges || foldRanges.size === 0) { + return + } + + const unfoldRanges = [] + + update.changes.iterChanges((fromA, toA, fromB, toB, inserted) => { + foldRanges.between(0, state.doc.length, (from, to) => { + const lineFrom = state.doc.lineAt(from).from + const lineTo = state.doc.lineAt(to).to; + + if ((fromA >= lineFrom && fromA <= lineTo) || (toA >= lineFrom && toA <= lineTo)) { + unfoldRanges.push({ from, to }); + } + }); + }); + + //console.log("Unfold ranges:", unfoldRanges); + if (unfoldRanges.length > 0) { + view.dispatch({ + effects: unfoldRanges.map(range => unfoldEffect.of(range)), + }); + } + }) +} + +export function foldGutterExtension() { + return [ + foldGutter({ + domEventHandlers: { + click(view, line, event) { + // editor should not loose focus when clicking on the fold gutter + view.docView.dom.focus() + }, + }, + }), + codeFolding( + { + //placeholderText: "⯈ Folded", + preparePlaceholder: (state, {from, to}) => { + // Count the number of lines in the folded range + const firstLine = state.doc.lineAt(from) + const lineFrom = firstLine.number + const lineTo = state.doc.lineAt(to).number + const lineCount = lineTo - lineFrom + 1 + + const label = firstLine.text + //console.log("label", label, "line", firstLine) + const labelDom = document.createElement("span") + labelDom.textContent = label.slice(0, 100) + + const linesDom = document.createElement("span") + linesDom.textContent = `${label.slice(-1).trim() === "" ? '' : ' '}… (${lineCount} lines)` + linesDom.style.fontStyle = "italic" + + const dom = document.createElement("span") + dom.className = "cm-foldPlaceholder" + dom.style.opacity = "0.6" + if (firstLine.from === from) { + dom.appendChild(labelDom) + } + dom.appendChild(linesDom) + return dom + }, + placeholderDOM: (view, onClick, prepared) => { + prepared.addEventListener("click", onClick) + return prepared + } + } + ), + autoUnfoldOnEdit(), + ] +} + + +export const toggleBlockFold = (editor) => (view) => { + const state = view.state + const folds = state.field(foldState, false) || RangeSet.empty + const effects = [] + + state.selection.ranges.map(range => range.head).forEach((pos) => { + const block = getNoteBlockFromPos(state, pos) + const firstLine = state.doc.lineAt(block.content.from) + const blockFolds = [] + folds.between(block.content.from, block.content.to, (from, to) => { + if (from <= firstLine.to && to === block.content.to) { + blockFolds.push({from, to}) + } + }) + if (blockFolds.length > 0) { + for (const range of blockFolds) { + // If there are folds in the block, unfold them + effects.push(unfoldEffect.of(range)) + } + } else { + // If there are no folds in the block, fold it + const line = state.doc.lineAt(block.content.from) + effects.push(foldEffect.of({from: Math.min(line.to, block.content.from + FOLD_LABEL_LENGTH), to: block.content.to})) + } + }) + + if (effects.length > 0) { + view.dispatch({ + effects: effects, + }) + } +} + + +export const foldBlock = (editor) => (view) => { + const state = view.state + const blockRanges = [] + state.selection.ranges.map(range => range.head).forEach((pos) => { + const block = getNoteBlockFromPos(state, pos) + if (block) { + const line = state.doc.lineAt(block.content.from) + blockRanges.push({from: Math.min(line.to, block.content.from + FOLD_LABEL_LENGTH), to: block.content.to}) + } + }) + const uniqueBlockRanges = [...new Set(blockRanges.map(JSON.stringify))].map(JSON.parse); + + if (uniqueBlockRanges.length > 0) { + view.dispatch({ + effects: uniqueBlockRanges.map(range => foldEffect.of(range)), + }) + } +} + +export const unfoldBlock = (editor) => (view) => { + const state = view.state + const folds = state.field(foldState, false) || RangeSet.empty + const blockFolds = [] + + state.selection.ranges.map(range => range.head).forEach((pos) => { + const block = getNoteBlockFromPos(state, pos) + const firstLine = state.doc.lineAt(block.content.from) + folds.between(block.content.from, block.content.to, (from, to) => { + //console.log("Fold in block", from, to, "block", block.content.from, block.content.to, firstLine.to) + if (from <= firstLine.to && to === block.content.to) { + blockFolds.push({from, to}) + } + }) + }) + + if (blockFolds.length > 0) { + view.dispatch({ + effects: blockFolds.map(range => unfoldEffect.of(range)), + }) + } +} \ No newline at end of file diff --git a/src/editor/keymap.js b/src/editor/keymap.js index 57449cc..98692f4 100644 --- a/src/editor/keymap.js +++ b/src/editor/keymap.js @@ -89,6 +89,17 @@ export const DEFAULT_KEYMAP = [ cmd("Mod-/", "toggleComment"), cmd("Alt-Shift-a", "toggleBlockComment"), + + // fold blocks + ...(isMac ? [ + cmd("Alt-Mod-[", "foldBlock"), + cmd("Alt-Mod-]", "unfoldBlock"), + cmd("Alt-Mod-.", "toggleBlockFold") + ] : [ + cmd("Ctrl-Shift-[", "foldBlock"), + cmd("Ctrl-Shift-]", "unfoldBlock"), + cmd("Ctrl-Shift-.", "toggleBlockFold") + ]), // search //cmd("Mod-f", "openSearchPanel"), diff --git a/src/editor/lang-heynote/heynote.js b/src/editor/lang-heynote/heynote.js index 7ee9c66..f66a9f9 100644 --- a/src/editor/lang-heynote/heynote.js +++ b/src/editor/lang-heynote/heynote.js @@ -7,6 +7,8 @@ import {styleTags, tags as t} from "@lezer/highlight" import { json } from "@codemirror/lang-json" import { javascript } from "@codemirror/lang-javascript" +import { FOLD_LABEL_LENGTH } from "@/src/common/constants.js" + function foldNode(node) { //console.log("foldNode", node); @@ -25,8 +27,9 @@ export const HeynoteLanguage = LRLanguage.define({ foldNodeProp.add({ //NoteContent: foldNode, //NoteContent: foldInside, - NoteContent(node) { - return {from:node.from, to:node.to} + NoteContent(node, state) { + //return {from:node.from, to:node.to} + return {from: Math.min(state.doc.lineAt(node.from).to, node.from + FOLD_LABEL_LENGTH), to: node.to} }, }), ],