ckeditor5-elements-menu 0.0.0-stage → 49.0.0

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 (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +50 -2
  3. package/dist/augmentation.d.ts +30 -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/breadcrumbs/breadcrumbItemView.d.ts +47 -0
  8. package/dist/breadcrumbs/breadcrumbItemView.d.ts.map +1 -0
  9. package/dist/breadcrumbs/breadcrumbItemView.js +30 -0
  10. package/dist/breadcrumbs/breadcrumbItemView.js.map +1 -0
  11. package/dist/breadcrumbs/breadcrumbLabelView.d.ts +29 -0
  12. package/dist/breadcrumbs/breadcrumbLabelView.d.ts.map +1 -0
  13. package/dist/breadcrumbs/breadcrumbLabelView.js +39 -0
  14. package/dist/breadcrumbs/breadcrumbLabelView.js.map +1 -0
  15. package/dist/breadcrumbs/breadcrumbsView.d.ts +68 -0
  16. package/dist/breadcrumbs/breadcrumbsView.d.ts.map +1 -0
  17. package/dist/breadcrumbs/breadcrumbsView.js +134 -0
  18. package/dist/breadcrumbs/breadcrumbsView.js.map +1 -0
  19. package/dist/breadcrumbs/elementBreadcrumbs.d.ts +73 -0
  20. package/dist/breadcrumbs/elementBreadcrumbs.d.ts.map +1 -0
  21. package/dist/breadcrumbs/elementBreadcrumbs.js +225 -0
  22. package/dist/breadcrumbs/elementBreadcrumbs.js.map +1 -0
  23. package/dist/contextMenu/contextMenuObserver.d.ts +19 -0
  24. package/dist/contextMenu/contextMenuObserver.d.ts.map +1 -0
  25. package/dist/contextMenu/contextMenuObserver.js +18 -0
  26. package/dist/contextMenu/contextMenuObserver.js.map +1 -0
  27. package/dist/contextMenu/elementContextMenu.d.ts +34 -0
  28. package/dist/contextMenu/elementContextMenu.d.ts.map +1 -0
  29. package/dist/contextMenu/elementContextMenu.js +88 -0
  30. package/dist/contextMenu/elementContextMenu.js.map +1 -0
  31. package/dist/domTarget.d.ts +8 -0
  32. package/dist/domTarget.d.ts.map +1 -0
  33. package/dist/domTarget.js +20 -0
  34. package/dist/domTarget.js.map +1 -0
  35. package/dist/elementMenu.d.ts +93 -0
  36. package/dist/elementMenu.d.ts.map +1 -0
  37. package/dist/elementMenu.js +140 -0
  38. package/dist/elementMenu.js.map +1 -0
  39. package/dist/elementMenuView.d.ts +40 -0
  40. package/dist/elementMenuView.d.ts.map +1 -0
  41. package/dist/elementMenuView.js +143 -0
  42. package/dist/elementMenuView.js.map +1 -0
  43. package/dist/index.d.ts +24 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +29 -0
  46. package/dist/index.js.map +1 -0
  47. package/dist/quickInsert/elementQuickInsert.d.ts +57 -0
  48. package/dist/quickInsert/elementQuickInsert.d.ts.map +1 -0
  49. package/dist/quickInsert/elementQuickInsert.js +96 -0
  50. package/dist/quickInsert/elementQuickInsert.js.map +1 -0
  51. package/dist/quickInsert/icons.d.ts +16 -0
  52. package/dist/quickInsert/icons.d.ts.map +1 -0
  53. package/dist/quickInsert/icons.js +21 -0
  54. package/dist/quickInsert/icons.js.map +1 -0
  55. package/dist/quickInsert/quickInsertCommand.d.ts +47 -0
  56. package/dist/quickInsert/quickInsertCommand.d.ts.map +1 -0
  57. package/dist/quickInsert/quickInsertCommand.js +81 -0
  58. package/dist/quickInsert/quickInsertCommand.js.map +1 -0
  59. package/dist/quickInsert/quickInsertListView.d.ts +29 -0
  60. package/dist/quickInsert/quickInsertListView.d.ts.map +1 -0
  61. package/dist/quickInsert/quickInsertListView.js +44 -0
  62. package/dist/quickInsert/quickInsertListView.js.map +1 -0
  63. package/dist/quickInsert/quickInsertPositionCommand.d.ts +33 -0
  64. package/dist/quickInsert/quickInsertPositionCommand.d.ts.map +1 -0
  65. package/dist/quickInsert/quickInsertPositionCommand.js +52 -0
  66. package/dist/quickInsert/quickInsertPositionCommand.js.map +1 -0
  67. package/dist/quickInsert/quickInsertView.d.ts +20 -0
  68. package/dist/quickInsert/quickInsertView.d.ts.map +1 -0
  69. package/dist/quickInsert/quickInsertView.js +52 -0
  70. package/dist/quickInsert/quickInsertView.js.map +1 -0
  71. package/package.json +52 -3
  72. package/src/augmentation.ts +44 -0
  73. package/src/breadcrumbs/breadcrumbItemView.ts +74 -0
  74. package/src/breadcrumbs/breadcrumbLabelView.ts +55 -0
  75. package/src/breadcrumbs/breadcrumbsView.ts +176 -0
  76. package/src/breadcrumbs/elementBreadcrumbs.ts +323 -0
  77. package/src/contextMenu/contextMenuObserver.ts +27 -0
  78. package/src/contextMenu/elementContextMenu.ts +104 -0
  79. package/src/domTarget.ts +27 -0
  80. package/src/elementMenu.ts +249 -0
  81. package/src/elementMenuView.ts +202 -0
  82. package/src/index.ts +54 -0
  83. package/src/quickInsert/elementQuickInsert.ts +135 -0
  84. package/src/quickInsert/icons.ts +26 -0
  85. package/src/quickInsert/quickInsertCommand.ts +96 -0
  86. package/src/quickInsert/quickInsertListView.ts +71 -0
  87. package/src/quickInsert/quickInsertPositionCommand.ts +68 -0
  88. package/src/quickInsert/quickInsertView.ts +64 -0
  89. package/theme/menu.css +131 -0
@@ -0,0 +1,176 @@
1
+ import { KeystrokeHandler, View, ViewCollection, type Locale } from 'ckeditor5';
2
+ import BreadcrumbItemView, { type BreadcrumbItem } from './breadcrumbItemView.js';
3
+ import BreadcrumbLabelView from './breadcrumbLabelView.js';
4
+
5
+ /**
6
+ * The options of {@link BreadcrumbsView}.
7
+ */
8
+ export interface BreadcrumbsViewOptions {
9
+ /**
10
+ * Whether the items are buttons that can be clicked, or labels. Defaults to `false`.
11
+ */
12
+ isClickable?: boolean;
13
+ }
14
+
15
+ /**
16
+ * Shows a path of elements, from the outermost to the innermost one, as a list (`<ol>`) in a `<nav>` landmark: buttons
17
+ * when the items can be clicked, labels otherwise. Items without a model element (list items) are labels too. The
18
+ * innermost item is marked with `aria-current="location"`.
19
+ *
20
+ * The left and right arrows move the focus between the items; past the first or the last one, they are left to the
21
+ * toolbar the view is in. Fires `execute` with the item when one of the buttons is clicked.
22
+ */
23
+ export default class BreadcrumbsView extends View {
24
+ /**
25
+ * The breadcrumb items: the buttons or labels, from the outermost to the innermost one.
26
+ */
27
+ public readonly items = new ViewCollection<BreadcrumbItemView | BreadcrumbLabelView>();
28
+
29
+ /**
30
+ * Whether the items are buttons that can be clicked, or labels.
31
+ */
32
+ public readonly isClickable: boolean;
33
+
34
+ /**
35
+ * Handles the keystrokes within the breadcrumbs.
36
+ */
37
+ public readonly keystrokes = new KeystrokeHandler();
38
+
39
+ /**
40
+ * The list items (`<li>`) that hold the {@link items}.
41
+ */
42
+ private readonly _listItems: ViewCollection<BreadcrumbsListItemView>;
43
+
44
+ constructor(locale?: Locale, { isClickable = false }: BreadcrumbsViewOptions = {}) {
45
+ super(locale);
46
+
47
+ this._listItems = this.createCollection();
48
+ this.isClickable = isClickable;
49
+
50
+ const isRtl = locale?.uiLanguageDirection === 'rtl';
51
+
52
+ this.keystrokes.set(isRtl ? 'arrowright' : 'arrowleft', (data, cancel) => {
53
+ if (this._focusSibling(-1)) {
54
+ cancel();
55
+ }
56
+ });
57
+
58
+ this.keystrokes.set(isRtl ? 'arrowleft' : 'arrowright', (data, cancel) => {
59
+ if (this._focusSibling(1)) {
60
+ cancel();
61
+ }
62
+ });
63
+
64
+ this.setTemplate({
65
+ tag: 'nav',
66
+ attributes: {
67
+ class: ['ck', 'ck-elements-breadcrumbs'],
68
+ 'aria-label': locale?.t('Element path') ?? 'Element path'
69
+ },
70
+ children: [
71
+ {
72
+ tag: 'ol',
73
+ attributes: {
74
+ class: ['ck', 'ck-elements-breadcrumbs__list']
75
+ },
76
+ children: this._listItems
77
+ }
78
+ ]
79
+ });
80
+ }
81
+
82
+ /**
83
+ * Replaces the items.
84
+ */
85
+ public setItems(items: readonly BreadcrumbItem[]): void {
86
+ for (const listItem of this._listItems) {
87
+ listItem.destroy();
88
+ }
89
+
90
+ this._listItems.clear();
91
+ this.items.clear();
92
+
93
+ items.forEach((item, index) => {
94
+ const isCurrent = index === items.length - 1;
95
+ const view =
96
+ this.isClickable && item.modelElement
97
+ ? new BreadcrumbItemView(this.locale, item, { isCurrent })
98
+ : new BreadcrumbLabelView(this.locale, item, { isCurrent });
99
+
100
+ if (view instanceof BreadcrumbItemView) {
101
+ view.on('execute', () => {
102
+ this.fire<BreadcrumbsViewExecuteEvent>('execute', view);
103
+ });
104
+ }
105
+
106
+ this.items.add(view);
107
+ this._listItems.add(new BreadcrumbsListItemView(this.locale, view));
108
+ });
109
+ }
110
+
111
+ /**
112
+ * Renders the view and starts handling the keystrokes.
113
+ */
114
+ public override render(): void {
115
+ super.render();
116
+
117
+ if (this.element) {
118
+ this.keystrokes.listenTo(this.element);
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Focuses an item: the first one when the focus moves forward (`1`, e.g. from the previous toolbar item), the
124
+ * innermost one otherwise.
125
+ */
126
+ public focus(direction?: 1 | -1): void {
127
+ (direction === 1 ? this.items.first : this.items.last)?.focus();
128
+ }
129
+
130
+ /**
131
+ * Destroys the view and its keystroke handler.
132
+ */
133
+ public override destroy(): void {
134
+ super.destroy();
135
+ this.keystrokes.destroy();
136
+ }
137
+
138
+ /**
139
+ * Focuses the item before (`-1`) or after (`1`) the focused one. Returns `false` when there is none.
140
+ */
141
+ private _focusSibling(step: 1 | -1): boolean {
142
+ const items = Array.from(this.items);
143
+ const index = items.findIndex(item => item.element === document.activeElement);
144
+ const sibling = index === -1 ? undefined : items[index + step];
145
+
146
+ sibling?.focus();
147
+
148
+ return !!sibling;
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Fired by {@link BreadcrumbsView} when one of its buttons is clicked.
154
+ */
155
+ export interface BreadcrumbsViewExecuteEvent {
156
+ name: 'execute';
157
+ args: [item: BreadcrumbItemView];
158
+ }
159
+
160
+ /**
161
+ * A list item (`<li>`) of the breadcrumbs, holding a button or a label.
162
+ */
163
+ class BreadcrumbsListItemView extends View {
164
+ constructor(locale: Locale | undefined, item: BreadcrumbItemView | BreadcrumbLabelView) {
165
+ super(locale);
166
+
167
+ // Rendering registers the item as a child, so it is destroyed with the list item.
168
+ this.setTemplate({
169
+ tag: 'li',
170
+ attributes: {
171
+ class: ['ck', 'ck-elements-breadcrumbs__list-item']
172
+ },
173
+ children: [item]
174
+ });
175
+ }
176
+ }
@@ -0,0 +1,323 @@
1
+ import {
2
+ Plugin,
3
+ type DifferItem,
4
+ type Editor,
5
+ type Locale,
6
+ type ModelDocumentChangeEvent,
7
+ type ModelDocumentFragment,
8
+ type ModelElement,
9
+ type ModelRootElement,
10
+ type ViewDocumentFragment,
11
+ type ViewElement,
12
+ type ViewNode
13
+ } from 'ckeditor5';
14
+ import { isInDocument } from 'ckeditor5-elements-core';
15
+ import ElementMenu, { type ElementMenuConfig } from '../elementMenu.js';
16
+ import { type BreadcrumbItem, type default as BreadcrumbItemView } from './breadcrumbItemView.js';
17
+ import BreadcrumbsView, { type BreadcrumbsViewExecuteEvent } from './breadcrumbsView.js';
18
+
19
+ /**
20
+ * Converting the data view of a large document takes a while. When the last conversion took longer than this (in
21
+ * milliseconds), the next one waits for {@link THROTTLE_TIME}, so editing elements quickly does not convert every time.
22
+ */
23
+ const SLOW_CONVERSION_TIME = 10;
24
+ const THROTTLE_TIME = 250;
25
+
26
+ /**
27
+ * The configuration of the {@link ElementBreadcrumbs} plugin.
28
+ */
29
+ export interface ElementBreadcrumbsConfig {
30
+ /**
31
+ * Returns the items of the menu that opens when a breadcrumb is clicked, for the element of the breadcrumb: the
32
+ * same items as in the `elementContextMenu` option (commands, submenus and `'|'` separators), so one callback can
33
+ * serve both. Without this option, breadcrumbs are labels, and cannot be clicked.
34
+ */
35
+ menu?: ElementMenuConfig;
36
+ }
37
+
38
+ /**
39
+ * The data view of a root, with the model elements mapped to their view elements and back.
40
+ */
41
+ interface DataView {
42
+ root: ModelRootElement;
43
+ viewElements: WeakMap<ModelElement, ViewElement>;
44
+ modelElements: WeakMap<ViewElement, ModelElement>;
45
+ }
46
+
47
+ /**
48
+ * Shows the data view structure at the current selection (the elements of the editor data, as in `getData()`), e.g.
49
+ * `aside.note › p`, `ul › li` or `figure.table › table › tbody › tr › td`.
50
+ *
51
+ * Inline elements are items, as any element. CKEditor's text attributes, marks on the text such as its `<strong>`, are
52
+ * skipped. The selected element, e.g. an image, is the innermost item.
53
+ *
54
+ * Place them in a toolbar as the `breadcrumbs` component, or anywhere else using a view from {@link createView}.
55
+ *
56
+ * Clicking is configured only by the `elementBreadcrumbs.menu` editor config option (see {@link ElementBreadcrumbsConfig}): with it,
57
+ * the items are buttons that open the menu of their element, with `ElementMenu`, as the context menu does; without it,
58
+ * they are labels.
59
+ *
60
+ * The data view is converted again only when elements change, not when typing or moving the cursor. For a large
61
+ * document, that happens at most every 250 ms.
62
+ */
63
+ export default class ElementBreadcrumbs extends Plugin {
64
+ /**
65
+ * The elements at the current selection, from the outermost to the innermost one.
66
+ *
67
+ * @observable
68
+ */
69
+ declare public items: readonly BreadcrumbItem[];
70
+
71
+ private _dataView: DataView | null = null;
72
+ private _isDataViewOutdated = true;
73
+ private _isConversionSlow = false;
74
+ private _conversionTimeout: ReturnType<typeof setTimeout> | null = null;
75
+
76
+ public static get pluginName(): 'ElementBreadcrumbs' {
77
+ return 'ElementBreadcrumbs' as const;
78
+ }
79
+
80
+ public static get requires(): readonly [typeof ElementMenu] {
81
+ return [ElementMenu] as const;
82
+ }
83
+
84
+ constructor(editor: Editor) {
85
+ super(editor);
86
+
87
+ this.set('items', []);
88
+ }
89
+
90
+ public init(): void {
91
+ const editor = this.editor;
92
+
93
+ editor.ui.componentFactory.add('breadcrumbs', locale => this.createView(locale));
94
+
95
+ this.listenTo(editor.ui, 'ready', () => {
96
+ this.refresh();
97
+ });
98
+
99
+ // Both selection and data changes.
100
+ this.listenTo<ModelDocumentChangeEvent>(editor.model.document, 'change', () => {
101
+ if (hasElementChanges(editor.model.document.differ.getChanges())) {
102
+ this._isDataViewOutdated = true;
103
+ }
104
+
105
+ this._update();
106
+ });
107
+ }
108
+
109
+ /**
110
+ * Creates a breadcrumbs view that follows {@link items}.
111
+ */
112
+ public createView(locale: Locale = this.editor.locale): BreadcrumbsView {
113
+ const isClickable = typeof this.editor.config.get('elementBreadcrumbs')?.menu === 'function';
114
+ const view = new BreadcrumbsView(locale, { isClickable });
115
+
116
+ view.setItems(this.items);
117
+ view.listenTo(this, 'change:items', () => {
118
+ view.setItems(this.items);
119
+ });
120
+
121
+ view.on<BreadcrumbsViewExecuteEvent>('execute', (evt, item) => {
122
+ this._openMenu(item);
123
+ });
124
+
125
+ return view;
126
+ }
127
+
128
+ /**
129
+ * Updates {@link items} from the current selection, converting the data view first when elements changed.
130
+ */
131
+ public refresh(): void {
132
+ const root = this.editor.model.document.selection.getFirstPosition()?.root;
133
+
134
+ if (root?.is('rootElement') && (this._isDataViewOutdated || root !== this._dataView?.root)) {
135
+ this._convert(root);
136
+ }
137
+
138
+ const items = this._getItems();
139
+
140
+ if (!areEqual(items, this.items)) {
141
+ this.items = items;
142
+ }
143
+ }
144
+
145
+ public override destroy(): void {
146
+ this._cancelConversion();
147
+ super.destroy();
148
+ }
149
+
150
+ /**
151
+ * Refreshes the items after a change. When converting is slow, the data view is converted later, and the items
152
+ * follow the outdated data view until then.
153
+ */
154
+ private _update(): void {
155
+ const root = this.editor.model.document.selection.getFirstPosition()?.root;
156
+
157
+ if (this._isDataViewOutdated && this._isConversionSlow && root === this._dataView?.root) {
158
+ this._conversionTimeout ??= setTimeout(() => {
159
+ this._conversionTimeout = null;
160
+ this.refresh();
161
+ }, THROTTLE_TIME);
162
+ }
163
+
164
+ if (this._conversionTimeout) {
165
+ const items = this._getItems();
166
+
167
+ if (!areEqual(items, this.items)) {
168
+ this.items = items;
169
+ }
170
+ } else {
171
+ this.refresh();
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Converts the root to the data view, and maps its model elements to their view elements.
177
+ */
178
+ private _convert(root: ModelRootElement): void {
179
+ const data = this.editor.data;
180
+ const start = performance.now();
181
+ const viewElements = new WeakMap<ModelElement, ViewElement>();
182
+ const modelElements = new WeakMap<ViewElement, ModelElement>();
183
+
184
+ // The mapper of the data pipeline is cleared by the next conversion (e.g. `getData()`), so keep the mapping.
185
+ data.toView(root);
186
+
187
+ for (const { item } of this.editor.model.createRangeIn(root)) {
188
+ const viewElement = item.is('element') ? data.mapper.toViewElement(item) : undefined;
189
+
190
+ if (item.is('element') && viewElement) {
191
+ viewElements.set(item, viewElement);
192
+ modelElements.set(viewElement, item);
193
+ }
194
+ }
195
+
196
+ this._dataView = { root, viewElements, modelElements };
197
+ this._isDataViewOutdated = false;
198
+ this._isConversionSlow = performance.now() - start > SLOW_CONVERSION_TIME;
199
+ this._cancelConversion();
200
+ }
201
+
202
+ private _cancelConversion(): void {
203
+ if (this._conversionTimeout) {
204
+ clearTimeout(this._conversionTimeout);
205
+ this._conversionTimeout = null;
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Returns the data view elements at the selection: the view element of the selected element, or of the innermost
211
+ * element with the selection, and its ancestors.
212
+ */
213
+ private _getItems(): BreadcrumbItem[] {
214
+ const dataView = this._dataView;
215
+ const selection = this.editor.model.document.selection;
216
+ const position = selection.getFirstPosition();
217
+
218
+ if (!dataView || position?.root !== dataView.root) {
219
+ return [];
220
+ }
221
+
222
+ // Elements created since the last conversion are not mapped yet; their closest mapped ancestor is used meanwhile.
223
+ let viewElement: ViewElement | undefined;
224
+
225
+ for (
226
+ let element: ModelElement | ModelDocumentFragment | null =
227
+ selection.getSelectedElement() ?? position.parent;
228
+ element?.is('element') && !element.is('rootElement') && !viewElement;
229
+ element = element.parent
230
+ ) {
231
+ viewElement = dataView.viewElements.get(element);
232
+ }
233
+
234
+ return viewElement ? getPath(viewElement).map(node => createItem(node, dataView)) : [];
235
+ }
236
+
237
+ private _openMenu(item: BreadcrumbItemView): void {
238
+ const menuItems = this.editor.config.get('elementBreadcrumbs')?.menu;
239
+ const element = item.modelElement;
240
+
241
+ if (!menuItems || !item.element || !element || !isInDocument(element)) {
242
+ return;
243
+ }
244
+
245
+ item.isExpanded = this.editor.plugins.get(ElementMenu).open(element, item.element, menuItems, {
246
+ // The breadcrumbs may have been replaced while the menu was open.
247
+ returnFocusTo: {
248
+ focus: () => {
249
+ if (item.element?.isConnected) {
250
+ item.focus();
251
+ } else {
252
+ this.editor.editing.view.focus();
253
+ }
254
+ }
255
+ },
256
+ onHide: () => {
257
+ item.isExpanded = false;
258
+ }
259
+ });
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Whether the changes add, remove or change elements. Typing (text changes) does not change the data view structure.
265
+ */
266
+ function hasElementChanges(changes: readonly DifferItem[]): boolean {
267
+ return changes.some(change =>
268
+ change.type === 'attribute'
269
+ ? Array.from(change.range.getItems({ shallow: true })).some(item => item.is('element'))
270
+ : change.name !== '$text'
271
+ );
272
+ }
273
+
274
+ /**
275
+ * Returns the view element and its ancestors, outermost first, without:
276
+ *
277
+ * * text attributes (attribute elements without a block inside, such as `<strong>` or `<a>`); list items (`<ul>`,
278
+ * `<li>`) are attribute elements too, but wrap blocks,
279
+ * * elements that are not rendered in the data, such as the paragraph in a list item or a table cell that has only
280
+ * one paragraph.
281
+ */
282
+ function getPath(viewElement: ViewElement): ViewElement[] {
283
+ const path: ViewElement[] = [];
284
+ let hasBlock = false;
285
+
286
+ for (let node: ViewNode | ViewDocumentFragment | null = viewElement; node?.is('element'); node = node.parent) {
287
+ const isTextAttribute = node.is('attributeElement') && !hasBlock;
288
+
289
+ if (!isTextAttribute && !node.getCustomProperty('dataPipeline:transparentRendering')) {
290
+ path.unshift(node);
291
+ }
292
+
293
+ hasBlock ||= node.is('containerElement');
294
+ }
295
+
296
+ return path;
297
+ }
298
+
299
+ /**
300
+ * Creates the item of a view element. Its model element is the mapped one, or the closest mapped ancestor of a part of
301
+ * a structure (e.g. a `tbody` is a part of a table). List items have none: they come from the attributes of the blocks
302
+ * in them, not from an element.
303
+ */
304
+ function createItem(viewElement: ViewElement, dataView: DataView): BreadcrumbItem {
305
+ let modelElement: ModelElement | null = null;
306
+
307
+ for (
308
+ let node: ViewNode | ViewDocumentFragment | null = viewElement;
309
+ node?.is('element') && !modelElement && !node.is('attributeElement');
310
+ node = node.parent
311
+ ) {
312
+ modelElement = dataView.modelElements.get(node) ?? null;
313
+ }
314
+
315
+ return { label: [viewElement.name, ...viewElement.getClassNames()].join('.'), viewElement, modelElement };
316
+ }
317
+
318
+ function areEqual(a: readonly BreadcrumbItem[], b: readonly BreadcrumbItem[]): boolean {
319
+ return (
320
+ a.length === b.length &&
321
+ a.every((item, index) => item.label === b[index]?.label && item.modelElement === b[index].modelElement)
322
+ );
323
+ }
@@ -0,0 +1,27 @@
1
+ import { DomEventObserver, type ViewDocumentDomEventData } from 'ckeditor5';
2
+
3
+ /**
4
+ * Fires the `contextmenu` event on the view document when the user opens the native context menu in an editable.
5
+ */
6
+ export default class ContextMenuObserver extends DomEventObserver<'contextmenu'> {
7
+ // The base class declares it as an accessor, so it cannot be a field here.
8
+ // eslint-disable-next-line @typescript-eslint/class-literal-property-style
9
+ public get domEventType(): 'contextmenu' {
10
+ return 'contextmenu';
11
+ }
12
+
13
+ /**
14
+ * Fires `contextmenu` on the view document for the DOM event.
15
+ */
16
+ public onDomEvent(domEvent: MouseEvent): void {
17
+ this.fire(domEvent.type, domEvent);
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Fired on the view document by the {@link ContextMenuObserver}.
23
+ */
24
+ export interface ViewDocumentContextMenuEvent {
25
+ name: 'contextmenu';
26
+ args: [data: ViewDocumentDomEventData<MouseEvent>];
27
+ }
@@ -0,0 +1,104 @@
1
+ import { Plugin, type ModelElement, type RectSource, type ViewElement } from 'ckeditor5';
2
+ import { getElementAtSelection, getElementsAround, isInDocument } from 'ckeditor5-elements-core';
3
+ import { getDomTarget } from '../domTarget.js';
4
+ import ElementMenu from '../elementMenu.js';
5
+ import ContextMenuObserver, { type ViewDocumentContextMenuEvent } from './contextMenuObserver.js';
6
+
7
+ /**
8
+ * Shows the menu of an element when the user right-clicks it, or opens the context menu with the keyboard (the menu
9
+ * key, or Shift+F10) while the cursor is in it.
10
+ *
11
+ * The items come from the `elementContextMenu` editor config option only (see `ElementMenuConfig`): commands, submenus
12
+ * and `'|'` separators. Without it, or when it returns no items for an
13
+ * element, the native context menu shows. The menu itself is shown by the `ElementMenu` plugin.
14
+ */
15
+ export default class ElementContextMenu extends Plugin {
16
+ public static get requires(): readonly [typeof ElementMenu] {
17
+ return [ElementMenu] as const;
18
+ }
19
+
20
+ public static get pluginName(): 'ElementContextMenu' {
21
+ return 'ElementContextMenu' as const;
22
+ }
23
+
24
+ public init(): void {
25
+ const editor = this.editor;
26
+ const editingView = editor.editing.view;
27
+
28
+ editingView.addObserver(ContextMenuObserver);
29
+
30
+ this.listenTo<ViewDocumentContextMenuEvent>(editingView.document, 'contextmenu', (evt, data) => {
31
+ const { domEvent } = data;
32
+ const element = this.getModelElement(data.target);
33
+
34
+ // A context menu opened with the keyboard (the menu key, or Shift+F10 in some browsers) targets the editable
35
+ // itself, not the element at the cursor. A right-click outside any element keeps the native menu.
36
+ const isOpen = element
37
+ ? this.open(element, new DOMRect(domEvent.clientX, domEvent.clientY, 0, 0))
38
+ : domEvent.button !== 2 && this.openAtSelection();
39
+
40
+ if (isOpen) {
41
+ data.preventDefault();
42
+ }
43
+ });
44
+
45
+ // Not every browser opens the context menu on Shift+F10 (e.g. on macOS), so handle it in all of them.
46
+ editor.keystrokes.set('Shift+F10', (data, cancel) => {
47
+ if (this.openAtSelection()) {
48
+ cancel();
49
+ }
50
+ });
51
+
52
+ if (typeof editor.config.get('elementContextMenu') === 'function') {
53
+ editor.accessibility.addKeystrokeInfos({
54
+ keystrokes: [{ label: editor.t('Open the menu of the element at the cursor'), keystroke: 'Shift+F10' }]
55
+ });
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Opens the menu of the element next to the target (e.g. a button, or a `DOMRect` in viewport coordinates), with
61
+ * the items from the `elementContextMenu` config option.
62
+ *
63
+ * Returns `false` when there are no items, so the caller can let the native context menu show instead.
64
+ */
65
+ public open(element: ModelElement, target: RectSource): boolean {
66
+ const config = this.editor.config.get('elementContextMenu');
67
+
68
+ if (typeof config !== 'function') {
69
+ return false;
70
+ }
71
+
72
+ return this.editor.plugins
73
+ .get(ElementMenu)
74
+ .open(element, target, config, { label: this.editor.t('Element menu') });
75
+ }
76
+
77
+ /**
78
+ * Opens the menu of the element at the selection (the selected element, or the element with the cursor), next to
79
+ * the selection.
80
+ *
81
+ * Returns `false` when there is no such element or no items.
82
+ */
83
+ public openAtSelection(): boolean {
84
+ const element = getElementAtSelection(this.editor.model.document.selection);
85
+ const target = getDomTarget(this.editor);
86
+
87
+ if (!element || !isInDocument(element) || !target) {
88
+ return false;
89
+ }
90
+
91
+ return this.open(element, target);
92
+ }
93
+
94
+ /**
95
+ * Returns the closest model element mapped to the given view element or its ancestors, skipping the root.
96
+ */
97
+ public getModelElement(viewElement: ViewElement): ModelElement | null {
98
+ for (const { element } of getElementsAround(this.editor.editing.mapper, viewElement)) {
99
+ return element;
100
+ }
101
+
102
+ return null;
103
+ }
104
+ }
@@ -0,0 +1,27 @@
1
+ import { ModelPosition, type Editor } from 'ckeditor5';
2
+ import type { ElementOrPosition } from 'ckeditor5-elements-core';
3
+
4
+ /**
5
+ * Returns what a menu for the target is shown next to: the DOM element of an element, or the DOM range of a position.
6
+ * Without a target, the ones of the selection: its selected element, or its range. `null` when it is not rendered.
7
+ */
8
+ export function getDomTarget(editor: Editor, target?: ElementOrPosition): HTMLElement | Range | null {
9
+ const { mapper, view } = editor.editing;
10
+
11
+ if (target instanceof ModelPosition) {
12
+ return view.domConverter.viewRangeToDom(view.createRange(mapper.toViewPosition(target)));
13
+ }
14
+
15
+ const element = target ?? editor.model.document.selection.getSelectedElement();
16
+
17
+ if (element) {
18
+ const viewElement = mapper.toViewElement(element);
19
+ const domElement = viewElement && view.domConverter.mapViewToDom(viewElement);
20
+
21
+ return domElement instanceof HTMLElement ? domElement : null;
22
+ }
23
+
24
+ const viewRange = view.document.selection.getFirstRange();
25
+
26
+ return viewRange ? view.domConverter.viewRangeToDom(viewRange) : null;
27
+ }