Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions etc/lime-elements.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -772,7 +772,7 @@ export namespace Components {
// @beta
export interface LimelProsemirrorAdapter {
"clear": () => Promise<void>;
"contentType": 'markdown' | 'html';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this won't be a breaking change, right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, this should not be a breaking change. The accepted values and runtime behavior remain exactly the same: 'markdown' | 'html'. The only difference is that the inline union has been extracted into the shared ContentType type, giving us a single source of truth for the type definition.

"contentType": ContentType;
// Warning: (ae-extra-release-tag) The doc comment should not contain more than one release tag
//
// @alpha
Expand Down Expand Up @@ -967,6 +967,9 @@ export type Config = {
featureSwitches?: Record<string, boolean>;
};

// @beta
export type ContentType = 'markdown' | 'html';

// @public
export interface CustomColorSwatch {
disabled?: boolean;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
24 changes: 24 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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\"",
Expand Down Expand Up @@ -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",
Expand Down
181 changes: 181 additions & 0 deletions src/components/text-editor/prosemirror-adapter/editor-config.spec.ts
Original file line number Diff line number Diff line change
@@ -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('<a>hello<b>'));
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();
});
Comment thread
Copilot marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

describe('transactions stay consistent across the plugin set', () => {
it('keeps the document valid across a sequence of transactions', () => {
const startDoc = doc(p('<a>Hello<b>'));
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');
});
});
});
Loading
Loading