ckeditor5-elements-breadcrumbs 0.0.0-stage → 49.0.0-alpha.2
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.
- package/LICENSE +21 -0
- package/README.md +31 -2
- package/dist/augmentation.d.ts +13 -0
- package/dist/augmentation.d.ts.map +1 -0
- package/dist/augmentation.js +2 -0
- package/dist/augmentation.js.map +1 -0
- package/dist/breadcrumbItemView.d.ts +47 -0
- package/dist/breadcrumbItemView.d.ts.map +1 -0
- package/dist/breadcrumbItemView.js +30 -0
- package/dist/breadcrumbItemView.js.map +1 -0
- package/dist/breadcrumbLabelView.d.ts +29 -0
- package/dist/breadcrumbLabelView.d.ts.map +1 -0
- package/dist/breadcrumbLabelView.js +39 -0
- package/dist/breadcrumbLabelView.js.map +1 -0
- package/dist/breadcrumbs.d.ts +103 -0
- package/dist/breadcrumbs.d.ts.map +1 -0
- package/dist/breadcrumbs.js +238 -0
- package/dist/breadcrumbs.js.map +1 -0
- package/dist/breadcrumbsView.d.ts +68 -0
- package/dist/breadcrumbsView.d.ts.map +1 -0
- package/dist/breadcrumbsView.js +134 -0
- package/dist/breadcrumbsView.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/package.json +49 -3
- package/src/augmentation.ts +14 -0
- package/src/breadcrumbItemView.ts +74 -0
- package/src/breadcrumbLabelView.ts +55 -0
- package/src/breadcrumbs.ts +375 -0
- package/src/breadcrumbsView.ts +176 -0
- package/src/index.ts +29 -0
- package/theme/breadcrumbs.css +103 -0
|
@@ -0,0 +1,375 @@
|
|
|
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 RectSource,
|
|
11
|
+
type ViewDocumentFragment,
|
|
12
|
+
type ViewElement,
|
|
13
|
+
type ViewNode
|
|
14
|
+
} from 'ckeditor5';
|
|
15
|
+
import { findPlugin } from 'ckeditor5-elements-core';
|
|
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 Breadcrumbs} plugin.
|
|
28
|
+
*/
|
|
29
|
+
export interface BreadcrumbsConfig {
|
|
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; see `ElementMenuConfig`),
|
|
33
|
+
* so one callback can serve both. Without this option, or without a loaded `ElementMenu` plugin, breadcrumbs cannot
|
|
34
|
+
* be clicked.
|
|
35
|
+
*/
|
|
36
|
+
menu?: BreadcrumbsMenuItems;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The data view of a root, with the model elements mapped to their view elements and back.
|
|
41
|
+
*/
|
|
42
|
+
interface DataView {
|
|
43
|
+
root: ModelRootElement;
|
|
44
|
+
viewElements: WeakMap<ModelElement, ViewElement>;
|
|
45
|
+
modelElements: WeakMap<ViewElement, ModelElement>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Shows the data view structure at the current selection (the elements of the editor data, as in `getData()`), e.g.
|
|
50
|
+
* `aside.note › p`, `ul › li` or `figure.table › table › tbody › tr › td`.
|
|
51
|
+
*
|
|
52
|
+
* Text attributes such as `<strong>` are 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 `breadcrumbs.menu` editor config option (see {@link BreadcrumbsConfig}): with it,
|
|
57
|
+
* the items are buttons that open a menu of their element; without it, they are labels. The menu is shown by the
|
|
58
|
+
* `ElementMenu` plugin (loaded by `ElementContextMenu` and `QuickInsert`), which is used through the
|
|
59
|
+
* {@link BreadcrumbsMenu} interface: the breadcrumbs do not depend on the other features.
|
|
60
|
+
*
|
|
61
|
+
* The data view is converted again only when elements change, not when typing or moving the cursor. For a large
|
|
62
|
+
* document, that happens at most every 250 ms.
|
|
63
|
+
*/
|
|
64
|
+
export default class Breadcrumbs extends Plugin {
|
|
65
|
+
/**
|
|
66
|
+
* The elements at the current selection, from the outermost to the innermost one.
|
|
67
|
+
*
|
|
68
|
+
* @observable
|
|
69
|
+
*/
|
|
70
|
+
declare public items: readonly BreadcrumbItem[];
|
|
71
|
+
|
|
72
|
+
private _dataView: DataView | null = null;
|
|
73
|
+
private _isDataViewOutdated = true;
|
|
74
|
+
private _isConversionSlow = false;
|
|
75
|
+
private _conversionTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
76
|
+
|
|
77
|
+
public static get pluginName(): 'Breadcrumbs' {
|
|
78
|
+
return 'Breadcrumbs' as const;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
constructor(editor: Editor) {
|
|
82
|
+
super(editor);
|
|
83
|
+
|
|
84
|
+
this.set('items', []);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
public init(): void {
|
|
88
|
+
const editor = this.editor;
|
|
89
|
+
|
|
90
|
+
editor.ui.componentFactory.add('breadcrumbs', locale => this.createView(locale));
|
|
91
|
+
|
|
92
|
+
this.listenTo(editor.ui, 'ready', () => {
|
|
93
|
+
this.refresh();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Both selection and data changes.
|
|
97
|
+
this.listenTo<ModelDocumentChangeEvent>(editor.model.document, 'change', () => {
|
|
98
|
+
if (hasElementChanges(editor.model.document.differ.getChanges())) {
|
|
99
|
+
this._isDataViewOutdated = true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
this._update();
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Creates a breadcrumbs view that follows {@link items}.
|
|
108
|
+
*/
|
|
109
|
+
public createView(locale: Locale = this.editor.locale): BreadcrumbsView {
|
|
110
|
+
const isClickable = typeof this.editor.config.get('breadcrumbs')?.menu === 'function' && !!this._getMenu();
|
|
111
|
+
const view = new BreadcrumbsView(locale, { isClickable });
|
|
112
|
+
|
|
113
|
+
view.setItems(this.items);
|
|
114
|
+
view.listenTo(this, 'change:items', () => {
|
|
115
|
+
view.setItems(this.items);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
view.on<BreadcrumbsViewExecuteEvent>('execute', (evt, item) => {
|
|
119
|
+
this._openMenu(item);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
return view;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Updates {@link items} from the current selection, converting the data view first when elements changed.
|
|
127
|
+
*/
|
|
128
|
+
public refresh(): void {
|
|
129
|
+
const root = this.editor.model.document.selection.getFirstPosition()?.root;
|
|
130
|
+
|
|
131
|
+
if (root?.is('rootElement') && (this._isDataViewOutdated || root !== this._dataView?.root)) {
|
|
132
|
+
this._convert(root);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const items = this._getItems();
|
|
136
|
+
|
|
137
|
+
if (!areEqual(items, this.items)) {
|
|
138
|
+
this.items = items;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
public override destroy(): void {
|
|
143
|
+
this._cancelConversion();
|
|
144
|
+
super.destroy();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Refreshes the items after a change. When converting is slow, the data view is converted later, and the items
|
|
149
|
+
* follow the outdated data view until then.
|
|
150
|
+
*/
|
|
151
|
+
private _update(): void {
|
|
152
|
+
const root = this.editor.model.document.selection.getFirstPosition()?.root;
|
|
153
|
+
|
|
154
|
+
if (this._isDataViewOutdated && this._isConversionSlow && root === this._dataView?.root) {
|
|
155
|
+
this._conversionTimeout ??= setTimeout(() => {
|
|
156
|
+
this._conversionTimeout = null;
|
|
157
|
+
this.refresh();
|
|
158
|
+
}, THROTTLE_TIME);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (this._conversionTimeout) {
|
|
162
|
+
const items = this._getItems();
|
|
163
|
+
|
|
164
|
+
if (!areEqual(items, this.items)) {
|
|
165
|
+
this.items = items;
|
|
166
|
+
}
|
|
167
|
+
} else {
|
|
168
|
+
this.refresh();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Converts the root to the data view, and maps its model elements to their view elements.
|
|
174
|
+
*/
|
|
175
|
+
private _convert(root: ModelRootElement): void {
|
|
176
|
+
const data = this.editor.data;
|
|
177
|
+
const start = performance.now();
|
|
178
|
+
const viewElements = new WeakMap<ModelElement, ViewElement>();
|
|
179
|
+
const modelElements = new WeakMap<ViewElement, ModelElement>();
|
|
180
|
+
|
|
181
|
+
// The mapper of the data pipeline is cleared by the next conversion (e.g. `getData()`), so keep the mapping.
|
|
182
|
+
data.toView(root);
|
|
183
|
+
|
|
184
|
+
for (const { item } of this.editor.model.createRangeIn(root)) {
|
|
185
|
+
const viewElement = item.is('element') ? data.mapper.toViewElement(item) : undefined;
|
|
186
|
+
|
|
187
|
+
if (item.is('element') && viewElement) {
|
|
188
|
+
viewElements.set(item, viewElement);
|
|
189
|
+
modelElements.set(viewElement, item);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
this._dataView = { root, viewElements, modelElements };
|
|
194
|
+
this._isDataViewOutdated = false;
|
|
195
|
+
this._isConversionSlow = performance.now() - start > SLOW_CONVERSION_TIME;
|
|
196
|
+
this._cancelConversion();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private _cancelConversion(): void {
|
|
200
|
+
if (this._conversionTimeout) {
|
|
201
|
+
clearTimeout(this._conversionTimeout);
|
|
202
|
+
this._conversionTimeout = null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Returns the data view elements at the selection: the view element of the selected element, or of the innermost
|
|
208
|
+
* element with the selection, and its ancestors.
|
|
209
|
+
*/
|
|
210
|
+
private _getItems(): BreadcrumbItem[] {
|
|
211
|
+
const dataView = this._dataView;
|
|
212
|
+
const selection = this.editor.model.document.selection;
|
|
213
|
+
const position = selection.getFirstPosition();
|
|
214
|
+
|
|
215
|
+
if (!dataView || position?.root !== dataView.root) {
|
|
216
|
+
return [];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Elements created since the last conversion are not mapped yet; their closest mapped ancestor is used meanwhile.
|
|
220
|
+
let viewElement: ViewElement | undefined;
|
|
221
|
+
|
|
222
|
+
for (
|
|
223
|
+
let element: ModelElement | ModelDocumentFragment | null =
|
|
224
|
+
selection.getSelectedElement() ?? position.parent;
|
|
225
|
+
element?.is('element') && !element.is('rootElement') && !viewElement;
|
|
226
|
+
element = element.parent
|
|
227
|
+
) {
|
|
228
|
+
viewElement = dataView.viewElements.get(element);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return viewElement ? getPath(viewElement).map(node => createItem(node, dataView)) : [];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private _openMenu(item: BreadcrumbItemView): void {
|
|
235
|
+
const menuItems = this.editor.config.get('breadcrumbs')?.menu;
|
|
236
|
+
const menu = this._getMenu();
|
|
237
|
+
const element = item.modelElement;
|
|
238
|
+
|
|
239
|
+
if (!menuItems || !menu || !item.element || !element || !isInDocument(element)) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
item.isExpanded = menu.open(element, item.element, menuItems, {
|
|
244
|
+
// The breadcrumbs may have been replaced while the menu was open.
|
|
245
|
+
returnFocusTo: {
|
|
246
|
+
focus: () => {
|
|
247
|
+
if (item.element?.isConnected) {
|
|
248
|
+
item.focus();
|
|
249
|
+
} else {
|
|
250
|
+
this.editor.editing.view.focus();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
onHide: () => {
|
|
255
|
+
item.isExpanded = false;
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Returns the plugin that shows menus: `ElementMenu`, when it is loaded.
|
|
262
|
+
*/
|
|
263
|
+
private _getMenu(): BreadcrumbsMenu | null {
|
|
264
|
+
return findPlugin(this.editor, 'ElementMenu', isBreadcrumbsMenu);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The part of the `ElementMenu` plugin the breadcrumbs use. The breadcrumbs do not depend on the other features: the
|
|
270
|
+
* plugin is looked up by name and checked against this interface.
|
|
271
|
+
*/
|
|
272
|
+
export interface BreadcrumbsMenu {
|
|
273
|
+
open(
|
|
274
|
+
element: ModelElement,
|
|
275
|
+
target: RectSource,
|
|
276
|
+
items: BreadcrumbsMenuItems,
|
|
277
|
+
options: BreadcrumbsMenuOptions
|
|
278
|
+
): boolean;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* The options the breadcrumbs pass to {@link BreadcrumbsMenu#open}.
|
|
283
|
+
*/
|
|
284
|
+
export interface BreadcrumbsMenuOptions {
|
|
285
|
+
/**
|
|
286
|
+
* What gets the focus when the menu is closed with Esc: the breadcrumb that opened it.
|
|
287
|
+
*/
|
|
288
|
+
returnFocusTo: { focus(): void };
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Called when the menu closes.
|
|
292
|
+
*/
|
|
293
|
+
onHide: () => void;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Returns the items of the menu of an element. The breadcrumbs pass them to the menu plugin as they are; for
|
|
298
|
+
* `ElementMenu`, they are commands, submenus and `'|'` separators.
|
|
299
|
+
*/
|
|
300
|
+
export type BreadcrumbsMenuItems = (element: ModelElement, editor: Editor) => readonly unknown[];
|
|
301
|
+
|
|
302
|
+
function isBreadcrumbsMenu(plugin: object): plugin is BreadcrumbsMenu {
|
|
303
|
+
return 'open' in plugin && typeof plugin.open === 'function';
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Whether the changes add, remove or change elements. Typing (text changes) does not change the data view structure.
|
|
308
|
+
*/
|
|
309
|
+
function hasElementChanges(changes: readonly DifferItem[]): boolean {
|
|
310
|
+
return changes.some(change =>
|
|
311
|
+
change.type === 'attribute'
|
|
312
|
+
? Array.from(change.range.getItems({ shallow: true })).some(item => item.is('element'))
|
|
313
|
+
: change.name !== '$text'
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Returns the view element and its ancestors, outermost first, without:
|
|
319
|
+
*
|
|
320
|
+
* * text attributes (attribute elements without a block inside, such as `<strong>` or `<a>`); list items (`<ul>`,
|
|
321
|
+
* `<li>`) are attribute elements too, but wrap blocks,
|
|
322
|
+
* * elements that are not rendered in the data, such as the paragraph in a list item or a table cell that has only
|
|
323
|
+
* one paragraph.
|
|
324
|
+
*/
|
|
325
|
+
function getPath(viewElement: ViewElement): ViewElement[] {
|
|
326
|
+
const path: ViewElement[] = [];
|
|
327
|
+
let hasBlock = false;
|
|
328
|
+
|
|
329
|
+
for (let node: ViewNode | ViewDocumentFragment | null = viewElement; node?.is('element'); node = node.parent) {
|
|
330
|
+
const isTextAttribute = node.is('attributeElement') && !hasBlock;
|
|
331
|
+
|
|
332
|
+
if (!isTextAttribute && !node.getCustomProperty('dataPipeline:transparentRendering')) {
|
|
333
|
+
path.unshift(node);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
hasBlock ||= node.is('containerElement');
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return path;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Creates the item of a view element. Its model element is the mapped one, or the closest mapped ancestor of a part of
|
|
344
|
+
* a structure (e.g. a `tbody` is a part of a table). List items have none: they come from the attributes of the blocks
|
|
345
|
+
* in them, not from an element.
|
|
346
|
+
*/
|
|
347
|
+
function createItem(viewElement: ViewElement, dataView: DataView): BreadcrumbItem {
|
|
348
|
+
let modelElement: ModelElement | null = null;
|
|
349
|
+
|
|
350
|
+
for (
|
|
351
|
+
let node: ViewNode | ViewDocumentFragment | null = viewElement;
|
|
352
|
+
node?.is('element') && !modelElement && !node.is('attributeElement');
|
|
353
|
+
node = node.parent
|
|
354
|
+
) {
|
|
355
|
+
modelElement = dataView.modelElements.get(node) ?? null;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return { label: [viewElement.name, ...viewElement.getClassNames()].join('.'), viewElement, modelElement };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Whether the element is in the document, i.e. not removed.
|
|
363
|
+
*/
|
|
364
|
+
function isInDocument(element: ModelElement): boolean {
|
|
365
|
+
const root = element.root;
|
|
366
|
+
|
|
367
|
+
return root.is('rootElement') && root.rootName !== '$graveyard' && root.isAttached();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function areEqual(a: readonly BreadcrumbItem[], b: readonly BreadcrumbItem[]): boolean {
|
|
371
|
+
return (
|
|
372
|
+
a.length === b.length &&
|
|
373
|
+
a.every((item, index) => item.label === b[index]?.label && item.modelElement === b[index].modelElement)
|
|
374
|
+
);
|
|
375
|
+
}
|
|
@@ -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
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Breadcrumbs: the element path at the selection, optionally clickable with a configured menu.
|
|
3
|
+
*
|
|
4
|
+
* @module ckeditor5-elements-breadcrumbs
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Plugins.
|
|
8
|
+
export {
|
|
9
|
+
default as Breadcrumbs,
|
|
10
|
+
type BreadcrumbsConfig,
|
|
11
|
+
type BreadcrumbsMenu,
|
|
12
|
+
type BreadcrumbsMenuItems,
|
|
13
|
+
type BreadcrumbsMenuOptions
|
|
14
|
+
} from './breadcrumbs.js';
|
|
15
|
+
|
|
16
|
+
// Views.
|
|
17
|
+
export {
|
|
18
|
+
default as BreadcrumbsView,
|
|
19
|
+
type BreadcrumbsViewExecuteEvent,
|
|
20
|
+
type BreadcrumbsViewOptions
|
|
21
|
+
} from './breadcrumbsView.js';
|
|
22
|
+
export {
|
|
23
|
+
default as BreadcrumbItemView,
|
|
24
|
+
type BreadcrumbItem,
|
|
25
|
+
type BreadcrumbItemViewOptions
|
|
26
|
+
} from './breadcrumbItemView.js';
|
|
27
|
+
export { default as BreadcrumbLabelView } from './breadcrumbLabelView.js';
|
|
28
|
+
|
|
29
|
+
import './augmentation.js';
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The element path: ancestors are muted, the innermost element (where the cursor is) is emphasised. Items are separated
|
|
3
|
+
* by chevrons. Buttons (with a menu) and labels (without one) look the same, except buttons react to hover and focus.
|
|
4
|
+
*/
|
|
5
|
+
.ck.ck-elements-breadcrumbs {
|
|
6
|
+
--ck-elements-breadcrumbs-item-height: 24px;
|
|
7
|
+
--ck-elements-breadcrumbs-separator-size: 0.38em;
|
|
8
|
+
|
|
9
|
+
display: flex;
|
|
10
|
+
align-items: center;
|
|
11
|
+
min-width: 0;
|
|
12
|
+
font-size: var(--ck-font-size-sm);
|
|
13
|
+
color: var(--ck-color-text);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.ck.ck-toolbar .ck.ck-elements-breadcrumbs {
|
|
17
|
+
min-height: var(--ck-size-control-min-height);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.ck.ck-elements-breadcrumbs__list {
|
|
21
|
+
display: flex;
|
|
22
|
+
flex-wrap: wrap;
|
|
23
|
+
align-items: center;
|
|
24
|
+
gap: 2px 0;
|
|
25
|
+
min-width: 0;
|
|
26
|
+
margin: 0;
|
|
27
|
+
padding: 0;
|
|
28
|
+
list-style: none;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.ck.ck-elements-breadcrumbs__list-item {
|
|
32
|
+
display: flex;
|
|
33
|
+
align-items: center;
|
|
34
|
+
min-width: 0;
|
|
35
|
+
margin: 0;
|
|
36
|
+
padding: 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/* Both item kinds: buttons and labels. */
|
|
40
|
+
.ck.ck-elements-breadcrumbs__item,
|
|
41
|
+
.ck.ck-button.ck-elements-breadcrumbs__item {
|
|
42
|
+
display: inline-flex;
|
|
43
|
+
align-items: center;
|
|
44
|
+
min-width: 0;
|
|
45
|
+
min-height: var(--ck-elements-breadcrumbs-item-height);
|
|
46
|
+
height: var(--ck-elements-breadcrumbs-item-height);
|
|
47
|
+
padding: 0 var(--ck-spacing-sm);
|
|
48
|
+
border-radius: var(--ck-border-radius-control);
|
|
49
|
+
font-family: var(--ck-font-family);
|
|
50
|
+
font-size: inherit;
|
|
51
|
+
line-height: 1;
|
|
52
|
+
white-space: nowrap;
|
|
53
|
+
color: color-mix(in srgb, var(--ck-color-text) 65%, transparent);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.ck.ck-button.ck-elements-breadcrumbs__item .ck-button__label {
|
|
57
|
+
font-size: inherit;
|
|
58
|
+
line-height: 1;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* The innermost element: where the cursor is. */
|
|
62
|
+
.ck.ck-elements-breadcrumbs__item[aria-current] {
|
|
63
|
+
color: var(--ck-color-text);
|
|
64
|
+
font-weight: 600;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/* Labels cannot be clicked, but can be focused with the keyboard. */
|
|
68
|
+
.ck.ck-elements-breadcrumbs__label {
|
|
69
|
+
cursor: default;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.ck.ck-elements-breadcrumbs__label:focus {
|
|
73
|
+
outline: none;
|
|
74
|
+
box-shadow:
|
|
75
|
+
0 0 0 1px var(--ck-color-focus-border),
|
|
76
|
+
var(--ck-focus-shadow);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/* Buttons show they can be clicked. */
|
|
80
|
+
.ck.ck-button.ck-elements-breadcrumbs__item:hover,
|
|
81
|
+
.ck.ck-button.ck-elements-breadcrumbs__item:focus,
|
|
82
|
+
.ck.ck-button.ck-elements-breadcrumbs__item.ck-on {
|
|
83
|
+
color: var(--ck-color-text);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/* A chevron between items, in the gap before each item, so it is not part of a button's hover area. */
|
|
87
|
+
.ck.ck-elements-breadcrumbs__list-item + .ck.ck-elements-breadcrumbs__list-item {
|
|
88
|
+
position: relative;
|
|
89
|
+
margin-left: calc(var(--ck-spacing-base) + var(--ck-elements-breadcrumbs-separator-size));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.ck.ck-elements-breadcrumbs__list-item + .ck.ck-elements-breadcrumbs__list-item::before {
|
|
93
|
+
content: '';
|
|
94
|
+
position: absolute;
|
|
95
|
+
top: 50%;
|
|
96
|
+
left: calc(-0.5 * var(--ck-spacing-base) - var(--ck-elements-breadcrumbs-separator-size));
|
|
97
|
+
width: var(--ck-elements-breadcrumbs-separator-size);
|
|
98
|
+
height: var(--ck-elements-breadcrumbs-separator-size);
|
|
99
|
+
border-top: 1.5px solid color-mix(in srgb, var(--ck-color-text) 40%, transparent);
|
|
100
|
+
border-right: 1.5px solid color-mix(in srgb, var(--ck-color-text) 40%, transparent);
|
|
101
|
+
transform: translateY(-50%) rotate(45deg);
|
|
102
|
+
pointer-events: none;
|
|
103
|
+
}
|