diff --git a/etc/lime-elements.api.md b/etc/lime-elements.api.md index d5f811d145..42b6882007 100644 --- a/etc/lime-elements.api.md +++ b/etc/lime-elements.api.md @@ -772,7 +772,7 @@ export namespace Components { // @beta export interface LimelProsemirrorAdapter { "clear": () => Promise; - "contentType": 'markdown' | 'html'; + "contentType": ContentType; // Warning: (ae-extra-release-tag) The doc comment should not contain more than one release tag // // @alpha @@ -967,6 +967,9 @@ export type Config = { featureSwitches?: Record; }; +// @beta +export type ContentType = 'markdown' | 'html'; + // @public export interface CustomColorSwatch { disabled?: boolean; @@ -3249,7 +3252,7 @@ export namespace JSX { // // @beta export interface LimelProsemirrorAdapter { - "contentType"?: 'markdown' | 'html'; + "contentType"?: ContentType; // Warning: (ae-extra-release-tag) The doc comment should not contain more than one release tag // // @alpha @@ -3283,8 +3286,10 @@ export namespace JSX { // (undocumented) export interface LimelProsemirrorAdapterAttributes { + // Warning: (ae-incompatible-release-tags) The symbol "contentType" is marked as @public, but its signature references "ContentType" which is marked as @beta + // // (undocumented) - "contentType": 'markdown' | 'html'; + "contentType": ContentType; // (undocumented) "disabled": boolean; // (undocumented) diff --git a/package-lock.json b/package-lock.json index 7f651990ed..33347cea9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,6 +54,7 @@ "prosemirror-model": ">=1.22.1", "prosemirror-schema-basic": "^1.2.4", "prosemirror-tables": "^1.8.5", + "prosemirror-test-builder": "^1.1.1", "react": "^19.2.7", "react-dom": "^19.2.7", "rehype-parse": "^9.0.1", @@ -8411,6 +8412,18 @@ "prosemirror-view": "^1.41.4" } }, + "node_modules/prosemirror-test-builder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/prosemirror-test-builder/-/prosemirror-test-builder-1.1.1.tgz", + "integrity": "sha512-DJ1+4TNTE9ZcYN/ozXCaWJVrGA99UttMoVvZuidvAotRg7FaiNtEYxL/vlDwfZDRnzJDXNYhmM3XPv3EweK7yA==", + "dev": true, + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-schema-basic": "^1.0.0", + "prosemirror-schema-list": "^1.0.0" + } + }, "node_modules/prosemirror-transform": { "version": "1.12.0", "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz", @@ -16674,6 +16687,17 @@ "prosemirror-view": "^1.41.4" } }, + "prosemirror-test-builder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/prosemirror-test-builder/-/prosemirror-test-builder-1.1.1.tgz", + "integrity": "sha512-DJ1+4TNTE9ZcYN/ozXCaWJVrGA99UttMoVvZuidvAotRg7FaiNtEYxL/vlDwfZDRnzJDXNYhmM3XPv3EweK7yA==", + "dev": true, + "requires": { + "prosemirror-model": ">=1.22.1", + "prosemirror-schema-basic": "^1.0.0", + "prosemirror-schema-list": "^1.0.0" + } + }, "prosemirror-transform": { "version": "1.12.0", "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz", diff --git a/package.json b/package.json index 11672a833f..e2bb26ed1c 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "api:update": "npm run build && node scripts/fix-tsdoc-tags.cjs && api-extractor run --local --verbose", "api:verify": "(shx test -f src/components.d.ts || (npm run build && npm run build)) && node scripts/fix-tsdoc-tags.cjs && api-extractor run", "build": "cross-env-shell NODE_ENV=prod SASS_PATH=node_modules \"stencil build --config stencil.config.dist.ts\"", + "clean": "shx rm -rf .stencil dist www", "dev": "cross-env-shell SASS_PATH=node_modules \"stencil build --dev --docs\"", "watch": "cross-env-shell SASS_PATH=node_modules \"stencil build --dev --watch --docs --serve\"", "watch:prod": "shx rm -rf www/ && cross-env-shell SASS_PATH=node_modules \"stencil build --watch\"", @@ -96,6 +97,7 @@ "prosemirror-model": ">=1.22.1", "prosemirror-schema-basic": "^1.2.4", "prosemirror-tables": "^1.8.5", + "prosemirror-test-builder": "^1.1.1", "react": "^19.2.7", "react-dom": "^19.2.7", "rehype-parse": "^9.0.1", diff --git a/src/components/text-editor/prosemirror-adapter/editor-config.spec.ts b/src/components/text-editor/prosemirror-adapter/editor-config.spec.ts new file mode 100644 index 0000000000..ad26f2bccb --- /dev/null +++ b/src/components/text-editor/prosemirror-adapter/editor-config.spec.ts @@ -0,0 +1,181 @@ +import { EditorState, TextSelection } from 'prosemirror-state'; +import { Slice } from 'prosemirror-model'; +import { EditorView } from 'prosemirror-view'; +import { + builders, + eq, + NodeBuilder, + MarkBuilder, +} from 'prosemirror-test-builder'; +import { + buildEditorSchema, + buildEditorPlugins, + EditorPluginsOptions, +} from './editor-config'; +import { MenuCommandFactory } from './menu/menu-commands'; +import { EditorMenuTypes } from './menu/types'; +import { ContentTypeConverter } from '../utils/content-type-converter'; +import { pluginKey as imageInserterPluginKey } from './plugins/image/inserter'; +import { linkPluginKey } from './plugins/link/link-plugin'; + +/** + * Integration tests for the text editor's real stack: the production schema + * and the production *ordered* plugin list from `buildEditorSchema` / + * `buildEditorPlugins`. + * + * Transactions are applied at the state level (`state.apply`) rather than + * through an `EditorView`: state application runs every plugin's + * `filterTransaction`/`appendTransaction` (the cross-plugin integrity path) + * without needing a real DOM selection, which the spec environment lacks. + * View-driven behaviour (real key/paste events, scrolling, focus) is the + * domain of the e2e tests. + */ +describe('editor-config (real-stack integration)', () => { + const schema = buildEditorSchema({ + customElements: [], + contentType: 'html', + language: 'en', + }); + const factory = new MenuCommandFactory(schema); + + // The trigger plugin takes a content converter but only invokes it on + // trigger events, which these tests never fire — a no-op keeps the setup + // hermetic. + const contentConverter: ContentTypeConverter = { + parseAsHTML: async () => '', + serialize: () => '', + }; + const noopImagePasted: EditorPluginsOptions['onImagePasted'] = (data) => + new CustomEvent('imagePasted', { detail: data }); + + const plugins = buildEditorPlugins({ + schema: schema, + menuCommandFactory: factory, + contentConverter: contentConverter, + language: 'en', + contentType: 'html', + triggerCharacters: [], + onNewLinkSelection: () => undefined, + onImagePasted: noopImagePasted, + onActiveItemsChange: () => undefined, + }); + + const builder = builders(schema, { p: { nodeType: 'paragraph' } }); + const doc = builder.doc as NodeBuilder; + const p = builder.p as NodeBuilder; + const strong = builder.strong as MarkBuilder; + + describe('the real stack instantiates', () => { + it('builds the production schema (nodes + marks the editor uses)', () => { + expect(schema.nodes.image).toBeDefined(); + expect(schema.nodes.table).toBeDefined(); + expect(schema.nodes.bullet_list).toBeDefined(); + expect(schema.marks.strikethrough).toBeDefined(); + expect(schema.marks.link).toBeDefined(); + }); + + it('assembles the full ordered plugin set', () => { + expect(Array.isArray(plugins)).toBe(true); + expect(plugins.length).toBeGreaterThan(8); + + const state = EditorState.create({ doc: doc(p()), plugins }); + expect(state.plugins).toHaveLength(plugins.length); + }); + }); + + describe('commands work against the real schema', () => { + it('the Bold command applies the strong mark to the selection', () => { + const startDoc = doc(p('hello')); + let state = EditorState.create({ + doc: startDoc, + plugins: plugins, + selection: TextSelection.create( + startDoc, + startDoc.tag.a, + startDoc.tag.b + ), + }); + + const bold = factory.getCommand(EditorMenuTypes.Bold); + bold(state, (transaction) => { + state = state.apply(transaction); + }); + + expect(eq(state.doc, doc(p(strong('hello'))))).toBe(true); + }); + }); + + describe('shared-event (paste) handler order', () => { + const linkPlugin = plugins.find( + (plugin) => plugin.spec.key === linkPluginKey + ); + const imagePlugin = plugins.find( + (plugin) => plugin.spec.key === imageInserterPluginKey + ); + + it('both the link and image plugins register handlePaste, link first', () => { + expect(linkPlugin).toBeDefined(); + expect(imagePlugin).toBeDefined(); + expect(typeof linkPlugin?.props.handlePaste).toBe('function'); + expect(typeof imagePlugin?.props.handlePaste).toBe('function'); + + // ProseMirror resolves handlePaste first-truthy-wins in plugin + // order, so the relative order of these two decides which claims a + // paste both could handle. + expect(plugins.indexOf(linkPlugin)).toBeLessThan( + plugins.indexOf(imagePlugin) + ); + }); + + it('neither plugin claims a plain paste, so others still run', () => { + expect(linkPlugin).toBeDefined(); + expect(imagePlugin).toBeDefined(); + + const view = {} as unknown as EditorView; + const plainPaste = { + clipboardData: { + getData: () => 'plain text without a link', + files: [], + }, + } as unknown as ClipboardEvent; + + expect( + linkPlugin?.props.handlePaste?.(view, plainPaste, Slice.empty) + ).toBeFalsy(); + expect( + imagePlugin?.props.handlePaste?.(view, plainPaste, Slice.empty) + ).toBeFalsy(); + }); + }); + + describe('transactions stay consistent across the plugin set', () => { + it('keeps the document valid across a sequence of transactions', () => { + const startDoc = doc(p('Hello')); + let state = EditorState.create({ + doc: startDoc, + plugins: plugins, + selection: TextSelection.create( + startDoc, + startDoc.tag.a, + startDoc.tag.b + ), + }); + + expect(() => { + const bold = factory.getCommand(EditorMenuTypes.Bold); + bold(state, (transaction) => { + state = state.apply(transaction); + }); + + state = state.apply( + state.tr.setSelection(TextSelection.atEnd(state.doc)) + ); + state = state.apply(state.tr.insertText(' world')); + + state.doc.check(); + }).not.toThrow(); + + expect(state.doc.textContent).toBe('Hello world'); + }); + }); +}); diff --git a/src/components/text-editor/prosemirror-adapter/editor-config.ts b/src/components/text-editor/prosemirror-adapter/editor-config.ts new file mode 100644 index 0000000000..06ab96f4ae --- /dev/null +++ b/src/components/text-editor/prosemirror-adapter/editor-config.ts @@ -0,0 +1,132 @@ +import { Plugin } from 'prosemirror-state'; +import { Schema } from 'prosemirror-model'; +import { schema as basicSchema } from 'prosemirror-schema-basic'; +import { addListNodes } from 'prosemirror-schema-list'; +import { exampleSetup } from 'prosemirror-example-setup'; +import { keymap } from 'prosemirror-keymap'; +import { MenuCommandFactory } from './menu/menu-commands'; +import { editorMenuTypesArray } from './menu/types'; +import { strikethrough } from './menu/menu-schema-extender'; +import { linkMarkSpec } from './plugins/link/link-mark'; +import { createLinkPlugin } from './plugins/link/link-plugin'; +import { createImageInserterPlugin } from './plugins/image/inserter'; +import { createImageViewPlugin } from './plugins/image/view'; +import { createMenuStateTrackingPlugin } from './plugins/menu-state-tracking-plugin'; +import { createActionBarInteractionPlugin } from './plugins/menu-action-interaction-plugin'; +import { createTriggerPlugin } from './plugins/trigger/factory'; +import { getTableNodes, getTableEditingPlugins } from './plugins/table-plugin'; +import { getImageNode } from './plugins/image/node'; +import { createNodeSpec } from '../utils/plugin-factory'; +import { ContentTypeConverter } from '../utils/content-type-converter'; +import { CustomElementDefinition } from '../../../global/shared-types/custom-element.types'; +import { Languages } from '../../date-picker/date.types'; +import { TriggerCharacter, InlineImages } from '../text-editor.types'; + +/** + * Content format the text editor reads and emits. + * + * @beta + */ +export type ContentType = 'markdown' | 'html'; + +export interface EditorSchemaOptions { + customElements: CustomElementDefinition[]; + contentType: ContentType; + language: Languages; + inlineImages?: InlineImages; +} + +/** + * Builds the ProseMirror schema used by the text editor. + * + * This is the single source of truth for the editor's schema: the + * `limel-prosemirror-adapter` component and any test that needs the real + * schema both call this, so the two can never drift apart. + * + * @param options - schema configuration derived from the editor's props + * @returns the configured ProseMirror schema + */ +export function buildEditorSchema(options: EditorSchemaOptions): Schema { + const { customElements, contentType, language, inlineImages } = options; + + let nodes = basicSchema.spec.nodes; + + for (const customElement of customElements) { + const newNodeSpec = createNodeSpec(customElement); + const nodeName = customElement.tagName; + + nodes = nodes.append({ [nodeName]: newNodeSpec }); + } + nodes = addListNodes(nodes, 'paragraph block*', 'block'); + + if (contentType === 'html') { + nodes = nodes.append(getTableNodes()); + } + + nodes = nodes.append(getImageNode(language, inlineImages)); + + return new Schema({ + nodes: nodes, + marks: basicSchema.spec.marks.append({ + strikethrough: strikethrough, + link: linkMarkSpec, + }), + }); +} + +export interface EditorPluginsOptions { + schema: Schema; + menuCommandFactory: MenuCommandFactory; + contentConverter: ContentTypeConverter; + language: Languages; + contentType: ContentType; + triggerCharacters: TriggerCharacter[]; + inlineImages?: InlineImages; + onNewLinkSelection: Parameters[0]; + onImagePasted: Parameters[0]; + onActiveItemsChange: Parameters[2]; +} + +/** + * Builds the ordered list of ProseMirror plugins used by the text editor. + * + * Plugin order is significant: ProseMirror resolves event props + * (`handlePaste`, `handleDOMEvents`, `handleKeyDown`) by calling plugins in + * this order and stopping at the first that returns a truthy value, and it + * chains `appendTransaction` in this order. Changing the order changes + * behavior. Callbacks the plugins need are injected so this can be built + * outside the component (e.g. in tests). + * + * @param options - the schema, command factory, converter and plugin callbacks + * @returns the ordered plugin list + */ +export function buildEditorPlugins(options: EditorPluginsOptions): Plugin[] { + const { + schema, + menuCommandFactory, + contentConverter, + language, + contentType, + triggerCharacters, + inlineImages, + onNewLinkSelection, + onImagePasted, + onActiveItemsChange, + } = options; + + return [ + ...exampleSetup({ schema: schema, menuBar: false }), + keymap(menuCommandFactory.buildKeymap()), + createTriggerPlugin(triggerCharacters, contentConverter), + createLinkPlugin(onNewLinkSelection), + createImageInserterPlugin(onImagePasted, inlineImages), + createImageViewPlugin(language), + createMenuStateTrackingPlugin( + editorMenuTypesArray, + menuCommandFactory, + onActiveItemsChange + ), + createActionBarInteractionPlugin(menuCommandFactory), + ...getTableEditingPlugins(contentType === 'html'), + ]; +} diff --git a/src/components/text-editor/prosemirror-adapter/prosemirror-adapter.tsx b/src/components/text-editor/prosemirror-adapter/prosemirror-adapter.tsx index 7a2f51fea0..b7c7db9157 100644 --- a/src/components/text-editor/prosemirror-adapter/prosemirror-adapter.tsx +++ b/src/components/text-editor/prosemirror-adapter/prosemirror-adapter.tsx @@ -13,10 +13,6 @@ import { import { EditorState, Transaction, Selection } from 'prosemirror-state'; import { EditorView } from 'prosemirror-view'; import { Schema, DOMParser } from 'prosemirror-model'; -import { schema } from 'prosemirror-schema-basic'; -import { addListNodes } from 'prosemirror-schema-list'; -import { exampleSetup } from 'prosemirror-example-setup'; -import { keymap } from 'prosemirror-keymap'; import { ActionBarItem } from '../../../components/action-bar/action-bar.types'; import { ListSeparator } from '../../../components/list-item/list-item.types'; import { MenuCommandFactory } from './menu/menu-commands'; @@ -24,26 +20,13 @@ import { menuTranslationIDs, getTextEditorMenuItems } from './menu/menu-items'; import { ContentTypeConverter } from '../utils/content-type-converter'; import { MarkdownConverter } from '../utils/markdown-converter'; import { HTMLConverter } from '../utils/html-converter'; -import { - EditorMenuTypes, - EditorTextLink, - editorMenuTypesArray, -} from './menu/types'; +import { EditorMenuTypes, EditorTextLink } from './menu/types'; import translate from '../../../global/translations'; import { createRandomString } from '../../../util/random-string'; import { isItem } from '../../action-bar/is-item'; import { cloneDeep, debounce } from 'lodash-es'; import { Languages } from '../../date-picker/date.types'; -import { strikethrough } from './menu/menu-schema-extender'; -import { createLinkPlugin } from './plugins/link/link-plugin'; -import { linkMarkSpec } from './plugins/link/link-mark'; -import { createImageInserterPlugin } from './plugins/image/inserter'; -import { createImageViewPlugin } from './plugins/image/view'; -import { createMenuStateTrackingPlugin } from './plugins/menu-state-tracking-plugin'; -import { createActionBarInteractionPlugin } from './plugins/menu-action-interaction-plugin'; import { CustomElementDefinition } from '../../../global/shared-types/custom-element.types'; -import { createNodeSpec } from '../utils/plugin-factory'; -import { createTriggerPlugin } from './plugins/trigger/factory'; import { TriggerCharacter, ImageInserter, @@ -53,8 +36,12 @@ import { InlineImages, isInlineImageTag, } from '../text-editor.types'; -import { getTableNodes, getTableEditingPlugins } from './plugins/table-plugin'; -import { getImageNode, imageCache } from './plugins/image/node'; +import { imageCache } from './plugins/image/node'; +import { + buildEditorSchema, + buildEditorPlugins, + ContentType, +} from './editor-config'; import { EditorUiType } from '../types'; import { getMetadataFromDoc, @@ -84,7 +71,7 @@ export class ProsemirrorAdapter { * Assumed to be set only once, so not reactive to changes */ @Prop() - public contentType: 'markdown' | 'html' = 'markdown'; + public contentType: ContentType = 'markdown'; /** * The value of the editor, expected to be markdown @@ -489,30 +476,11 @@ export class ProsemirrorAdapter { } private initializeSchema() { - let nodes = schema.spec.nodes; - - for (const customElement of this.customElements) { - const newNodeSpec = createNodeSpec(customElement); - const nodeName = customElement.tagName; - - nodes = nodes.append({ [nodeName]: newNodeSpec }); - } - nodes = addListNodes(nodes, 'paragraph block*', 'block'); - - if (this.contentType === 'html') { - nodes = nodes.append(getTableNodes()); - } - - nodes = nodes.append( - getImageNode(this.language, this.validatedInlineImages) - ); - - return new Schema({ - nodes: nodes, - marks: schema.spec.marks.append({ - strikethrough: strikethrough, - link: linkMarkSpec, - }), + return buildEditorSchema({ + customElements: this.customElements, + contentType: this.contentType, + language: this.language, + inlineImages: this.validatedInlineImages, }); } @@ -535,27 +503,18 @@ export class ProsemirrorAdapter { private createEditorState(initialDoc) { return EditorState.create({ doc: initialDoc, - plugins: [ - ...exampleSetup({ schema: this.schema, menuBar: false }), - keymap(this.menuCommandFactory.buildKeymap()), - createTriggerPlugin( - this.triggerCharacters, - this.contentConverter - ), - createLinkPlugin(this.handleNewLinkSelection), - createImageInserterPlugin( - this.imagePasted.emit, - this.validatedInlineImages - ), - createImageViewPlugin(this.language), - createMenuStateTrackingPlugin( - editorMenuTypesArray, - this.menuCommandFactory, - this.updateActiveActionBarItems - ), - createActionBarInteractionPlugin(this.menuCommandFactory), - ...getTableEditingPlugins(this.contentType === 'html'), - ], + plugins: buildEditorPlugins({ + schema: this.schema, + menuCommandFactory: this.menuCommandFactory, + contentConverter: this.contentConverter, + language: this.language, + contentType: this.contentType, + triggerCharacters: this.triggerCharacters, + inlineImages: this.validatedInlineImages, + onNewLinkSelection: this.handleNewLinkSelection, + onImagePasted: this.imagePasted.emit, + onActiveItemsChange: this.updateActiveActionBarItems, + }), }); }