Improve block and code folding

- Display the number of folded lines for a folded region
- Add commands for folding/unfolding/toggling blocks and assigne them default key bindings
- Add toggleFold command (toggles the nearest foldable range)
- Prevent editor from loosing focus when fold gutter is clicked
- Fix 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)
This commit is contained in:
Jonatan Heyman 2025-06-11 23:31:39 +02:00
parent bc863f20fa
commit a7a4c73bae
6 changed files with 197 additions and 6 deletions

View File

@ -20,3 +20,5 @@ export const UPDATE_DOWNLOAD_PROGRESS = "update-download-progress"
export const UPDATE_START_DOWNLOAD = "auto-update:startDownload" export const UPDATE_START_DOWNLOAD = "auto-update:startDownload"
export const UPDATE_INSTALL_AND_RESTART = "auto-update:installAndRestart" export const UPDATE_INSTALL_AND_RESTART = "auto-update:installAndRestart"
export const UPDATE_CHECK_FOR_UPDATES = "auto-update:checkForUpdates" export const UPDATE_CHECK_FOR_UPDATES = "auto-update:checkForUpdates"
export const FOLD_LABEL_LENGTH = 50

View File

@ -11,7 +11,7 @@ import {
insertNewlineAndIndent, insertNewlineAndIndent,
toggleComment, toggleBlockComment, toggleLineComment, toggleComment, toggleBlockComment, toggleLineComment,
} from "@codemirror/commands" } from "@codemirror/commands"
import { foldCode, unfoldCode } from "@codemirror/language" import { foldCode, unfoldCode, toggleFold } from "@codemirror/language"
import { selectNextOccurrence } from "@codemirror/search" import { selectNextOccurrence } from "@codemirror/search"
import { insertNewlineContinueMarkup } from "@codemirror/lang-markdown" 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 { markModeMoveCommand, toggleSelectionMarkMode, selectionMarkModeCancel } from "./mark-mode.js"
import { insertDateAndTime } from "./date-time.js" import { insertDateAndTime } from "./date-time.js"
import { foldBlock, unfoldBlock, toggleBlockFold } from "./fold-gutter.js"
const cursorPreviousBlock = markModeMoveCommand(gotoPreviousBlock, selectPreviousBlock) const cursorPreviousBlock = markModeMoveCommand(gotoPreviousBlock, selectPreviousBlock)
@ -101,6 +102,9 @@ const HEYNOTE_COMMANDS = {
openCreateNewBuffer: cmd(openCreateNewBuffer, "Buffer", "Create new buffer…"), openCreateNewBuffer: cmd(openCreateNewBuffer, "Buffer", "Create new buffer…"),
cut: cmd(cutCommand, "Clipboard", "Cut selection"), cut: cmd(cutCommand, "Clipboard", "Cut selection"),
copy: cmd(copyCommand, "Clipboard", "Copy 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 // commands without editor context
paste: cmdLessContext(pasteCommand, "Clipboard", "Paste from clipboard"), paste: cmdLessContext(pasteCommand, "Clipboard", "Paste from clipboard"),
@ -127,6 +131,7 @@ const HEYNOTE_COMMANDS = {
indentLess: cmdLessContext(indentLess, "Edit", "Indent less"), indentLess: cmdLessContext(indentLess, "Edit", "Indent less"),
foldCode: cmdLessContext(foldCode, "Edit", "Fold code"), foldCode: cmdLessContext(foldCode, "Edit", "Fold code"),
unfoldCode: cmdLessContext(unfoldCode, "Edit", "Unfold code"), unfoldCode: cmdLessContext(unfoldCode, "Edit", "Unfold code"),
toggleFold: cmdLessContext(toggleFold, "Edit", "Toggle fold"),
selectNextOccurrence: cmdLessContext(selectNextOccurrence, "Cursor", "Select next occurrence"), selectNextOccurrence: cmdLessContext(selectNextOccurrence, "Cursor", "Select next occurrence"),
deleteCharBackward: cmdLessContext(deleteCharBackward, "Edit", "Delete character backward"), deleteCharBackward: cmdLessContext(deleteCharBackward, "Edit", "Delete character backward"),
deleteCharForward: cmdLessContext(deleteCharForward, "Edit", "Delete character forward"), deleteCharForward: cmdLessContext(deleteCharForward, "Edit", "Delete character forward"),

View File

@ -1,6 +1,6 @@
import { Annotation, EditorState, Compartment, Facet, EditorSelection, Transaction, Prec } from "@codemirror/state" import { Annotation, EditorState, Compartment, Facet, EditorSelection, Transaction, Prec } from "@codemirror/state"
import { EditorView, keymap as cmKeymap, drawSelection, ViewPlugin, lineNumbers } from "@codemirror/view" 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 { markdown, markdownKeymap } from "@codemirror/lang-markdown"
import { undo, redo } from "@codemirror/commands" 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 { AUTO_SAVE_INTERVAL } from "../common/constants.js"
import { useHeynoteStore } from "../stores/heynote-store.js"; import { useHeynoteStore } from "../stores/heynote-store.js";
import { useErrorStore } from "../stores/error-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) // 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, //minimalSetup,
this.lineNumberCompartment.of(showLineNumberGutter ? blockLineNumbers : []), this.lineNumberCompartment.of(showLineNumberGutter ? blockLineNumbers : []),
customSetup, customSetup,
this.foldGutterCompartment.of(showFoldGutter ? [foldGutter()] : []), this.foldGutterCompartment.of(showFoldGutter ? [foldGutterExtension()] : []),
this.closeBracketsCompartment.of(bracketClosing ? [getCloseBracketsExtensions()] : []), this.closeBracketsCompartment.of(bracketClosing ? [getCloseBracketsExtensions()] : []),
this.readOnlyCompartment.of([]), this.readOnlyCompartment.of([]),
@ -375,7 +376,7 @@ export class HeynoteEditor {
setFoldGutter(show) { setFoldGutter(show) {
this.view.dispatch({ this.view.dispatch({
effects: this.foldGutterCompartment.reconfigure(show ? [foldGutter()] : []), effects: this.foldGutterCompartment.reconfigure(show ? foldGutterExtension : []),
}) })
} }

169
src/editor/fold-gutter.js Normal file
View File

@ -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)),
})
}
}

View File

@ -90,6 +90,17 @@ export const DEFAULT_KEYMAP = [
cmd("Mod-/", "toggleComment"), cmd("Mod-/", "toggleComment"),
cmd("Alt-Shift-a", "toggleBlockComment"), 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 // search
//cmd("Mod-f", "openSearchPanel"), //cmd("Mod-f", "openSearchPanel"),
//cmd("F3", "findNext"), //cmd("F3", "findNext"),

View File

@ -7,6 +7,8 @@ import {styleTags, tags as t} from "@lezer/highlight"
import { json } from "@codemirror/lang-json" import { json } from "@codemirror/lang-json"
import { javascript } from "@codemirror/lang-javascript" import { javascript } from "@codemirror/lang-javascript"
import { FOLD_LABEL_LENGTH } from "@/src/common/constants.js"
function foldNode(node) { function foldNode(node) {
//console.log("foldNode", node); //console.log("foldNode", node);
@ -25,8 +27,9 @@ export const HeynoteLanguage = LRLanguage.define({
foldNodeProp.add({ foldNodeProp.add({
//NoteContent: foldNode, //NoteContent: foldNode,
//NoteContent: foldInside, //NoteContent: foldInside,
NoteContent(node) { NoteContent(node, state) {
return {from:node.from, to:node.to} //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}
}, },
}), }),
], ],