Skip to content

Commit 7fc0230

Browse files
authored
Editor Stage 2: Toolbar and Lexical->MDAST enhancements (#2758)
* toolbar: implement bold-italic-link, CSS tweaks regarding transitions, SVGs * rough shortcuts, initial toolbar shape * svgs, dirty multikey shortcuts handler, restore original toolbar composition * multiline prefixes, insertMarkdown with cursor positioning * nowrap on mode tabs, fix toolbar overflow check for fractional zoom * max-height capped at 80svh * cleanup: refactor toolbar, handle dropdowns with objects, multi-action commands * more appropriate icons for additional formatting options and 'show toolbar' * cleanup: remove redundancies from md-commands, use objects to avoid repetitions * fix forgotten codeblock language param * continue cleanup on md-commands * add externalImage shortcut, toolbar block dropdown option and command handlers * wrap shortcut in parenthesis * shortcuts: use physical key (Digit) instead of characters, display os-specific shortcuts client-side, refactor ShortcutsExtension to ShortcutsPlugin because of React usage; cleanup: force client on MenuAlternateDimension, refer to toolbarRef.current snapshot (element), css classname tweaks * consistency: quote -> blockquote, always search for a formatted shortcut, remove unused toolbarState states * nits: delete MDCommandsExtension and ShortcutsExtension from extensions README, cleanup * don't user de-select when clicking the 'show toolbar' button * pivot: Transformer Bridge to achieve full parity between markdown and rich text toolbar transformations * mdast: collapse redundant adjacent html tags (like sup and sub) * wip: toggle link via transformer bridge * wip: LinkExtension for transformer bridge, mdast tweaks * bridge: finalize support for links * bridge: fix headless bridge re-renders by using stable callback deps, cleanup * fallback to simple markdown formatting commands on collapsed selections * transformer bridge cleanup * don't be distructive when transformer bridge fails * fix shortcuts and transformer bridge blocks by referencing the right fns * check for rangeselection when getting the range-selected block type * fix link shortcut typo * fix keyboard shortcuts * select all consecutive empty paragraphs if selection is collapsed on block insertions * don't split quotes in multiple paragraphs * remove external image action, cleanup transformer bridge * cleanup * cleanup, refactor formatting commands * lexical-to-mdast: improve paragraph mdast node compatibility with Transformer Bridge and quote lexical nodes compatibility with MDAST * remove dead url handler * use LIFO to close HTML tags on the Lexical->MDAST Text visitor; remove reported useless SVGs * debugging logs for MDAST pipeline, flip the script: instead of not splitting on quote nodes, split only lists in paragraphs * don't split selection in paragraph, conform to markdown spec and lexical native behavior * cleanup: remove redundant blockquote check on 0 join * use mod instead of opaque meta/ctrl to signal meta or ctrl * fix: toggle links on and off via transformer bridge by checking if the markdown being passed has markdown link formatting * mdast: fix typo
1 parent 83b2384 commit 7fc0230

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+1213
-281
lines changed

‎components/editor/editor.js‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ import { ReactExtension } from '@lexical/react/ReactExtension'
77
import { ContentEditable } from '@lexical/react/LexicalContentEditable'
88
import { LexicalExtensionComposer } from '@lexical/react/LexicalExtensionComposer'
99
import { AutoFocusExtension } from '@lexical/extension'
10-
import { ShortcutsExtension } from '@/lib/lexical/exts/shortcuts'
11-
import { MDCommandsExtension } from '@/lib/lexical/exts/md-commands'
10+
import ShortcutsPlugin from '@/components/editor/plugins/core/shortcuts'
11+
import { FormattingCommandsExtension } from '@/lib/lexical/exts/formatting'
1212
import { ToolbarContextProvider } from '@/components/editor/contexts/toolbar'
1313
import { ToolbarPlugin } from '@/components/editor/plugins/toolbar'
1414
import FormikBridgePlugin from '@/components/editor/plugins/core/formik'
@@ -28,6 +28,7 @@ import { SoftkeyUnborkerPlugin } from '@/components/editor/plugins/patch/softkey
2828
import { SoftkeyEmptyGuardPlugin } from '@/components/editor/plugins/patch/softkey-emptyguard'
2929
import { MarkdownTextExtension } from '@/lib/lexical/exts/markdown'
3030
import AppendValuePlugin from '@/components/editor/plugins/core/append-value'
31+
import TransformerBridgePlugin from '@/components/editor/plugins/core/transformer-bridge'
3132

3233
/**
3334
* main lexical editor component with formik integration
@@ -53,8 +54,7 @@ export default function Editor ({ name, autoFocus, topLevel, ...props }) {
5354
MarkdownTextExtension,
5455
ApplePatchExtension,
5556
HistoryExtension,
56-
ShortcutsExtension,
57-
MDCommandsExtension,
57+
FormattingCommandsExtension,
5858
configExtension(ReactExtension, { contentEditable: null }),
5959
configExtension(AutoFocusExtension, { disabled: !autoFocus })
6060
],
@@ -116,12 +116,14 @@ function EditorContent ({ name, placeholder, lengthOptions, topLevel, required =
116116
)}
117117
<FileUploadPlugin editorRef={containerRef} />
118118
<MentionsPlugin />
119+
<ShortcutsPlugin />
119120
<AppendValuePlugin value={appendValue} />
120121
<LocalDraftPlugin name={name} />
121122
<FormikBridgePlugin name={name} />
122123
<MaxLengthPlugin lengthOptions={lengthOptions} />
123124
<SoftkeyUnborkerPlugin />
124125
<SoftkeyEmptyGuardPlugin />
126+
<TransformerBridgePlugin />
125127
{hint && <BootstrapForm.Text>{hint}</BootstrapForm.Text>}
126128
{warn && <BootstrapForm.Text className='text-warning'>{warn}</BootstrapForm.Text>}
127129
</div>
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { useRef, useEffect } from 'react'
2+
import { buildEditorFromExtensions, defineExtension } from '@lexical/extension'
3+
import { RichTextExtension } from '@lexical/rich-text'
4+
import { ListExtension, CheckListExtension } from '@lexical/list'
5+
import DefaultNodes from '@/lib/lexical/nodes'
6+
import DefaultTheme from '@/lib/lexical/theme'
7+
import { LinkExtension } from '@lexical/link'
8+
9+
const DEFAULT_EXTENSIONS = []
10+
const DEFAULT_NAME = 'sn-headless-bridge'
11+
12+
/**
13+
* shared hook that creates and manages a headless bridge editor
14+
* @param {Object} [opts] - optional configuration for the bridge editor
15+
* @param {Array} [opts.nodes] - custom nodes to use (defaults to DefaultNodes)
16+
* @param {Object} [opts.theme] - theme configuration (defaults to DefaultTheme)
17+
* @param {Array} [opts.extensions] - additional extensions to use (defaults to [])
18+
* @param {string} [opts.name] - name of the bridge editor (defaults to 'sn-headless-bridge')
19+
* @returns {React.RefObject} ref to the bridge editor instance
20+
*/
21+
export default function useHeadlessBridge (opts = {}) {
22+
const {
23+
nodes = DefaultNodes,
24+
theme = DefaultTheme,
25+
extensions = DEFAULT_EXTENSIONS,
26+
name = DEFAULT_NAME
27+
} = opts
28+
const bridge = useRef(null)
29+
30+
// create the bridge once on mount and dispose of it on unmount
31+
useEffect(() => {
32+
if (!bridge.current) {
33+
bridge.current = buildEditorFromExtensions(
34+
defineExtension({
35+
onError: (error) => console.error('editor bridge has encountered an error:', error),
36+
name,
37+
dependencies: [
38+
RichTextExtension,
39+
ListExtension,
40+
CheckListExtension,
41+
LinkExtension,
42+
...extensions
43+
],
44+
nodes,
45+
theme
46+
})
47+
)
48+
}
49+
return () => {
50+
if (bridge.current) {
51+
bridge.current.dispose()
52+
bridge.current = null
53+
}
54+
}
55+
}, [])
56+
57+
return bridge
58+
}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
2+
import { useEffect, useState } from 'react'
3+
import { KEY_DOWN_COMMAND, COMMAND_PRIORITY_HIGH } from 'lexical'
4+
import { IS_APPLE } from '@lexical/utils'
5+
import { SN_FORMAT_COMMAND } from '@/lib/lexical/commands/formatting/format'
6+
import { SN_FORMAT_BLOCK_COMMAND } from '@/lib/lexical/commands/formatting/blocks'
7+
import { SN_TOGGLE_LINK_COMMAND } from '@/lib/lexical/commands/links'
8+
import { SN_UPLOAD_FILES_COMMAND } from '@/components/editor/plugins/upload'
9+
import { TOGGLE_PREVIEW_COMMAND } from '@/components/editor/plugins/preview'
10+
import { SUBMIT_FORMIK_COMMAND } from '@/components/editor/plugins/core/formik'
11+
12+
export const SHORTCUTS = {
13+
link: {
14+
key: 'mod+KeyK',
15+
handler: (editor) => editor.dispatchCommand(SN_TOGGLE_LINK_COMMAND)
16+
},
17+
bold: {
18+
key: 'mod+KeyB',
19+
handler: (editor) => editor.dispatchCommand(SN_FORMAT_COMMAND, 'bold')
20+
},
21+
italic: {
22+
key: 'mod+KeyI',
23+
handler: (editor) => editor.dispatchCommand(SN_FORMAT_COMMAND, 'italic')
24+
},
25+
quote: {
26+
key: 'control+shift+KeyQ',
27+
handler: (editor) => editor.dispatchCommand(SN_FORMAT_BLOCK_COMMAND, 'quote')
28+
},
29+
inlineCode: {
30+
key: 'mod+KeyE',
31+
handler: (editor) => editor.dispatchCommand(SN_FORMAT_COMMAND, 'code')
32+
},
33+
superscript: {
34+
key: 'mod+Period',
35+
handler: (editor) => editor.dispatchCommand(SN_FORMAT_COMMAND, 'superscript')
36+
},
37+
subscript: {
38+
key: 'mod+Comma',
39+
handler: (editor) => editor.dispatchCommand(SN_FORMAT_COMMAND, 'subscript')
40+
},
41+
strikethrough: {
42+
key: 'mod+shift+KeyX',
43+
handler: (editor) => editor.dispatchCommand(SN_FORMAT_COMMAND, 'strikethrough')
44+
},
45+
h1: {
46+
key: 'mod+alt+Digit1',
47+
handler: (editor) => editor.dispatchCommand(SN_FORMAT_BLOCK_COMMAND, 'h1')
48+
},
49+
h2: {
50+
key: 'mod+alt+Digit2',
51+
handler: (editor) => editor.dispatchCommand(SN_FORMAT_BLOCK_COMMAND, 'h2')
52+
},
53+
h3: {
54+
key: 'mod+alt+Digit3',
55+
handler: (editor) => editor.dispatchCommand(SN_FORMAT_BLOCK_COMMAND, 'h3')
56+
},
57+
numberedList: {
58+
key: 'mod+shift+Digit7',
59+
handler: (editor) => editor.dispatchCommand(SN_FORMAT_BLOCK_COMMAND, 'number')
60+
},
61+
bulletList: {
62+
key: 'mod+shift+Digit8',
63+
handler: (editor) => editor.dispatchCommand(SN_FORMAT_BLOCK_COMMAND, 'bullet')
64+
},
65+
check: {
66+
key: 'mod+shift+Digit9',
67+
handler: (editor) => editor.dispatchCommand(SN_FORMAT_BLOCK_COMMAND, 'check')
68+
},
69+
codeblock: {
70+
key: 'mod+alt+KeyC',
71+
handler: (editor) => editor.dispatchCommand(SN_FORMAT_BLOCK_COMMAND, 'code')
72+
},
73+
upload: {
74+
key: 'mod+KeyU',
75+
handler: (editor) => editor.dispatchCommand(SN_UPLOAD_FILES_COMMAND)
76+
},
77+
preview: {
78+
key: 'mod+KeyP',
79+
handler: (editor) => editor.dispatchCommand(TOGGLE_PREVIEW_COMMAND, editor)
80+
},
81+
submit: {
82+
key: 'mod+Enter',
83+
handler: (editor) => editor.dispatchCommand(SUBMIT_FORMIK_COMMAND)
84+
}
85+
}
86+
87+
export default function ShortcutsPlugin ({ shortcuts = SHORTCUTS }) {
88+
const [editor] = useLexicalComposerContext()
89+
90+
useEffect(() => {
91+
return editor.registerCommand(
92+
KEY_DOWN_COMMAND,
93+
(e) => {
94+
for (const shortcut of Object.values(shortcuts)) {
95+
const { key, handler } = shortcut
96+
if (!key) continue
97+
98+
const parts = key.toLowerCase().split('+')
99+
const needsMod = parts.includes('mod')
100+
const needsControl = parts.includes('control')
101+
const needsShift = parts.includes('shift')
102+
const needsAlt = parts.includes('alt')
103+
const targetCode = parts[parts.length - 1]
104+
105+
const hasMod = e.metaKey || e.ctrlKey
106+
const hasControl = e.ctrlKey
107+
const hasShift = e.shiftKey
108+
const hasAlt = e.altKey
109+
110+
if (needsMod && !hasMod) continue
111+
if (needsControl && !hasControl) continue
112+
if (needsShift !== hasShift) continue
113+
if (needsAlt !== hasAlt) continue
114+
115+
// match against e.code (physical key) instead of e.key (character)
116+
if (e.code.toLowerCase() === targetCode) {
117+
const handled = handler(editor)
118+
// only prevent default behavior if the handler returned true
119+
if (handled) e.preventDefault()
120+
return handled
121+
}
122+
}
123+
return false
124+
},
125+
COMMAND_PRIORITY_HIGH
126+
)
127+
}, [editor, shortcuts])
128+
129+
return null
130+
}
131+
132+
// modifier key display mapping
133+
const MODIFIER_DISPLAY = {
134+
mod: IS_APPLE ? '⌘' : 'ctrl',
135+
control: IS_APPLE ? '⌃' : 'ctrl',
136+
alt: IS_APPLE ? '⌥' : 'alt'
137+
}
138+
139+
// convert e.code values to display characters
140+
function codeToDisplay (code) {
141+
if (code.startsWith('Digit')) return code.slice(5)
142+
if (code.startsWith('Key')) return code.slice(3)
143+
const special = { Period: '.', Comma: ',', Enter: '↵', Slash: '/' }
144+
return special[code] || code
145+
}
146+
147+
// format a shortcut key string for display (e.g., 'mod+shift+Digit1' -> '⌘+shift+1')
148+
export function formatShortcut (key) {
149+
if (!key) return ''
150+
const parts = key.split('+')
151+
return parts.map(p => MODIFIER_DISPLAY[p.toLowerCase()] || codeToDisplay(p)).join('+')
152+
}
153+
154+
// force shortcuts to render on the client (IS_APPLE comparison)
155+
// client-side
156+
export function useFormattedShortcut (key) {
157+
const [formatted, setFormatted] = useState('')
158+
159+
useEffect(() => {
160+
setFormatted(key ? formatShortcut(key) : '')
161+
}, [key])
162+
163+
return formatted
164+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { useEffect } from 'react'
2+
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
3+
import { createCommand, $selectAll, $getSelection, COMMAND_PRIORITY_EDITOR, $isRangeSelection, $getRoot } from 'lexical'
4+
import { $formatBlock } from '@/lib/lexical/commands/formatting/blocks'
5+
import useHeadlessBridge from '@/components/editor/hooks/use-headless-bridge'
6+
import { $markdownToLexical, $lexicalToMarkdown } from '@/lib/lexical/utils/mdast'
7+
import { $insertMarkdown, $debugNodeToJSON } from '@/lib/lexical/utils'
8+
import { $toggleLink } from '@/lib/lexical/commands/links'
9+
import { URL_REGEXP } from '@/lib/url'
10+
11+
/** command to transform markdown selections using a headless lexical editor
12+
* @param {Object} params.selection - selection to transform
13+
* @param {string} params.formatType - format type to transform
14+
* @param {string} params.transformation - transformation to apply
15+
* @returns {boolean} true if transformation was applied
16+
*/
17+
export const USE_TRANSFORMER_BRIDGE = createCommand('USE_TRANSFORMER_BRIDGE')
18+
19+
/** bridge plugin that transforms markdown selections using a headless lexical editor,
20+
* registers USE_TRANSFORMER_BRIDGE command to transform markdown selections
21+
*/
22+
export default function TransformerBridgePlugin () {
23+
const [editor] = useLexicalComposerContext()
24+
const bridgeRef = useHeadlessBridge()
25+
26+
// Markdown Transformer Bridge
27+
// uses markdown transformers to apply transformations to a markdown selection
28+
useEffect(() => {
29+
return editor.registerCommand(USE_TRANSFORMER_BRIDGE, ({ selection, formatType, transformation }) => {
30+
selection = selection || $getSelection()
31+
// if we don't have a selection or it's not a range selection, bail
32+
if (!$isRangeSelection(selection) || selection.isCollapsed()) return false
33+
34+
// get the markdown from the selection
35+
const markdown = selection.getTextContent()
36+
// new markdown to be inserted in the original editor
37+
let newMarkdown = ''
38+
39+
// bridge editor update cycle
40+
bridgeRef.current.update(() => {
41+
// make sure we're working with a clean bridge
42+
$getRoot().clear()
43+
44+
// transform markdown to lexical nodes
45+
$markdownToLexical(markdown)
46+
47+
// DEBUG: what are we transforming?
48+
if (process.env.NODE_ENV !== 'production') {
49+
console.log('[Transformer Bridge] BEFORE TRANSFORMATION root with children', $debugNodeToJSON($getRoot()))
50+
}
51+
52+
// bridge editor selection
53+
$selectAll()
54+
const innerSelection = $getSelection()
55+
56+
// if we have a selection, apply the transformation
57+
if (innerSelection) {
58+
const hasExplicitLink = /\[.*\]\(.*\)/.test(markdown)
59+
60+
switch (formatType) {
61+
case 'format':
62+
innerSelection.formatText(transformation)
63+
break
64+
case 'block':
65+
$formatBlock(bridgeRef.current, transformation)
66+
break
67+
case 'link':
68+
if (hasExplicitLink) {
69+
$toggleLink(bridgeRef.current, null)
70+
} else {
71+
// check if selection is a URL
72+
const text = innerSelection.getTextContent()
73+
const isURL = URL_REGEXP.test(text)
74+
$toggleLink(bridgeRef.current, isURL ? text : 'https://')
75+
}
76+
break
77+
}
78+
79+
// get the new markdown from the bridge editor
80+
newMarkdown = $lexicalToMarkdown()
81+
}
82+
83+
// we're done, clear the bridge
84+
$getRoot().clear()
85+
})
86+
87+
// if we don't have new markdown, bail
88+
if (!newMarkdown) return false
89+
90+
// insert the new markdown into the original editor
91+
$insertMarkdown(newMarkdown)
92+
return true
93+
}, COMMAND_PRIORITY_EDITOR)
94+
}, [editor, bridgeRef])
95+
96+
return null
97+
}

0 commit comments

Comments
 (0)