From b4d9dc3f4dd3520c682c6380f1bdd40fbc5a66fd Mon Sep 17 00:00:00 2001 From: John Traas Date: Wed, 17 Jun 2026 15:04:19 +0200 Subject: [PATCH 1/9] build: add clean script and prosemirror-test-builder dev dependency --- package-lock.json | 24 ++++++++++++++++++++++++ package.json | 2 ++ 2 files changed, 26 insertions(+) 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", From d0aedb1893d3add0c5d9449e50eed4b9fa1532db Mon Sep 17 00:00:00 2001 From: John Traas Date: Wed, 17 Jun 2026 15:04:19 +0200 Subject: [PATCH 2/9] refactor(text-editor): extract schema and plugin assembly into editor-config --- .../prosemirror-adapter/editor-config.ts | 127 ++++++++++++++++++ .../prosemirror-adapter.tsx | 85 +++--------- 2 files changed, 147 insertions(+), 65 deletions(-) create mode 100644 src/components/text-editor/prosemirror-adapter/editor-config.ts 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..350b444207 --- /dev/null +++ b/src/components/text-editor/prosemirror-adapter/editor-config.ts @@ -0,0 +1,127 @@ +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'; + +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..31c7446fd7 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,8 @@ 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 } from './editor-config'; import { EditorUiType } from '../types'; import { getMetadataFromDoc, @@ -489,30 +472,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 +499,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, + }), }); } From 2b0ebebe26fd8ae000e5991534bb328684e50975 Mon Sep 17 00:00:00 2001 From: John Traas Date: Wed, 17 Jun 2026 15:04:20 +0200 Subject: [PATCH 3/9] test(text-editor): add real-stack integration spec for editor-config --- .../prosemirror-adapter/editor-config.spec.ts | 189 ++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 src/components/text-editor/prosemirror-adapter/editor-config.spec.ts 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..4c850794ef --- /dev/null +++ b/src/components/text-editor/prosemirror-adapter/editor-config.spec.ts @@ -0,0 +1,189 @@ +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 proof for the text editor's real stack. + * + * It builds the production schema and the production *ordered* plugin list via + * the exported `buildEditorSchema` / `buildEditorPlugins`, and exercises them + * with the official `prosemirror-test-builder` only. It imports nothing from + * any bespoke test-setup helpers — the point is to show the real editor brain + * is testable as-is. + * + * 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. + * + * Coverage maps to the editor's regression risks: + * - commands produce the expected transaction (A, B); + * - one plugin does not silently shadow another on a shared event (C); + * - a sequence of transactions keeps the document valid (D). + */ +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 = (() => + undefined) as unknown as EditorPluginsOptions['onImagePasted']; + + 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('A — 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('B — 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('C — no plugin shadows another on a shared event', () => { + 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 link plugin coming first is the contract that + // decides who handles a paste that both could claim. + expect(plugins.indexOf(linkPlugin)).toBeLessThan( + plugins.indexOf(imagePlugin) + ); + }); + + it('neither plugin claims a plain paste, so others still run', () => { + 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('D — 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')); + + // Throws if any step produced an invalid document or a + // mis-mapped position. + state.doc.check(); + }).not.toThrow(); + + expect(state.doc.textContent).toBe('Hello world'); + }); + }); +}); From 141782c094584c390e67533c8113988173ba190f Mon Sep 17 00:00:00 2001 From: John Traas Date: Mon, 29 Jun 2026 16:04:58 +0200 Subject: [PATCH 4/9] test(text-editor): keep only load-bearing comments in the integration spec --- .../prosemirror-adapter/editor-config.spec.ts | 29 ++++++------------- 1 file changed, 9 insertions(+), 20 deletions(-) diff --git a/src/components/text-editor/prosemirror-adapter/editor-config.spec.ts b/src/components/text-editor/prosemirror-adapter/editor-config.spec.ts index 4c850794ef..1b0e6c5611 100644 --- a/src/components/text-editor/prosemirror-adapter/editor-config.spec.ts +++ b/src/components/text-editor/prosemirror-adapter/editor-config.spec.ts @@ -19,13 +19,9 @@ import { pluginKey as imageInserterPluginKey } from './plugins/image/inserter'; import { linkPluginKey } from './plugins/link/link-plugin'; /** - * Integration proof for the text editor's real stack. - * - * It builds the production schema and the production *ordered* plugin list via - * the exported `buildEditorSchema` / `buildEditorPlugins`, and exercises them - * with the official `prosemirror-test-builder` only. It imports nothing from - * any bespoke test-setup helpers — the point is to show the real editor brain - * is testable as-is. + * 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 @@ -33,11 +29,6 @@ import { linkPluginKey } from './plugins/link/link-plugin'; * 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. - * - * Coverage maps to the editor's regression risks: - * - commands produce the expected transaction (A, B); - * - one plugin does not silently shadow another on a shared event (C); - * - a sequence of transactions keeps the document valid (D). */ describe('editor-config (real-stack integration)', () => { const schema = buildEditorSchema({ @@ -74,7 +65,7 @@ describe('editor-config (real-stack integration)', () => { const p = builder.p as NodeBuilder; const strong = builder.strong as MarkBuilder; - describe('A — the real stack instantiates', () => { + 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(); @@ -92,7 +83,7 @@ describe('editor-config (real-stack integration)', () => { }); }); - describe('B — commands work against the real schema', () => { + 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({ @@ -114,7 +105,7 @@ describe('editor-config (real-stack integration)', () => { }); }); - describe('C — no plugin shadows another on a shared event', () => { + describe('shared-event (paste) handler order', () => { const linkPlugin = plugins.find( (plugin) => plugin.spec.key === linkPluginKey ); @@ -129,8 +120,8 @@ describe('editor-config (real-stack integration)', () => { expect(typeof imagePlugin.props.handlePaste).toBe('function'); // ProseMirror resolves handlePaste first-truthy-wins in plugin - // order, so the link plugin coming first is the contract that - // decides who handles a paste that both could claim. + // order, so the relative order of these two decides which claims a + // paste both could handle. expect(plugins.indexOf(linkPlugin)).toBeLessThan( plugins.indexOf(imagePlugin) ); @@ -154,7 +145,7 @@ describe('editor-config (real-stack integration)', () => { }); }); - describe('D — transactions stay consistent across the plugin set', () => { + 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({ @@ -178,8 +169,6 @@ describe('editor-config (real-stack integration)', () => { ); state = state.apply(state.tr.insertText(' world')); - // Throws if any step produced an invalid document or a - // mis-mapped position. state.doc.check(); }).not.toThrow(); From a367c56c29d548c80db75e60f51c4efb00ae9652 Mon Sep 17 00:00:00 2001 From: John Traas <122509723+john-traas@users.noreply.github.com> Date: Thu, 9 Jul 2026 09:43:59 +0200 Subject: [PATCH 5/9] test(text-editor): properly type the callback Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../text-editor/prosemirror-adapter/editor-config.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/text-editor/prosemirror-adapter/editor-config.spec.ts b/src/components/text-editor/prosemirror-adapter/editor-config.spec.ts index 1b0e6c5611..ea7165c536 100644 --- a/src/components/text-editor/prosemirror-adapter/editor-config.spec.ts +++ b/src/components/text-editor/prosemirror-adapter/editor-config.spec.ts @@ -45,8 +45,8 @@ describe('editor-config (real-stack integration)', () => { parseAsHTML: async () => '', serialize: () => '', }; - const noopImagePasted = (() => - undefined) as unknown as EditorPluginsOptions['onImagePasted']; + const noopImagePasted: EditorPluginsOptions['onImagePasted'] = (data) => + new CustomEvent('imagePasted', { detail: data }); const plugins = buildEditorPlugins({ schema: schema, From 44cd26a2404d748cb1c5bb82866a449e66b830e6 Mon Sep 17 00:00:00 2001 From: John Traas <122509723+john-traas@users.noreply.github.com> Date: Thu, 9 Jul 2026 09:44:21 +0200 Subject: [PATCH 6/9] test(text-editor): assert plugins exist before testing on them Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../text-editor/prosemirror-adapter/editor-config.spec.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/text-editor/prosemirror-adapter/editor-config.spec.ts b/src/components/text-editor/prosemirror-adapter/editor-config.spec.ts index ea7165c536..16ac0abde2 100644 --- a/src/components/text-editor/prosemirror-adapter/editor-config.spec.ts +++ b/src/components/text-editor/prosemirror-adapter/editor-config.spec.ts @@ -128,6 +128,9 @@ describe('editor-config (real-stack integration)', () => { }); 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: { @@ -137,10 +140,10 @@ describe('editor-config (real-stack integration)', () => { } as unknown as ClipboardEvent; expect( - linkPlugin.props.handlePaste(view, plainPaste, Slice.empty) + linkPlugin!.props.handlePaste(view, plainPaste, Slice.empty) ).toBeFalsy(); expect( - imagePlugin.props.handlePaste(view, plainPaste, Slice.empty) + imagePlugin!.props.handlePaste(view, plainPaste, Slice.empty) ).toBeFalsy(); }); }); From 99a679ed1d8e9eaab4e78007217a2f4b09ab6c30 Mon Sep 17 00:00:00 2001 From: John Traas Date: Thu, 16 Jul 2026 01:48:11 +0200 Subject: [PATCH 7/9] refactor(text-editor): reuse the ContentType type from editor-config --- .../text-editor/prosemirror-adapter/editor-config.ts | 2 +- .../prosemirror-adapter/prosemirror-adapter.tsx | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/components/text-editor/prosemirror-adapter/editor-config.ts b/src/components/text-editor/prosemirror-adapter/editor-config.ts index 350b444207..e70c17d311 100644 --- a/src/components/text-editor/prosemirror-adapter/editor-config.ts +++ b/src/components/text-editor/prosemirror-adapter/editor-config.ts @@ -22,7 +22,7 @@ import { CustomElementDefinition } from '../../../global/shared-types/custom-ele import { Languages } from '../../date-picker/date.types'; import { TriggerCharacter, InlineImages } from '../text-editor.types'; -type ContentType = 'markdown' | 'html'; +export type ContentType = 'markdown' | 'html'; export interface EditorSchemaOptions { customElements: CustomElementDefinition[]; diff --git a/src/components/text-editor/prosemirror-adapter/prosemirror-adapter.tsx b/src/components/text-editor/prosemirror-adapter/prosemirror-adapter.tsx index 31c7446fd7..b7c7db9157 100644 --- a/src/components/text-editor/prosemirror-adapter/prosemirror-adapter.tsx +++ b/src/components/text-editor/prosemirror-adapter/prosemirror-adapter.tsx @@ -37,7 +37,11 @@ import { isInlineImageTag, } from '../text-editor.types'; import { imageCache } from './plugins/image/node'; -import { buildEditorSchema, buildEditorPlugins } from './editor-config'; +import { + buildEditorSchema, + buildEditorPlugins, + ContentType, +} from './editor-config'; import { EditorUiType } from '../types'; import { getMetadataFromDoc, @@ -67,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 From 97176d00fb7790ccfdf3a79bdff35205616fb9a5 Mon Sep 17 00:00:00 2001 From: John Traas Date: Thu, 16 Jul 2026 01:56:39 +0200 Subject: [PATCH 8/9] refactor(text-editor): tag ContentType as beta and update API report --- etc/lime-elements.api.md | 11 ++++++++--- .../text-editor/prosemirror-adapter/editor-config.ts | 5 +++++ 2 files changed, 13 insertions(+), 3 deletions(-) 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/src/components/text-editor/prosemirror-adapter/editor-config.ts b/src/components/text-editor/prosemirror-adapter/editor-config.ts index e70c17d311..06ab96f4ae 100644 --- a/src/components/text-editor/prosemirror-adapter/editor-config.ts +++ b/src/components/text-editor/prosemirror-adapter/editor-config.ts @@ -22,6 +22,11 @@ import { CustomElementDefinition } from '../../../global/shared-types/custom-ele 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 { From ab4ae790a9781f22fc14978c5ff6e94f19ed1630 Mon Sep 17 00:00:00 2001 From: John Traas Date: Thu, 16 Jul 2026 16:55:10 +0200 Subject: [PATCH 9/9] test(text-editor): access optional paste handlers with optional chaining --- .../text-editor/prosemirror-adapter/editor-config.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/text-editor/prosemirror-adapter/editor-config.spec.ts b/src/components/text-editor/prosemirror-adapter/editor-config.spec.ts index 16ac0abde2..ad26f2bccb 100644 --- a/src/components/text-editor/prosemirror-adapter/editor-config.spec.ts +++ b/src/components/text-editor/prosemirror-adapter/editor-config.spec.ts @@ -116,8 +116,8 @@ describe('editor-config (real-stack integration)', () => { 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'); + 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 @@ -140,10 +140,10 @@ describe('editor-config (real-stack integration)', () => { } as unknown as ClipboardEvent; expect( - linkPlugin!.props.handlePaste(view, plainPaste, Slice.empty) + linkPlugin?.props.handlePaste?.(view, plainPaste, Slice.empty) ).toBeFalsy(); expect( - imagePlugin!.props.handlePaste(view, plainPaste, Slice.empty) + imagePlugin?.props.handlePaste?.(view, plainPaste, Slice.empty) ).toBeFalsy(); }); });