ckeditor5-elements-schema 0.0.0-stage → 49.0.0-alpha.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +70 -2
  3. package/dist/augmentation.d.ts +25 -0
  4. package/dist/augmentation.d.ts.map +1 -0
  5. package/dist/augmentation.js +2 -0
  6. package/dist/augmentation.js.map +1 -0
  7. package/dist/commands/elementBlockCommand.d.ts +46 -0
  8. package/dist/commands/elementBlockCommand.d.ts.map +1 -0
  9. package/dist/commands/elementBlockCommand.js +71 -0
  10. package/dist/commands/elementBlockCommand.js.map +1 -0
  11. package/dist/commands/elementContainerCommand.d.ts +36 -0
  12. package/dist/commands/elementContainerCommand.d.ts.map +1 -0
  13. package/dist/commands/elementContainerCommand.js +67 -0
  14. package/dist/commands/elementContainerCommand.js.map +1 -0
  15. package/dist/commands/elementInlineCommand.d.ts +87 -0
  16. package/dist/commands/elementInlineCommand.d.ts.map +1 -0
  17. package/dist/commands/elementInlineCommand.js +253 -0
  18. package/dist/commands/elementInlineCommand.js.map +1 -0
  19. package/dist/commands/elementInsertCommand.d.ts +33 -0
  20. package/dist/commands/elementInsertCommand.d.ts.map +1 -0
  21. package/dist/commands/elementInsertCommand.js +61 -0
  22. package/dist/commands/elementInsertCommand.js.map +1 -0
  23. package/dist/commands/elementSchemaCommand.d.ts +62 -0
  24. package/dist/commands/elementSchemaCommand.d.ts.map +1 -0
  25. package/dist/commands/elementSchemaCommand.js +107 -0
  26. package/dist/commands/elementSchemaCommand.js.map +1 -0
  27. package/dist/conversion/convertElementSchema.d.ts +17 -0
  28. package/dist/conversion/convertElementSchema.d.ts.map +1 -0
  29. package/dist/conversion/convertElementSchema.js +55 -0
  30. package/dist/conversion/convertElementSchema.js.map +1 -0
  31. package/dist/conversion/inlineTextElement.d.ts +24 -0
  32. package/dist/conversion/inlineTextElement.d.ts.map +1 -0
  33. package/dist/conversion/inlineTextElement.js +28 -0
  34. package/dist/conversion/inlineTextElement.js.map +1 -0
  35. package/dist/definition/canHold.d.ts +10 -0
  36. package/dist/definition/canHold.d.ts.map +1 -0
  37. package/dist/definition/canHold.js +22 -0
  38. package/dist/definition/canHold.js.map +1 -0
  39. package/dist/definition/compileElementSchema.d.ts +41 -0
  40. package/dist/definition/compileElementSchema.d.ts.map +1 -0
  41. package/dist/definition/compileElementSchema.js +60 -0
  42. package/dist/definition/compileElementSchema.js.map +1 -0
  43. package/dist/definition/elementSchemaError.d.ts +12 -0
  44. package/dist/definition/elementSchemaError.d.ts.map +1 -0
  45. package/dist/definition/elementSchemaError.js +16 -0
  46. package/dist/definition/elementSchemaError.js.map +1 -0
  47. package/dist/definition/readDefinition.d.ts +18 -0
  48. package/dist/definition/readDefinition.d.ts.map +1 -0
  49. package/dist/definition/readDefinition.js +60 -0
  50. package/dist/definition/readDefinition.js.map +1 -0
  51. package/dist/definition/suggest.d.ts +5 -0
  52. package/dist/definition/suggest.d.ts.map +1 -0
  53. package/dist/definition/suggest.js +27 -0
  54. package/dist/definition/suggest.js.map +1 -0
  55. package/dist/definition/types.d.ts +105 -0
  56. package/dist/definition/types.d.ts.map +1 -0
  57. package/dist/definition/types.js +2 -0
  58. package/dist/definition/types.js.map +1 -0
  59. package/dist/definition/validateElementSchema.d.ts +26 -0
  60. package/dist/definition/validateElementSchema.d.ts.map +1 -0
  61. package/dist/definition/validateElementSchema.js +145 -0
  62. package/dist/definition/validateElementSchema.js.map +1 -0
  63. package/dist/elementSchema.d.ts +47 -0
  64. package/dist/elementSchema.d.ts.map +1 -0
  65. package/dist/elementSchema.js +108 -0
  66. package/dist/elementSchema.js.map +1 -0
  67. package/dist/elementSchemaCommands.d.ts +83 -0
  68. package/dist/elementSchemaCommands.d.ts.map +1 -0
  69. package/dist/elementSchemaCommands.js +137 -0
  70. package/dist/elementSchemaCommands.js.map +1 -0
  71. package/dist/index.d.ts +18 -0
  72. package/dist/index.d.ts.map +1 -0
  73. package/dist/index.js +22 -0
  74. package/dist/index.js.map +1 -0
  75. package/dist/typing/twoStepCaretMovement.d.ts +11 -0
  76. package/dist/typing/twoStepCaretMovement.d.ts.map +1 -0
  77. package/dist/typing/twoStepCaretMovement.js +76 -0
  78. package/dist/typing/twoStepCaretMovement.js.map +1 -0
  79. package/dist/validate.d.ts +12 -0
  80. package/dist/validate.d.ts.map +1 -0
  81. package/dist/validate.js +11 -0
  82. package/dist/validate.js.map +1 -0
  83. package/package.json +49 -3
  84. package/src/augmentation.ts +28 -0
  85. package/src/commands/elementBlockCommand.ts +87 -0
  86. package/src/commands/elementContainerCommand.ts +83 -0
  87. package/src/commands/elementInlineCommand.ts +311 -0
  88. package/src/commands/elementInsertCommand.ts +74 -0
  89. package/src/commands/elementSchemaCommand.ts +140 -0
  90. package/src/conversion/convertElementSchema.ts +65 -0
  91. package/src/conversion/inlineTextElement.ts +31 -0
  92. package/src/definition/canHold.ts +29 -0
  93. package/src/definition/compileElementSchema.ts +88 -0
  94. package/src/definition/elementSchemaError.ts +21 -0
  95. package/src/definition/readDefinition.ts +98 -0
  96. package/src/definition/suggest.ts +38 -0
  97. package/src/definition/types.ts +123 -0
  98. package/src/definition/validateElementSchema.ts +238 -0
  99. package/src/elementSchema.ts +133 -0
  100. package/src/elementSchemaCommands.ts +202 -0
  101. package/src/index.ts +30 -0
  102. package/src/typing/twoStepCaretMovement.ts +119 -0
  103. package/src/validate.ts +20 -0
@@ -0,0 +1,123 @@
1
+ /**
2
+ * The kinds of elements of an element schema:
3
+ *
4
+ * * `empty`: has no content, like an image or a line break. It is an object: selected as a whole.
5
+ * * `block`: can contain text and inline elements, like a paragraph or a heading. With `isInline`, it is an inline
6
+ * element with text, like a bold with an `id`.
7
+ *
8
+ * * `container`: cannot contain text; holds blocks, empty block elements and other containers, like a block quote or a
9
+ * note.
10
+ *
11
+ * Every type can be inline (see {@link ElementSchemaElement#isInline}): the type says what the element holds, `isInline`
12
+ * where and how it is laid out. Inline elements are allowed only in blocks.
13
+ */
14
+ export type ElementSchemaType = 'empty' | 'block' | 'container';
15
+
16
+ /**
17
+ * The kind of an element: what it is, and whether it is inline: laid out in a line with its siblings, instead of on a
18
+ * line of its own. Any type can be inline.
19
+ */
20
+ export interface ElementSchemaKind {
21
+ type: ElementSchemaType;
22
+ isInline: boolean;
23
+ }
24
+
25
+ /**
26
+ * The definition of one element of an element schema.
27
+ */
28
+ export interface ElementSchemaElement {
29
+ /**
30
+ * What the element is: `'empty'`, `'block'` or `'container'`.
31
+ */
32
+ type: ElementSchemaType;
33
+
34
+ /**
35
+ * Whether the element is inline: it is about the position and the layout of the element, not about text. An inline
36
+ * element is laid out in a line with its siblings, where a block-level one has a line of its own. Any type can be:
37
+ *
38
+ * * `empty`: like an inline image, or a line break;
39
+ * * `block`: an element with text in a line of text, like a bold that is an element, with attributes;
40
+ * * `container`: a box that is not typable, in a line with other boxes, e.g. several in a block.
41
+ *
42
+ * Inline elements are allowed only in blocks: by default, where text is. A block holds text and any number of inline
43
+ * elements; containers and the root hold no inline elements. Default: `false`.
44
+ */
45
+ isInline?: boolean;
46
+
47
+ /**
48
+ * The elements allowed inside this one, in addition to what its type allows: names of elements of the schema, or of
49
+ * elements of other plugins, and `'$text'`. As in CKEditor's `allowChildren`.
50
+ *
51
+ * What a type allows by default: a `block` has text and inline elements; a `container` has blocks, containers and
52
+ * `empty` block-level elements.
53
+ */
54
+ allowChildren?: string[];
55
+
56
+ /**
57
+ * The elements this one is allowed in, in addition to what its type allows: names of elements, and `'$root'`. As in
58
+ * CKEditor's `allowIn`.
59
+ *
60
+ * Unlike in CKEditor, when it is given it **replaces** the default places (a block-level element is allowed in the
61
+ * root and in containers; an inline element, where text is), so that an element can be restricted to its parent,
62
+ * e.g. a row to a table.
63
+ */
64
+ allowIn?: string[];
65
+
66
+ /**
67
+ * The attributes the element can have, as in CKEditor's `allowAttributes`.
68
+ */
69
+ allowAttributes?: string[];
70
+
71
+ /**
72
+ * The tag of the element in the editing view, e.g. `'section'`, when the converters of the schema are on (the
73
+ * `elementSchemaConverters` option). The data has `div` or `span`, whatever it is. Default: the tag of the data.
74
+ */
75
+ editingTag?: string;
76
+ }
77
+
78
+ /**
79
+ * An element schema: the definitions of the elements, by name.
80
+ */
81
+ export type ElementSchemaDefinition = Record<string, ElementSchemaElement>;
82
+
83
+ /**
84
+ * What the `elementSchema` option accepts: an {@link ElementSchemaDefinition}, or a JSON file imported as it is, whose
85
+ * strings TypeScript does not know are the allowed ones, and whose arrays are not known to be mutable. Either way, it is
86
+ * validated when the editor is created.
87
+ */
88
+ export type ElementSchemaInput = Record<
89
+ string,
90
+ {
91
+ [Key in keyof ElementSchemaElement]: Key extends 'type'
92
+ ? string
93
+ : ElementSchemaElement[Key] extends string[] | undefined
94
+ ? readonly string[] | undefined
95
+ : ElementSchemaElement[Key];
96
+ }
97
+ >;
98
+
99
+ /**
100
+ * A problem of an element schema.
101
+ */
102
+ export interface ElementSchemaIssue {
103
+ /**
104
+ * Where the problem is, e.g. `note.allowChildren[1]`.
105
+ */
106
+ path: string;
107
+
108
+ /**
109
+ * What is wrong, and what to do about it.
110
+ */
111
+ message: string;
112
+ }
113
+
114
+ /**
115
+ * The options of {@link validateElementSchema}.
116
+ */
117
+ export interface ValidateElementSchemaOptions {
118
+ /**
119
+ * Elements that exist outside the schema, e.g. `paragraph` of the `Paragraph` plugin, by name. The schema can refer to
120
+ * them, but cannot declare them. Without them, the schema can refer only to its own elements.
121
+ */
122
+ external?: Readonly<Record<string, ElementSchemaKind>>;
123
+ }
@@ -0,0 +1,238 @@
1
+ import { ROOT, whyNot } from './canHold.js';
2
+ import {
3
+ ATTRIBUTE_NAME,
4
+ ELEMENT_KEYS,
5
+ ELEMENT_NAME,
6
+ TAG_NAME,
7
+ checkKeys,
8
+ isRecord,
9
+ readKind,
10
+ readNames,
11
+ type Report
12
+ } from './readDefinition.js';
13
+ import { suggest } from './suggest.js';
14
+ import type { ElementSchemaIssue, ElementSchemaKind, ValidateElementSchemaOptions } from './types.js';
15
+
16
+ /**
17
+ * Checks an element schema, so that it is not contradictory, and returns the problems. An empty array means that the
18
+ * schema is valid.
19
+ *
20
+ * The schema is rejected when it:
21
+ *
22
+ * * is not of the shape {@link ElementSchemaDefinition}: an object of elements, by name; has keys that are not part of
23
+ * it (typos are problems), or has values of the wrong type;
24
+ * * declares an element with an unknown `type`, or an `empty` element with `allowChildren`;
25
+ * * has attribute names that are not valid, or that differ only in case (HTML attribute names are not case sensitive),
26
+ * or an `editingTag` that is not the name of a tag;
27
+ * * refers to elements that do not exist (a name that is neither declared in the schema, nor in `external`), or lists the
28
+ * same name twice;
29
+ * * allows what its type cannot hold: text or an inline element in a container or in the root, a block-level (not inline)
30
+ * element in a block, or anything in an `empty` element, whichever side says it: `allowChildren` of the parent, or
31
+ * `allowIn` of the child.
32
+ *
33
+ * `isInline` is about the position and the layout of an element, so it is valid for every type: an inline element is
34
+ * laid out in a line with its siblings. Inline elements are allowed only in blocks, which can hold several of them,
35
+ * e.g. inline containers, or inline images between text.
36
+ *
37
+ * It does not need an editor. The editor of `ElementSchema` runs it with the elements of the other plugins as `external`.
38
+ */
39
+ export function validateElementSchema(
40
+ input: unknown,
41
+ { external = {} }: ValidateElementSchemaOptions = {}
42
+ ): ElementSchemaIssue[] {
43
+ const issues: ElementSchemaIssue[] = [];
44
+ const report = (path: string, message: string): void => {
45
+ issues.push({ path, message });
46
+ };
47
+
48
+ if (!isRecord(input)) {
49
+ report(
50
+ '',
51
+ 'The schema must be an object of element definitions, by name: { "note": { "type": "container" } }.'
52
+ );
53
+
54
+ return issues;
55
+ }
56
+
57
+ const elements = input;
58
+
59
+ if (!Object.keys(elements).length) {
60
+ report('', 'The schema declares no elements: declare at least one.');
61
+
62
+ return issues;
63
+ }
64
+
65
+ if (isRecord(elements.elements) && !('type' in elements.elements)) {
66
+ report(
67
+ 'elements',
68
+ 'the schema is the object of the elements itself: remove the "elements" wrapper, and put the elements at the top.'
69
+ );
70
+
71
+ return issues;
72
+ }
73
+
74
+ // The first pass checks every definition on its own, and collects the kinds and the lists to cross-check.
75
+ const kinds = new Map<string, ElementSchemaKind>();
76
+ const lists = new Map<string, { allowChildren: string[]; allowIn: string[] }>();
77
+
78
+ for (const [name, definition] of Object.entries(elements)) {
79
+ const path = name;
80
+
81
+ if (!ELEMENT_NAME.test(name)) {
82
+ report(
83
+ path,
84
+ `"${name}" is not a valid element name: use camelCase letters and digits, starting with a lowercase letter.`
85
+ );
86
+ }
87
+
88
+ if (name in external) {
89
+ report(
90
+ path,
91
+ `"${name}" already exists in the editor (it is declared by another plugin): remove it, or rename it.`
92
+ );
93
+ }
94
+
95
+ if (!isRecord(definition)) {
96
+ report(path, 'must be an object, e.g. { "type": "block" }.');
97
+ continue;
98
+ }
99
+
100
+ checkKeys(definition, ELEMENT_KEYS, path, report);
101
+
102
+ const kind = readKind(definition, path, report);
103
+
104
+ if (kind) {
105
+ kinds.set(name, kind);
106
+ }
107
+
108
+ const allowChildren = readNames(definition, 'allowChildren', path, report);
109
+ const allowIn = readNames(definition, 'allowIn', path, report);
110
+ const allowAttributes = readNames(definition, 'allowAttributes', path, report);
111
+
112
+ if (allowIn && !allowIn.length) {
113
+ report(
114
+ `${path}.allowIn`,
115
+ 'is empty, so the element would be allowed nowhere: list its parents, or remove it for the default places.'
116
+ );
117
+ }
118
+
119
+ const lowercase = new Map<string, string>();
120
+
121
+ for (const [index, attribute] of (allowAttributes ?? []).entries()) {
122
+ const attributePath = `${path}.allowAttributes[${String(index)}]`;
123
+ const collides = lowercase.get(attribute.toLowerCase());
124
+
125
+ if (!ATTRIBUTE_NAME.test(attribute)) {
126
+ report(attributePath, `"${attribute}" is not a valid attribute name.`);
127
+ } else if (collides !== undefined) {
128
+ report(
129
+ attributePath,
130
+ `"${attribute}" collides with "${collides}": the names of HTML attributes are not case sensitive, so the data could not tell them apart.`
131
+ );
132
+ } else {
133
+ lowercase.set(attribute.toLowerCase(), attribute);
134
+ }
135
+ }
136
+
137
+ if (
138
+ definition.editingTag !== undefined &&
139
+ (typeof definition.editingTag !== 'string' || !TAG_NAME.test(definition.editingTag))
140
+ ) {
141
+ report(
142
+ `${path}.editingTag`,
143
+ `must be the name of an HTML tag, in lowercase, e.g. "section", not ${JSON.stringify(definition.editingTag)}.`
144
+ );
145
+ }
146
+
147
+ if (kind?.type === 'empty' && allowChildren?.length) {
148
+ report(
149
+ `${path}.allowChildren`,
150
+ 'an empty element has no content: remove allowChildren, or change the type.'
151
+ );
152
+ }
153
+
154
+ lists.set(name, { allowChildren: kind?.type === 'empty' ? [] : (allowChildren ?? []), allowIn: allowIn ?? [] });
155
+ }
156
+
157
+ // The second pass checks what the lists refer to, and whether the types can hold each other.
158
+ const known = (name: string): ElementSchemaKind | undefined => kinds.get(name) ?? external[name];
159
+ const candidates = [...kinds.keys(), ...Object.keys(external)];
160
+
161
+ for (const [name, { allowChildren, allowIn }] of lists) {
162
+ const kind = kinds.get(name);
163
+
164
+ if (!kind) {
165
+ continue;
166
+ }
167
+
168
+ allowChildren.forEach((child, index) => {
169
+ const path = `${name}.allowChildren[${String(index)}]`;
170
+ const childKind = child === '$text' ? child : resolveReference(child, known, candidates, path, report);
171
+
172
+ if (childKind) {
173
+ const reason = whyNot(kind, childKind);
174
+
175
+ if (reason) {
176
+ report(path, `"${child}" cannot be a child of "${name}": ${reason}.`);
177
+ }
178
+ }
179
+ });
180
+
181
+ allowIn.forEach((parent, index) => {
182
+ const path = `${name}.allowIn[${String(index)}]`;
183
+ const parentKind = parent === '$root' ? ROOT : resolveReference(parent, known, candidates, path, report);
184
+
185
+ if (parentKind) {
186
+ const reason = whyNot(parentKind, kind);
187
+
188
+ if (reason) {
189
+ report(
190
+ path,
191
+ `"${name}" cannot be allowed in ${parent === '$root' ? 'the root' : `"${parent}"`}: ${reason}.`
192
+ );
193
+ }
194
+ }
195
+ });
196
+ }
197
+
198
+ // In the order of the file: the problems of an element together, whichever pass found them.
199
+ const names = Object.keys(elements);
200
+ const position = ({ path }: ElementSchemaIssue): number =>
201
+ names.findIndex(name => path === name || path.startsWith(`${name}.`));
202
+
203
+ return issues.sort((a, b) => position(a) - position(b));
204
+ }
205
+
206
+ /**
207
+ * Returns the kind of the element the list entry names. Reports an unknown name (with the closest known one), and the
208
+ * names that cannot be used there: `$text` and `$root` are not elements that can be both parents and children.
209
+ */
210
+ function resolveReference(
211
+ name: string,
212
+ known: (name: string) => ElementSchemaKind | undefined,
213
+ candidates: readonly string[],
214
+ path: string,
215
+ report: Report
216
+ ): ElementSchemaKind | undefined {
217
+ if (name === '$text' || name === '$root') {
218
+ report(
219
+ path,
220
+ name === '$text'
221
+ ? '"$text" is not an element: it is text, and cannot have children or parents.'
222
+ : '"$root" is the root of the editor: it can be a parent (allowIn), not a child.'
223
+ );
224
+
225
+ return undefined;
226
+ }
227
+
228
+ const kind = known(name);
229
+
230
+ if (!kind) {
231
+ report(
232
+ path,
233
+ `unknown element "${name}"${suggest(name, candidates)}. It must be declared in the schema, or be an element of another plugin that is loaded.`
234
+ );
235
+ }
236
+
237
+ return kind;
238
+ }
@@ -0,0 +1,133 @@
1
+ import { Plugin, type ModelSchema } from 'ckeditor5';
2
+ import { findPlugin } from 'ckeditor5-elements-core';
3
+ import { compileElementSchema } from './definition/compileElementSchema.js';
4
+ import { convertElementSchema } from './conversion/convertElementSchema.js';
5
+ import ElementSchemaError from './definition/elementSchemaError.js';
6
+ import type { ElementSchemaInput, ElementSchemaKind } from './definition/types.js';
7
+ import { validateElementSchema } from './definition/validateElementSchema.js';
8
+
9
+ /**
10
+ * Declares elements in the model schema of the editor from a simple definition, the `elementSchema` editor option: a
11
+ * plain object, e.g. a JSON file.
12
+ *
13
+ * ```json
14
+ * {
15
+ * "note": { "type": "container", "allowAttributes": ["type"] },
16
+ * "title": { "type": "block", "allowIn": ["note"] },
17
+ * "badge": { "type": "empty", "isInline": true, "allowAttributes": ["color"] }
18
+ * }
19
+ * ```
20
+ *
21
+ * Every element has one of three types, instead of CKEditor's many `is*` flags and generic items:
22
+ *
23
+ * * `empty`: has no content, like an image. It is an object, selected as a whole. With `isInline`, it is in a line of text.
24
+ * * `block`: can contain text and inline elements, like a paragraph. With `isInline`, it is an inline element with text,
25
+ * like a bold that is an element with attributes.
26
+ * * `container`: cannot contain text, but blocks, containers and empty elements, like a block quote.
27
+ *
28
+ * `allowAttributes` and `allowChildren` work as in CKEditor's schema. `allowIn` too, but it replaces the default places.
29
+ * See {@link ElementSchemaElement}.
30
+ *
31
+ * The schema is validated first, and a schema that is contradictory or refers to unknown elements is not accepted: the
32
+ * editor is not created, and the {@link ElementSchemaError} lists all the problems (see {@link validateElementSchema}).
33
+ *
34
+ * The elements are registered after the plugins are initialized (in `afterInit()`), so they can refer to the elements
35
+ * of other plugins, e.g. `paragraph`. Plugins that extend or convert the elements must do it in `afterInit()` too, or
36
+ * later; the converters can be declared at any time.
37
+ *
38
+ * The `empty` and `container` elements can be selected as a whole, by a click on them and by the arrow keys, when
39
+ * the `ElementSelectable` plugin is loaded too: the element schema does not depend on it, and uses it when it is there
40
+ * (with `elementSchemaConverters`, it warns when it is not).
41
+ *
42
+ * The plugin declares the schema only: how the elements look in the editor and in the data is declared with
43
+ * converters, or by the plugin when the `elementSchemaConverters` option is `true`: one to one, as `div` and `span`.
44
+ */
45
+ export default class ElementSchema extends Plugin {
46
+ public static get pluginName(): 'ElementSchema' {
47
+ return 'ElementSchema' as const;
48
+ }
49
+
50
+ public afterInit(): void {
51
+ const input = this.editor.config.get('elementSchema');
52
+
53
+ if (input === undefined) {
54
+ return;
55
+ }
56
+
57
+ const schema = this.editor.model.schema;
58
+ const issues = validateElementSchema(input, { external: getExternalKinds(schema) });
59
+
60
+ if (issues.length) {
61
+ throw new ElementSchemaError(issues);
62
+ }
63
+
64
+ const { items, children } = compileElementSchema(input);
65
+
66
+ for (const [name, definition] of items) {
67
+ schema.register(name, definition);
68
+ }
69
+
70
+ for (const { parent, child } of children) {
71
+ schema.extend(child, { allowIn: parent });
72
+ }
73
+
74
+ this._makeSelectable(input);
75
+
76
+ if (this.editor.config.get('elementSchemaConverters')) {
77
+ convertElementSchema(this.editor, input);
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Makes the `empty` elements and the containers selectable as a whole, by a click on them and by the arrow keys, with
83
+ * `ElementSelectable`. Not the blocks: a click in a block puts the cursor in its text.
84
+ */
85
+ private _makeSelectable(input: ElementSchemaInput): void {
86
+ const names = Object.entries(input)
87
+ .filter(([, element]) => element.type === 'empty' || element.type === 'container')
88
+ .map(([name]) => name);
89
+ const selectable = findPlugin(this.editor, 'ElementSelectable', isSelectablePlugin);
90
+
91
+ if (selectable) {
92
+ selectable.add(...names);
93
+ } else if (names.length && this.editor.config.get('elementSchemaConverters')) {
94
+ // The converters make the elements, and nothing selects them: say it, as it is a silent loss. Not CKEditor's
95
+ // `logWarning()`: it links to CKEditor's list of errors, which does not have this one.
96
+ // eslint-disable-next-line no-console -- A warning for the developer, once, when the editor is created.
97
+ console.warn(
98
+ `ElementSchema: load ElementSelectable to select the elements of the schema as a whole: ${names.join(', ')}.`
99
+ );
100
+ }
101
+ }
102
+ }
103
+
104
+ /**
105
+ * The part of the `ElementSelectable` plugin the element schema uses. The schema does not depend on the other features:
106
+ * the plugin is looked up by name and checked against this interface.
107
+ */
108
+ interface SelectablePlugin {
109
+ add(...names: string[]): void;
110
+ }
111
+
112
+ function isSelectablePlugin(plugin: object): plugin is SelectablePlugin {
113
+ return 'add' in plugin && typeof plugin.add === 'function';
114
+ }
115
+
116
+ /**
117
+ * Returns the kinds of the elements of other plugins: inline or not, and `empty` for objects, `block` for what can
118
+ * contain text, `container` for the rest. The generic items (`$block`, …) are not elements.
119
+ */
120
+ function getExternalKinds(schema: ModelSchema): Record<string, ElementSchemaKind> {
121
+ const kinds: Record<string, ElementSchemaKind> = {};
122
+
123
+ for (const name of Object.keys(schema.getDefinitions())) {
124
+ if (!name.startsWith('$')) {
125
+ kinds[name] = {
126
+ isInline: schema.isInline(name),
127
+ type: schema.isObject(name) ? 'empty' : schema.checkChild(name, '$text') ? 'block' : 'container'
128
+ };
129
+ }
130
+ }
131
+
132
+ return kinds;
133
+ }
@@ -0,0 +1,202 @@
1
+ import { Plugin, type ModelSchema, type ViewDowncastWriter, type ViewElement } from 'ckeditor5';
2
+ import { ElementOrPositionButtonView } from 'ckeditor5-elements-core';
3
+ import { createInlineTextElement } from './conversion/inlineTextElement.js';
4
+ import ElementBlockCommand from './commands/elementBlockCommand.js';
5
+ import ElementContainerCommand from './commands/elementContainerCommand.js';
6
+ import ElementInlineCommand from './commands/elementInlineCommand.js';
7
+ import ElementInsertCommand from './commands/elementInsertCommand.js';
8
+ import ElementSchema from './elementSchema.js';
9
+ import type ElementSchemaCommand from './commands/elementSchemaCommand.js';
10
+ import { enableTwoStepCaretMovement } from './typing/twoStepCaretMovement.js';
11
+
12
+ /**
13
+ * An element of {@link ElementSchemaCommands}: its command and its button.
14
+ */
15
+ export interface ElementSchemaCommandDefinition {
16
+ /**
17
+ * The model element: of the element schema, or of another plugin. Default: the name of the command.
18
+ */
19
+ element?: string;
20
+
21
+ /**
22
+ * The title of the command, and the label of its button, e.g. `'Bold'`.
23
+ */
24
+ title: string;
25
+
26
+ /**
27
+ * The icon of the command and of its button (an SVG string), e.g. `IconBold` of CKEditor.
28
+ */
29
+ icon?: string;
30
+
31
+ /**
32
+ * The keystroke that executes the command, e.g. `'Ctrl+B'`.
33
+ */
34
+ keystroke?: string;
35
+
36
+ /**
37
+ * The tag of the element in the data and in the editing view, e.g. `'strong'` or `'hr'`. The converters of the plugin
38
+ * replace the ones of the element schema (`elementSchemaConverters`) in the downcast, and the upcast takes both.
39
+ * Without it, the element is converted by the schema, or by your converters.
40
+ */
41
+ view?: string;
42
+ }
43
+
44
+ /**
45
+ * The `elementSchemaCommands` option: the elements, by the name of their command and of their button.
46
+ */
47
+ export type ElementSchemaCommandsConfig = Record<string, ElementSchemaCommandDefinition>;
48
+
49
+ /**
50
+ * Commands and toolbar buttons for the elements of the element schema, as CKEditor's features have for theirs: the
51
+ * command of an element is the one of its kind (see {@link ElementSchemaCommand}).
52
+ *
53
+ * | Element | Command | Works as CKEditor's |
54
+ * | ----------------------------- | --------------------------- | ----------------------------- |
55
+ * | inline `block` | {@link ElementInlineCommand} | bold: wraps text |
56
+ * | `block` | {@link ElementBlockCommand} | heading: renames blocks |
57
+ * | `container` | {@link ElementContainerCommand} | block quote: wraps blocks |
58
+ * | `empty`, inline `container` | {@link ElementInsertCommand} | horizontal line: inserts |
59
+ *
60
+ * ```ts
61
+ * elementSchema: schema,
62
+ * elementSchemaCommands: {
63
+ * bold: { title: 'Bold', icon: IconBold, keystroke: 'Ctrl+B', view: 'strong' },
64
+ * callout: { title: 'Callout', icon: IconQuote },
65
+ * divider: { title: 'Divider', icon: IconHorizontalLine, view: 'hr' }
66
+ * },
67
+ * toolbar: ['bold', 'callout', 'divider']
68
+ * ```
69
+ *
70
+ * Each key is the name of the command and of its toolbar button. The commands have titles and icons, so they can be
71
+ * menu items too, e.g. of the context menu.
72
+ *
73
+ * At the ends of the inline elements with text, the left and right arrow keys move in two steps, as CKEditor's two-step
74
+ * caret movement does at the ends of a bold: in the element, then in the text.
75
+ *
76
+ * The commands are made when the elements are registered, after `ElementSchema` and the other plugins are initialized.
77
+ * The editor is not created when an element is unknown.
78
+ */
79
+ export default class ElementSchemaCommands extends Plugin {
80
+ public static get pluginName(): 'ElementSchemaCommands' {
81
+ return 'ElementSchemaCommands' as const;
82
+ }
83
+
84
+ public static get requires(): readonly [typeof ElementSchema] {
85
+ return [ElementSchema] as const;
86
+ }
87
+
88
+ /**
89
+ * The commands, by their name.
90
+ */
91
+ private readonly _commands = new Map<string, ElementSchemaCommand>();
92
+
93
+ public init(): void {
94
+ const editor = this.editor;
95
+
96
+ for (const [name, { title, keystroke }] of this._getDefinitions()) {
97
+ if (keystroke) {
98
+ editor.keystrokes.set(keystroke, name);
99
+ editor.accessibility.addKeystrokeInfos({ keystrokes: [{ label: title, keystroke }] });
100
+ }
101
+
102
+ editor.ui.componentFactory.add(name, locale => {
103
+ const command = this._commands.get(name);
104
+
105
+ // The commands are made in `afterInit()`, and the toolbars after it.
106
+ if (!command) {
107
+ throw new Error(`elementSchemaCommands.${name}: the button is created before the command.`);
108
+ }
109
+
110
+ const button = new ElementOrPositionButtonView(locale, command);
111
+
112
+ button.bind('isOn').to(command, 'isOn');
113
+ button.keystroke = keystroke;
114
+
115
+ return button;
116
+ });
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Makes the commands of the elements, which are registered then, and their converters.
122
+ */
123
+ public afterInit(): void {
124
+ const editor = this.editor;
125
+ const schema = editor.model.schema;
126
+ const inlineNames = new Set<string>();
127
+
128
+ for (const [name, { element, title, icon, view }] of this._getDefinitions()) {
129
+ const elementName = element ?? name;
130
+
131
+ if (!schema.isRegistered(elementName)) {
132
+ throw new Error(
133
+ `elementSchemaCommands.${name}: unknown element "${elementName}". Declare it in the element schema, or ` +
134
+ `set "element" to an element of another plugin.`
135
+ );
136
+ }
137
+
138
+ const CommandClass = getCommandClass(schema, elementName);
139
+ const command = new CommandClass(editor, elementName);
140
+
141
+ command.set({ title, icon });
142
+ editor.commands.add(name, command);
143
+ this._commands.set(name, command);
144
+
145
+ if (command instanceof ElementInlineCommand) {
146
+ inlineNames.add(elementName);
147
+ }
148
+
149
+ if (view) {
150
+ this._convert(elementName, view, (writer: ViewDowncastWriter) => {
151
+ if (command instanceof ElementInlineCommand) {
152
+ return createInlineTextElement(writer, view);
153
+ }
154
+
155
+ return schema.isObject(elementName)
156
+ ? writer.createEmptyElement(view)
157
+ : writer.createContainerElement(view);
158
+ });
159
+ }
160
+ }
161
+
162
+ enableTwoStepCaretMovement(this, inlineNames);
163
+ }
164
+
165
+ private _getDefinitions(): [name: string, definition: ElementSchemaCommandDefinition][] {
166
+ return Object.entries(this.editor.config.get('elementSchemaCommands') ?? {});
167
+ }
168
+
169
+ /**
170
+ * Declares the converters of an element with the tag of its `view`: the downcast with the highest priority, so it
171
+ * replaces the ones of the element schema, and the upcast.
172
+ */
173
+ private _convert(elementName: string, tag: string, createView: (writer: ViewDowncastWriter) => ViewElement): void {
174
+ const conversion = this.editor.conversion;
175
+
176
+ conversion.for('upcast').elementToElement({ view: tag, model: elementName });
177
+ conversion.for('downcast').elementToElement({
178
+ model: elementName,
179
+ view: (_element, { writer }) => createView(writer),
180
+ // Over the ones of the schema, some of which are `high` already.
181
+ converterPriority: 'highest'
182
+ });
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Returns the command of an element, by its kind: an object, or an inline element with no text, is inserted; an inline
188
+ * element with text wraps text; a block-level one with text is a block; the others are containers.
189
+ */
190
+ function getCommandClass(schema: ModelSchema, name: string): typeof ElementSchemaCommand {
191
+ const holdsText = schema.checkChild(name, '$text');
192
+
193
+ if (schema.isObject(name) || (schema.isInline(name) && !holdsText)) {
194
+ return ElementInsertCommand;
195
+ }
196
+
197
+ if (schema.isInline(name)) {
198
+ return ElementInlineCommand;
199
+ }
200
+
201
+ return holdsText ? ElementBlockCommand : ElementContainerCommand;
202
+ }