From c142c5c5c0aaa7bc23e2952d932c209ab2f322a6 Mon Sep 17 00:00:00 2001 From: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com> Date: Tue, 19 Aug 2025 23:32:16 -0700 Subject: [PATCH] Desktop,Mobile: Markdown editor: Toggle checkboxes on ctrl-click (#12927) --- .eslintignore | 4 +++ .gitignore | 4 +++ packages/editor/CodeMirror/createEditor.ts | 2 ++ .../extensions/ctrlClickActionExtension.ts | 33 +++++++++++++++++++ .../extensions/ctrlClickCheckboxExtension.ts | 30 +++++++++++++++++ .../links/ctrlClickLinksExtension.ts | 33 ++++++------------- .../extensions/rendering/replaceCheckboxes.ts | 24 ++------------ .../utils/markdown/getCheckboxAtPosition.ts | 21 ++++++++++++ .../utils/markdown/toggleCheckboxAt.ts | 26 +++++++++++++++ 9 files changed, 132 insertions(+), 45 deletions(-) create mode 100644 packages/editor/CodeMirror/extensions/ctrlClickActionExtension.ts create mode 100644 packages/editor/CodeMirror/extensions/ctrlClickCheckboxExtension.ts create mode 100644 packages/editor/CodeMirror/utils/markdown/getCheckboxAtPosition.ts create mode 100644 packages/editor/CodeMirror/utils/markdown/toggleCheckboxAt.ts diff --git a/.eslintignore b/.eslintignore index 97fcc85150..ba1e498bbe 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1004,6 +1004,8 @@ packages/editor/CodeMirror/editorCommands/sortSelectedLines.test.js packages/editor/CodeMirror/editorCommands/sortSelectedLines.js packages/editor/CodeMirror/editorCommands/supportsCommand.js packages/editor/CodeMirror/extensions/biDirectionalTextExtension.js +packages/editor/CodeMirror/extensions/ctrlClickActionExtension.js +packages/editor/CodeMirror/extensions/ctrlClickCheckboxExtension.js packages/editor/CodeMirror/extensions/keyUpHandlerExtension.js packages/editor/CodeMirror/extensions/links/ctrlClickLinksExtension.js packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.test.js @@ -1074,9 +1076,11 @@ packages/editor/CodeMirror/utils/isInSyntaxNode.js packages/editor/CodeMirror/utils/markdown/codeBlockLanguages/allLanguages.js packages/editor/CodeMirror/utils/markdown/codeBlockLanguages/defaultLanguage.js packages/editor/CodeMirror/utils/markdown/codeBlockLanguages/lookUpLanguage.js +packages/editor/CodeMirror/utils/markdown/getCheckboxAtPosition.js packages/editor/CodeMirror/utils/markdown/renumberSelectedLists.test.js packages/editor/CodeMirror/utils/markdown/renumberSelectedLists.js packages/editor/CodeMirror/utils/markdown/stripBlockquote.js +packages/editor/CodeMirror/utils/markdown/toggleCheckboxAt.js packages/editor/CodeMirror/utils/setupVim.js packages/editor/ProseMirror/commands.test.js packages/editor/ProseMirror/commands.js diff --git a/.gitignore b/.gitignore index 632736679f..d8c5269131 100644 --- a/.gitignore +++ b/.gitignore @@ -977,6 +977,8 @@ packages/editor/CodeMirror/editorCommands/sortSelectedLines.test.js packages/editor/CodeMirror/editorCommands/sortSelectedLines.js packages/editor/CodeMirror/editorCommands/supportsCommand.js packages/editor/CodeMirror/extensions/biDirectionalTextExtension.js +packages/editor/CodeMirror/extensions/ctrlClickActionExtension.js +packages/editor/CodeMirror/extensions/ctrlClickCheckboxExtension.js packages/editor/CodeMirror/extensions/keyUpHandlerExtension.js packages/editor/CodeMirror/extensions/links/ctrlClickLinksExtension.js packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.test.js @@ -1047,9 +1049,11 @@ packages/editor/CodeMirror/utils/isInSyntaxNode.js packages/editor/CodeMirror/utils/markdown/codeBlockLanguages/allLanguages.js packages/editor/CodeMirror/utils/markdown/codeBlockLanguages/defaultLanguage.js packages/editor/CodeMirror/utils/markdown/codeBlockLanguages/lookUpLanguage.js +packages/editor/CodeMirror/utils/markdown/getCheckboxAtPosition.js packages/editor/CodeMirror/utils/markdown/renumberSelectedLists.test.js packages/editor/CodeMirror/utils/markdown/renumberSelectedLists.js packages/editor/CodeMirror/utils/markdown/stripBlockquote.js +packages/editor/CodeMirror/utils/markdown/toggleCheckboxAt.js packages/editor/CodeMirror/utils/setupVim.js packages/editor/ProseMirror/commands.test.js packages/editor/ProseMirror/commands.js diff --git a/packages/editor/CodeMirror/createEditor.ts b/packages/editor/CodeMirror/createEditor.ts index 2ff58b89e5..6fac98da35 100644 --- a/packages/editor/CodeMirror/createEditor.ts +++ b/packages/editor/CodeMirror/createEditor.ts @@ -39,6 +39,7 @@ import selectedNoteIdExtension, { setNoteIdEffect } from './extensions/selectedN import ctrlKeyStateClassExtension from './extensions/modifierKeyCssExtension'; import ctrlClickLinksExtension from './extensions/links/ctrlClickLinksExtension'; import { RenderedContentContext } from './extensions/rendering/types'; +import ctrlClickCheckboxExtension from './extensions/ctrlClickCheckboxExtension'; // Newer versions of CodeMirror by default use Chrome's EditContext API. // While this might be stable enough for desktop use, it causes significant @@ -255,6 +256,7 @@ const createEditor = ( ctrlClickLinksExtension(link => { props.onEvent({ kind: EditorEventType.FollowLink, link }); }), + ctrlClickCheckboxExtension(), highlightSpecialChars(), indentOnInput(), diff --git a/packages/editor/CodeMirror/extensions/ctrlClickActionExtension.ts b/packages/editor/CodeMirror/extensions/ctrlClickActionExtension.ts new file mode 100644 index 0000000000..22e6856bc1 --- /dev/null +++ b/packages/editor/CodeMirror/extensions/ctrlClickActionExtension.ts @@ -0,0 +1,33 @@ +import { EditorView } from '@codemirror/view'; +import { Prec } from '@codemirror/state'; + +const hasMultipleCursors = (view: EditorView) => { + return view.state.selection.ranges.length > 1; +}; + +type OnCtrlClick = (view: EditorView, event: MouseEvent)=> boolean; + +const ctrlClickActionExtension = (onCtrlClick: OnCtrlClick) => { + return [ + Prec.high([ + EditorView.domEventHandlers({ + mousedown: (event: MouseEvent, view: EditorView) => { + const hasModifier = event.ctrlKey || event.metaKey; + // The default CodeMirror action for ctrl-click is to add another cursor + // to the document. If the user already has multiple cursors, assume that + // the ctrl-click action is intended to add another. + if (hasModifier && !hasMultipleCursors(view)) { + const handled = onCtrlClick(view, event); + if (handled) { + event.preventDefault(); + return true; + } + } + return false; + }, + }), + ]), + ]; +}; + +export default ctrlClickActionExtension; diff --git a/packages/editor/CodeMirror/extensions/ctrlClickCheckboxExtension.ts b/packages/editor/CodeMirror/extensions/ctrlClickCheckboxExtension.ts new file mode 100644 index 0000000000..7c965be583 --- /dev/null +++ b/packages/editor/CodeMirror/extensions/ctrlClickCheckboxExtension.ts @@ -0,0 +1,30 @@ +import { EditorView } from '@codemirror/view'; +import modifierKeyCssExtension from './modifierKeyCssExtension'; +import { syntaxTree } from '@codemirror/language'; +import getCheckboxAtPosition from '../utils/markdown/getCheckboxAtPosition'; +import toggleCheckboxAt from '../utils/markdown/toggleCheckboxAt'; +import ctrlClickActionExtension from './ctrlClickActionExtension'; + + +const ctrlClickCheckboxExtension = () => { + return [ + modifierKeyCssExtension, + EditorView.theme({ + '&.-ctrl-or-cmd-pressed .cm-taskMarker': { + cursor: 'pointer', + }, + }), + ctrlClickActionExtension((view, event) => { + const target = view.posAtCoords(event); + const taskMarker = getCheckboxAtPosition(target, syntaxTree(view.state)); + + if (taskMarker) { + toggleCheckboxAt(target)(view); + return true; + } + return false; + }), + ]; +}; + +export default ctrlClickCheckboxExtension; diff --git a/packages/editor/CodeMirror/extensions/links/ctrlClickLinksExtension.ts b/packages/editor/CodeMirror/extensions/links/ctrlClickLinksExtension.ts index f622a6af47..3295df2645 100644 --- a/packages/editor/CodeMirror/extensions/links/ctrlClickLinksExtension.ts +++ b/packages/editor/CodeMirror/extensions/links/ctrlClickLinksExtension.ts @@ -4,12 +4,10 @@ import modifierKeyCssExtension from '../modifierKeyCssExtension'; import openLink from './utils/openLink'; import getUrlAtPosition from './utils/getUrlAtPosition'; import { syntaxTree } from '@codemirror/language'; -import { Prec } from '@codemirror/state'; - +import ctrlClickActionExtension from '../ctrlClickActionExtension'; type OnOpenLink = (url: string, view: EditorView)=> void; - const ctrlClickLinksExtension = (onOpenExternalLink: OnOpenLink) => { return [ modifierKeyCssExtension, @@ -19,27 +17,16 @@ const ctrlClickLinksExtension = (onOpenExternalLink: OnOpenLink) => { cursor: 'pointer', }, }), - Prec.high([ - EditorView.domEventHandlers({ - mousedown: (event: MouseEvent, view: EditorView) => { - if (event.ctrlKey || event.metaKey) { - const target = view.posAtCoords(event); - const url = getUrlAtPosition(target, syntaxTree(view.state), view.state); - const hasMultipleCursors = view.state.selection.ranges.length > 1; + ctrlClickActionExtension((view: EditorView, event: MouseEvent) => { + const target = view.posAtCoords(event); + const url = getUrlAtPosition(target, syntaxTree(view.state), view.state); - // The default CodeMirror action for ctrl-click is to add another cursor - // to the document. If the user already has multiple cursors, assume that - // the ctrl-click action is intended to add another. - if (url && !hasMultipleCursors) { - openLink(url.url, view, onOpenExternalLink); - event.preventDefault(); - return true; - } - } - return false; - }, - }), - ]), + if (url) { + openLink(url.url, view, onOpenExternalLink); + return true; + } + return false; + }), ]; }; diff --git a/packages/editor/CodeMirror/extensions/rendering/replaceCheckboxes.ts b/packages/editor/CodeMirror/extensions/rendering/replaceCheckboxes.ts index f05f61a861..2b34ec21d2 100644 --- a/packages/editor/CodeMirror/extensions/rendering/replaceCheckboxes.ts +++ b/packages/editor/CodeMirror/extensions/rendering/replaceCheckboxes.ts @@ -1,30 +1,10 @@ import { Decoration, EditorView, WidgetType } from '@codemirror/view'; import { SyntaxNodeRef } from '@lezer/common'; import makeReplaceExtension from './utils/makeInlineReplaceExtension'; +import toggleCheckboxAt from '../../utils/markdown/toggleCheckboxAt'; const checkboxClassName = 'cm-ext-checkbox-toggle'; -const toggleCheckbox = (view: EditorView, linePos: number) => { - if (linePos >= view.state.doc.length) { - // Position out of range - return false; - } - - const line = view.state.doc.lineAt(linePos); - const checkboxMarkup = line.text.match(/\[(x|\s)\]/); - if (!checkboxMarkup) { - // Couldn't find the checkbox - return false; - } - - const isChecked = checkboxMarkup[0] === '[x]'; - const checkboxPos = checkboxMarkup.index! + line.from; - - view.dispatch({ - changes: [{ from: checkboxPos, to: checkboxPos + 3, insert: isChecked ? '[ ]' : '[x]' }], - }); - return true; -}; class CheckboxWidget extends WidgetType { public constructor(private checked: boolean, private depth: number, private label: string) { @@ -58,7 +38,7 @@ class CheckboxWidget extends WidgetType { container.appendChild(checkbox); checkbox.oninput = () => { - toggleCheckbox(view, view.posAtDOM(container)); + toggleCheckboxAt(view.posAtDOM(container))(view); }; this.applyContainerClasses(container); diff --git a/packages/editor/CodeMirror/utils/markdown/getCheckboxAtPosition.ts b/packages/editor/CodeMirror/utils/markdown/getCheckboxAtPosition.ts new file mode 100644 index 0000000000..137c905952 --- /dev/null +++ b/packages/editor/CodeMirror/utils/markdown/getCheckboxAtPosition.ts @@ -0,0 +1,21 @@ +import { Tree } from '@lezer/common'; + +const getCheckboxAtPosition = (pos: number, tree: Tree) => { + let iterator = tree.resolveStack(pos); + + while (true) { + if (iterator.node.name === 'TaskMarker') { + return iterator.node; + } + + if (!iterator.next) { + break; + } else { + iterator = iterator.next; + } + } + + return null; +}; + +export default getCheckboxAtPosition; diff --git a/packages/editor/CodeMirror/utils/markdown/toggleCheckboxAt.ts b/packages/editor/CodeMirror/utils/markdown/toggleCheckboxAt.ts new file mode 100644 index 0000000000..3949dda3f2 --- /dev/null +++ b/packages/editor/CodeMirror/utils/markdown/toggleCheckboxAt.ts @@ -0,0 +1,26 @@ +import { Command, EditorView } from '@codemirror/view'; + +const toggleCheckbox = (linePos: number): Command => (target: EditorView) => { + const state = target.state; + if (linePos >= state.doc.length) { + // Position out of range + return false; + } + + const line = state.doc.lineAt(linePos); + const checkboxMarkup = line.text.match(/\[(x|\s)\]/); + if (!checkboxMarkup) { + // Couldn't find the checkbox + return false; + } + + const isChecked = checkboxMarkup[0] === '[x]'; + const checkboxPos = checkboxMarkup.index! + line.from; + + target.dispatch({ + changes: [{ from: checkboxPos, to: checkboxPos + 3, insert: isChecked ? '[ ]' : '[x]' }], + }); + return true; +}; + +export default toggleCheckbox;