-
Notifications
You must be signed in to change notification settings - Fork 17
feat(text-editor): own ProseMirror schema assembly #3988
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
e7e0558
a4ee46e
7ebb320
26dc249
eab38cf
fd1af6c
53af9a2
0154e95
5747dce
c6b5fbc
395c0c5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,24 @@ | ||
| import { toggleMark, setBlockType, wrapIn, lift } from 'prosemirror-commands'; | ||
| import { | ||
| chainCommands, | ||
| exitCode, | ||
| joinDown, | ||
| joinUp, | ||
| lift, | ||
| selectParentNode, | ||
| setBlockType, | ||
| toggleMark, | ||
| wrapIn, | ||
| } from 'prosemirror-commands'; | ||
| import { undo, redo } from 'prosemirror-history'; | ||
| import { undoInputRule } from 'prosemirror-inputrules'; | ||
| import { Schema, MarkType, NodeType, Attrs } from 'prosemirror-model'; | ||
| import { findWrapping, liftTarget } from 'prosemirror-transform'; | ||
| import { | ||
| splitListItem, | ||
| liftListItem, | ||
| sinkListItem, | ||
| } from 'prosemirror-schema-list'; | ||
| import { Command, EditorState, TextSelection } from 'prosemirror-state'; | ||
| import { findWrapping, liftTarget } from 'prosemirror-transform'; | ||
| import { EditorMenuTypes, EditorTextLink, LevelMapping } from './types'; | ||
| import { getLinkAttributes } from '../plugins/link/utils'; | ||
|
|
||
|
|
@@ -295,6 +312,61 @@ const createListCommand = ( | |
| return command; | ||
| }; | ||
|
|
||
| const createSplitListItemCommand = (schema: Schema): CommandWithActive => { | ||
| const type = schema.nodes.list_item; | ||
|
|
||
| return splitListItem(type); | ||
| }; | ||
|
|
||
| const createLiftListItemCommand = (schema: Schema): CommandWithActive => { | ||
| const type = schema.nodes.list_item; | ||
|
|
||
| return liftListItem(type); | ||
| }; | ||
|
|
||
| const createSinkListItemCommand = (schema: Schema): CommandWithActive => { | ||
| const type = schema.nodes.list_item; | ||
|
|
||
| return sinkListItem(type); | ||
| }; | ||
|
|
||
| const createHardBreakCommand = (schema: Schema): CommandWithActive => { | ||
| const br = schema.nodes.hard_break; | ||
|
|
||
| return chainCommands(exitCode, (state, dispatch) => { | ||
| if (dispatch) { | ||
| dispatch( | ||
| state.tr.replaceSelectionWith(br.create()).scrollIntoView() | ||
| ); | ||
| } | ||
|
|
||
| return true; | ||
| }); | ||
| }; | ||
|
|
||
| const createHorizontalRuleCommand = (schema: Schema): CommandWithActive => { | ||
| const hr = schema.nodes.horizontal_rule; | ||
|
|
||
| return (state, dispatch) => { | ||
| if (dispatch) { | ||
| dispatch( | ||
| state.tr.replaceSelectionWith(hr.create()).scrollIntoView() | ||
| ); | ||
| } | ||
|
|
||
| return true; | ||
| }; | ||
| }; | ||
|
|
||
| const createWrapInNodeCommand = ( | ||
| schema: Schema, | ||
| nodeType: string | ||
| ): CommandWithActive => { | ||
| const type = schema.nodes[nodeType]; | ||
|
|
||
| return wrapIn(type); | ||
| }; | ||
|
|
||
| const commandMapping: CommandMapping = { | ||
| strong: createToggleMarkCommand, | ||
| em: createToggleMarkCommand, | ||
|
|
@@ -320,15 +392,44 @@ const commandMapping: CommandMapping = { | |
| LevelMapping.Heading, | ||
| LevelMapping.three | ||
| ), | ||
| headerlevel4: (schema) => | ||
| createSetNodeTypeCommand( | ||
| schema, | ||
| LevelMapping.Heading, | ||
| LevelMapping.four | ||
| ), | ||
| headerlevel5: (schema) => | ||
| createSetNodeTypeCommand( | ||
| schema, | ||
| LevelMapping.Heading, | ||
| LevelMapping.five | ||
| ), | ||
| headerlevel6: (schema) => | ||
| createSetNodeTypeCommand( | ||
| schema, | ||
| LevelMapping.Heading, | ||
| LevelMapping.six | ||
| ), | ||
| blockquote: (schema) => | ||
| createWrapInCommand(schema, EditorMenuTypes.Blockquote), | ||
|
|
||
| code_block: (schema) => | ||
| createSetNodeTypeCommand(schema, EditorMenuTypes.CodeBlock), | ||
| paragraph: (schema) => | ||
| createSetNodeTypeCommand(schema, EditorMenuTypes.Paragraph), | ||
| ordered_list: (schema) => | ||
| createListCommand(schema, EditorMenuTypes.OrderedList), | ||
| bullet_list: (schema) => | ||
| createListCommand(schema, EditorMenuTypes.BulletList), | ||
| split_list_item: (schema) => createSplitListItemCommand(schema), | ||
| lift_list_item: (schema) => createLiftListItemCommand(schema), | ||
| sink_list_item: (schema) => createSinkListItemCommand(schema), | ||
| hard_break: (schema) => createHardBreakCommand(schema), | ||
| horizontal_rule: (schema) => createHorizontalRuleCommand(schema), | ||
| wrap_bullet_list: (schema) => | ||
| createWrapInNodeCommand(schema, 'bullet_list'), | ||
| wrap_ordered_list: (schema) => | ||
| createWrapInNodeCommand(schema, 'ordered_list'), | ||
| wrap_blockquote: (schema) => createWrapInNodeCommand(schema, 'blockquote'), | ||
| }; | ||
|
|
||
| export class MenuCommandFactory { | ||
|
|
@@ -347,16 +448,59 @@ export class MenuCommandFactory { | |
| return commandFunc(this.schema, mark, link); | ||
| } | ||
|
|
||
| buildKeymap() { | ||
| buildKeymap(): { [key: string]: Command } { | ||
| return { | ||
| // History | ||
| 'Mod-z': undo, | ||
| 'Shift-Mod-z': redo, | ||
| Backspace: undoInputRule, | ||
|
|
||
| // Navigation | ||
| 'Alt-ArrowUp': joinUp, | ||
| 'Alt-ArrowDown': joinDown, | ||
| 'Mod-BracketLeft': lift, | ||
| Escape: selectParentNode, | ||
|
|
||
| // Mark toggles | ||
| 'Mod-b': this.getCommand(EditorMenuTypes.Bold), | ||
| 'Mod-B': this.getCommand(EditorMenuTypes.Bold), | ||
| 'Mod-i': this.getCommand(EditorMenuTypes.Italic), | ||
| 'Mod-I': this.getCommand(EditorMenuTypes.Italic), | ||
| 'Mod-`': this.getCommand(EditorMenuTypes.Code), | ||
| 'Mod-Shift-x': this.getCommand(EditorMenuTypes.Strikethrough), | ||
| 'Mod-Shift-X': this.getCommand(EditorMenuTypes.Strikethrough), | ||
|
|
||
| // Block types (Mod-Shift) | ||
| 'Mod-Shift-1': this.getCommand(EditorMenuTypes.HeaderLevel1), | ||
| 'Mod-Shift-2': this.getCommand(EditorMenuTypes.HeaderLevel2), | ||
| 'Mod-Shift-3': this.getCommand(EditorMenuTypes.HeaderLevel3), | ||
| 'Mod-Shift-X': this.getCommand(EditorMenuTypes.Strikethrough), | ||
| 'Mod-`': this.getCommand(EditorMenuTypes.Code), | ||
| 'Mod-Shift-c': this.getCommand(EditorMenuTypes.CodeBlock), | ||
| 'Mod-Shift-C': this.getCommand(EditorMenuTypes.CodeBlock), | ||
|
Comment on lines
+465
to
478
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These new shortcuts are shadowed by the base keymap and won’t hit Line 352, Line 354, Line 359, and Line 362 overlap with bindings in Suggested directionUse a single source of truth for overlapping shortcuts:
🤖 Prompt for AI Agents |
||
|
|
||
| // Block types (Shift-Ctrl) | ||
| 'Shift-Ctrl-0': this.getCommand(EditorMenuTypes.Paragraph), | ||
| 'Shift-Ctrl-1': this.getCommand(EditorMenuTypes.HeaderLevel1), | ||
| 'Shift-Ctrl-2': this.getCommand(EditorMenuTypes.HeaderLevel2), | ||
| 'Shift-Ctrl-3': this.getCommand(EditorMenuTypes.HeaderLevel3), | ||
| 'Shift-Ctrl-4': this.getCommand(EditorMenuTypes.HeaderLevel4), | ||
| 'Shift-Ctrl-5': this.getCommand(EditorMenuTypes.HeaderLevel5), | ||
| 'Shift-Ctrl-6': this.getCommand(EditorMenuTypes.HeaderLevel6), | ||
| 'Shift-Ctrl-\\': this.getCommand(EditorMenuTypes.CodeBlock), | ||
|
|
||
| // List operations | ||
| Enter: this.getCommand(EditorMenuTypes.SplitListItem), | ||
| 'Mod-[': this.getCommand(EditorMenuTypes.LiftListItem), | ||
| 'Mod-]': this.getCommand(EditorMenuTypes.SinkListItem), | ||
| 'Shift-Ctrl-8': this.getCommand(EditorMenuTypes.WrapInBulletList), | ||
| 'Shift-Ctrl-9': this.getCommand(EditorMenuTypes.WrapInOrderedList), | ||
|
|
||
| // Wrapping | ||
| 'Ctrl->': this.getCommand(EditorMenuTypes.WrapInBlockquote), | ||
|
|
||
| // Insertions | ||
| 'Mod-Enter': this.getCommand(EditorMenuTypes.HardBreak), | ||
| 'Shift-Enter': this.getCommand(EditorMenuTypes.HardBreak), | ||
| 'Mod-_': this.getCommand(EditorMenuTypes.HorizontalRule), | ||
| }; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| import { Plugin } from 'prosemirror-state'; | ||
| import { Schema } from 'prosemirror-model'; | ||
| import { keymap } from 'prosemirror-keymap'; | ||
| import { baseKeymap } from 'prosemirror-commands'; | ||
| import { history } from 'prosemirror-history'; | ||
| import { dropCursor } from 'prosemirror-dropcursor'; | ||
| import { gapCursor } from 'prosemirror-gapcursor'; | ||
| import { buildInputRules } from './input-rules'; | ||
|
|
||
| /** | ||
| * Assembles the base ProseMirror plugins for the text editor. | ||
| * | ||
| * Provides infrastructure plugins only: input rules, base keymap | ||
| * (Enter, Backspace, Delete), drop cursor, gap cursor, and history. | ||
| * Schema-aware keybindings are handled by MenuCommandFactory.buildKeymap(). | ||
| * @param schema - The ProseMirror schema to build plugins for | ||
| */ | ||
| export function buildBasePlugins(schema: Schema): Plugin[] { | ||
| return [ | ||
| buildInputRules(schema), | ||
| keymap(baseKeymap), | ||
| dropCursor(), | ||
| gapCursor(), | ||
| history(), | ||
| ]; | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -120,13 +120,15 @@ function getImageHTML(attrs: ImageNodeAttrs): string { | |||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| const styleAttribute = style.length > 0 ? ` style="${style.join('')}"` : ''; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| const titleAttribute = attrs.title ? ` title="${attrs.title}"` : ''; | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| return `<img src="${attrs.src}" alt="${attrs.alt}"${styleAttribute} />`; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| return `<img src="${attrs.src}" alt="${attrs.alt}"${titleAttribute}${styleAttribute} />`; | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+123
to
+125
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Escape serialized attributes before emitting image HTML. Line 123-Line 125 interpolates Suggested fix+function escapeAttr(value: string): string {
+ return value
+ .replace(/&/g, '&')
+ .replace(/"/g, '"')
+ .replace(/</g, '<')
+ .replace(/>/g, '>');
+}
+
function getImageHTML(attrs: ImageNodeAttrs): string {
const style = [];
@@
- const titleAttribute = attrs.title ? ` title="${attrs.title}"` : '';
-
- return `<img src="${attrs.src}" alt="${attrs.alt}"${titleAttribute}${styleAttribute} />`;
+ const titleAttribute = attrs.title
+ ? ` title="${escapeAttr(attrs.title)}"`
+ : '';
+
+ return `<img src="${escapeAttr(attrs.src)}" alt="${escapeAttr(
+ attrs.alt
+ )}"${titleAttribute}${styleAttribute} />`;
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| export interface ImageNodeAttrs { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| src: string; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| alt: string; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| title?: string | null; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| state: EditorImageState; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| fileInfoId: string | number; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| height?: string; | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -140,9 +142,11 @@ function createImageNodeSpec(language: Languages): NodeSpec { | |||||||||||||||||||||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| group: 'inline', | ||||||||||||||||||||||||||||||||||||||||||||||||||
| inline: true, | ||||||||||||||||||||||||||||||||||||||||||||||||||
| draggable: true, | ||||||||||||||||||||||||||||||||||||||||||||||||||
| attrs: { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| src: { default: '' }, | ||||||||||||||||||||||||||||||||||||||||||||||||||
| alt: { default: '' }, | ||||||||||||||||||||||||||||||||||||||||||||||||||
| title: { default: null }, | ||||||||||||||||||||||||||||||||||||||||||||||||||
| fileInfoId: { default: '' }, | ||||||||||||||||||||||||||||||||||||||||||||||||||
| height: { default: '' }, | ||||||||||||||||||||||||||||||||||||||||||||||||||
| width: { default: '' }, | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -169,6 +173,7 @@ function createImageNodeSpec(language: Languages): NodeSpec { | |||||||||||||||||||||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| src: dom.getAttribute('src') || '', | ||||||||||||||||||||||||||||||||||||||||||||||||||
| alt: dom.getAttribute('alt') || 'file', | ||||||||||||||||||||||||||||||||||||||||||||||||||
| title: dom.getAttribute('title') || null, | ||||||||||||||||||||||||||||||||||||||||||||||||||
| width: dom.style.width || '', | ||||||||||||||||||||||||||||||||||||||||||||||||||
| height: dom.style.height || '', | ||||||||||||||||||||||||||||||||||||||||||||||||||
| maxWidth: '100%', | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -230,6 +235,7 @@ function updateImageElement( | |||||||||||||||||||||||||||||||||||||||||||||||||
| node: Node | ||||||||||||||||||||||||||||||||||||||||||||||||||
| ): HTMLImageElement { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| img.alt = node.attrs.alt; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| img.title = node.attrs.title || ''; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| applyImageStyles(img, node); | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| return img; | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -239,6 +245,10 @@ function createImageElement(node: Node): HTMLImageElement { | |||||||||||||||||||||||||||||||||||||||||||||||||
| const img = document.createElement('img'); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| img.src = node.attrs.src; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| img.alt = node.attrs.alt; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| if (node.attrs.title) { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| img.title = node.attrs.title; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| applyImageStyles(img, node); | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| return img; | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: Lundalogik/lime-elements
Length of output: 367
🌐 Web query:
orderedmap npm package latest version current💡 Result:
The latest version of the orderedmap npm package is 2.1.1, published on May 17, 2023. It is a persistent ordered mapping from strings to values, maintained by Marijn Haverbeke, with the repository at https://github.com/marijnh/orderedmap. No newer versions have been released as of 2026-03-30.
Citations:
Declare
orderedmapexplicitly before importing it directly.orderedmapis imported directly insrc/components/text-editor/prosemirror-adapter/schema/nodes.tsandsrc/components/text-editor/prosemirror-adapter/schema/marks.tsbut not declared inpackage.json. This currently relies on transitive dependency hoisting and will break in stricter installs.Suggested fix
"dependencies": { "diff": "^8.0.3", + "orderedmap": "^2.1.1", "prosemirror-commands": "^1.7.1", "prosemirror-dropcursor": "^1.8.2", "prosemirror-gapcursor": "^1.4.1",📝 Committable suggestion
🤖 Prompt for AI Agents