ckeditor5-elements-interaction 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.
@@ -0,0 +1,49 @@
1
+ import { getAncestorElements } from 'ckeditor5-elements-core';
2
+ /**
3
+ * Returns the selectable element that the cursor reaches with one step in the direction, as the arrow key moves it, or
4
+ * `null`: the next element is not selectable, or the step stays in the text. The step is CKEditor's: a character in the
5
+ * text, and the boundary between blocks, an inline object or an element at the end of a block. The first selectable
6
+ * element the step enters or passes is the one: the outermost, when it enters nested ones.
7
+ */
8
+ export function findSelectableAfterStep(model, selection, isForward, isSelectable) {
9
+ const probe = model.createSelection(selection);
10
+ model.modifySelection(probe, { direction: isForward ? 'forward' : 'backward' });
11
+ if (!selection.focus || !probe.focus || probe.isEqual(selection)) {
12
+ return null;
13
+ }
14
+ const range = isForward
15
+ ? model.createRange(selection.focus, probe.focus)
16
+ : model.createRange(probe.focus, selection.focus);
17
+ // The elements the step enters or passes: their start tag in the range when going forward, their end tag backward.
18
+ const tokenType = isForward ? 'elementStart' : 'elementEnd';
19
+ for (const { item, type } of range.getWalker({ direction: isForward ? 'forward' : 'backward' })) {
20
+ if (type === tokenType && item.is('element') && isSelectable(item)) {
21
+ return item;
22
+ }
23
+ }
24
+ return null;
25
+ }
26
+ /**
27
+ * Returns the range the selection goes to from the selected element in the direction: the next selectable element
28
+ * (selected as a whole) when it is just there, otherwise the nearest place of the content, or `null` when there is none.
29
+ */
30
+ export function findLeaveTarget(model, element, isForward, isSelectable) {
31
+ const position = model.createPositionAt(element, isForward ? 'after' : 'before');
32
+ const neighbour = isForward ? position.nodeAfter : position.nodeBefore;
33
+ return neighbour?.is('element') && isSelectable(neighbour)
34
+ ? model.createRangeOn(neighbour)
35
+ : model.schema.getNearestSelectionRange(position, isForward ? 'forward' : 'backward');
36
+ }
37
+ /**
38
+ * Returns the closest selectable element around the selection, not the selected one, or `null`.
39
+ */
40
+ export function findSelectableAround(selection, isSelectable) {
41
+ const selected = selection.getSelectedElement();
42
+ for (const element of getAncestorElements(selected ? selected.parent : selection.getFirstPosition()?.parent)) {
43
+ if (isSelectable(element)) {
44
+ return element;
45
+ }
46
+ }
47
+ return null;
48
+ }
49
+ //# sourceMappingURL=navigation.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"navigation.js","sourceRoot":"","sources":["../src/navigation.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,mBAAmB,EAAE,MAAM,yBAAyB,CAAC;AAO9D;;;;;GAKG;AACH,MAAM,UAAU,uBAAuB,CACtC,KAAY,EACZ,SAAkD,EAClD,SAAkB,EAClB,YAA0B;IAE1B,MAAM,KAAK,GAAG,KAAK,CAAC,eAAe,CAAC,SAAS,CAAC,CAAC;IAE/C,KAAK,CAAC,eAAe,CAAC,KAAK,EAAE,EAAE,SAAS,EAAE,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,UAAU,EAAE,CAAC,CAAC;IAEhF,IAAI,CAAC,SAAS,CAAC,KAAK,IAAI,CAAC,KAAK,CAAC,KAAK,IAAI,KAAK,CAAC,OAAO,CAAC,SAAS,CAAC,EAAE,CAAC;QAClE,OAAO,IAAI,CAAC;IACb,CAAC;IAED,MAAM,KAAK,GAAG,SAAS;QACtB,CAAC,CAAC,KAAK,CAAC,WAAW,CAAC,SAAS,CAAC,KAAK,EAAE,KAAK,CAAC,KAAK,CAAC;QACjD,CAAC,CAAC,KAAK,CAAC,WAAW,CAAC,KAAK,CAAC,KAAK,EAAE,SAAS,CAAC,KAAK,CAAC,CAAC;IAEnD,mHAAmH;IACnH,MAAM,SAAS,GAAG,SAAS,CAAC,CAAC,CAAC,cAAc,CAAC,CAAC,CAAC,YAAY,CAAC;IAE5D,KAAK,MAAM,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,KAAK,CAAC,SAAS,CAAC,EAAE,SAAS,EAAE,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,UAAU,EAAE,CAAC,EAAE,CAAC;QACjG,IAAI,IAAI,KAAK,SAAS,IAAI,IAAI,CAAC,EAAE,CAAC,SAAS,CAAC,IAAI,YAAY,CAAC,IAAI,CAAC,EAAE,CAAC;YACpE,OAAO,IAAI,CAAC;QACb,CAAC;IACF,CAAC;IAED,OAAO,IAAI,CAAC;AACb,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,eAAe,CAC9B,KAAY,EACZ,OAAqB,EACrB,SAAkB,EAClB,YAA0B;IAE1B,MAAM,QAAQ,GAAG,KAAK,CAAC,gBAAgB,CAAC,OAAO,EAAE,SAAS,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC;IACjF,MAAM,SAAS,GAAG,SAAS,CAAC,CAAC,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAC,CAAC,QAAQ,CAAC,UAAU,CAAC;IAEvE,OAAO,SAAS,EAAE,EAAE,CAAC,SAAS,CAAC,IAAI,YAAY,CAAC,SAAS,CAAC;QACzD,CAAC,CAAC,KAAK,CAAC,aAAa,CAAC,SAAS,CAAC;QAChC,CAAC,CAAC,KAAK,CAAC,MAAM,CAAC,wBAAwB,CAAC,QAAQ,EAAE,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC;AACxF,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,oBAAoB,CACnC,SAAkD,EAClD,YAA0B;IAE1B,MAAM,QAAQ,GAAG,SAAS,CAAC,kBAAkB,EAAE,CAAC;IAEhD,KAAK,MAAM,OAAO,IAAI,mBAAmB,CAAC,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,SAAS,CAAC,gBAAgB,EAAE,EAAE,MAAM,CAAC,EAAE,CAAC;QAC9G,IAAI,YAAY,CAAC,OAAO,CAAC,EAAE,CAAC;YAC3B,OAAO,OAAO,CAAC;QAChB,CAAC;IACF,CAAC;IAED,OAAO,IAAI,CAAC;AACb,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,52 @@
1
1
  {
2
2
  "name": "ckeditor5-elements-interaction",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "49.0.0",
4
+ "description": "Element interaction for CKEditor 5, without CKEditor's widgets: select an element as a whole by a click or by the arrow keys, and react to clicks and double clicks on elements.",
5
+ "keywords": [
6
+ "ckeditor",
7
+ "ckeditor5",
8
+ "ckeditor 5",
9
+ "ckeditor5-feature",
10
+ "ckeditor5-plugin"
11
+ ],
12
+ "license": "MIT",
13
+ "author": "Ivan Kildyushov",
14
+ "homepage": "https://ivankild.github.io/ckeditor5-elements/guide/selectable",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/ivankild/ckeditor5-elements.git",
18
+ "directory": "packages/ckeditor5-elements-interaction"
19
+ },
20
+ "bugs": {
21
+ "url": "https://github.com/ivankild/ckeditor5-elements/issues"
22
+ },
23
+ "type": "module",
24
+ "main": "./dist/index.js",
25
+ "types": "./dist/index.d.ts",
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.ts",
29
+ "import": "./dist/index.js"
30
+ },
31
+ "./index.css": "./theme/selectable.css",
32
+ "./package.json": "./package.json"
33
+ },
34
+ "files": [
35
+ "dist",
36
+ "src",
37
+ "theme",
38
+ "README.md"
39
+ ],
40
+ "sideEffects": [
41
+ "*.css"
42
+ ],
43
+ "dependencies": {
44
+ "ckeditor5-elements-core": "49.0.0"
45
+ },
46
+ "peerDependencies": {
47
+ "ckeditor5": "^49.0.0"
48
+ },
49
+ "scripts": {
50
+ "build": "tsc --build"
51
+ }
6
52
  }
@@ -0,0 +1,26 @@
1
+ import type {
2
+ ElementClickable,
3
+ ElementClickableDefinition,
4
+ ElementSelectable,
5
+ ElementSelectableMatcher
6
+ } from './index.js';
7
+
8
+ declare module 'ckeditor5' {
9
+ interface PluginsMap {
10
+ [ElementSelectable.pluginName]: ElementSelectable;
11
+ [ElementClickable.pluginName]: ElementClickable;
12
+ }
13
+
14
+ interface EditorConfig {
15
+ /**
16
+ * The elements that can be selected as a whole: names of model elements, or filter functions. See
17
+ * {@link ElementSelectable}.
18
+ */
19
+ elementSelectable?: ElementSelectableMatcher[];
20
+
21
+ /**
22
+ * The elements that react to clicks and double clicks. See {@link ElementClickable}.
23
+ */
24
+ elementClickable?: ElementClickableDefinition[];
25
+ }
26
+ }
@@ -0,0 +1,28 @@
1
+ import { DomEventObserver, type ViewDocumentDomEventData } from 'ckeditor5';
2
+
3
+ /**
4
+ * Fires `dblclick` on the view document when the user double-clicks in the editing view. (CKEditor has an observer for
5
+ * `click`, but not for `dblclick`.)
6
+ */
7
+ export default class DoubleClickObserver extends DomEventObserver<'dblclick'> {
8
+ // The base class declares it as an accessor, so it cannot be a field here.
9
+ // eslint-disable-next-line @typescript-eslint/class-literal-property-style
10
+ public get domEventType(): 'dblclick' {
11
+ return 'dblclick';
12
+ }
13
+
14
+ /**
15
+ * Fires `dblclick` on the view document for the DOM event.
16
+ */
17
+ public onDomEvent(domEvent: MouseEvent): void {
18
+ this.fire(domEvent.type, domEvent);
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Fired on the view document by the {@link DoubleClickObserver}.
24
+ */
25
+ export interface ViewDocumentDoubleClickEvent {
26
+ name: 'dblclick';
27
+ args: [data: ViewDocumentDomEventData<MouseEvent>];
28
+ }
@@ -0,0 +1,117 @@
1
+ import {
2
+ ClickObserver,
3
+ Plugin,
4
+ type Editor,
5
+ type ModelElement,
6
+ type ViewDocumentClickEvent,
7
+ type ViewElement
8
+ } from 'ckeditor5';
9
+ import { getElementsAround, matchesElement, type ElementMatcher } from 'ckeditor5-elements-core';
10
+ import DoubleClickObserver, { type ViewDocumentDoubleClickEvent } from './doubleClickObserver.js';
11
+
12
+ /**
13
+ * The mouse events an element can be made clickable with.
14
+ */
15
+ export type ElementClickableEvent = 'click' | 'dblclick';
16
+
17
+ /**
18
+ * Makes the elements that match `element` react to clicks.
19
+ */
20
+ export interface ElementClickableDefinition {
21
+ /**
22
+ * The elements the definition is for: the name of a model element (e.g. `'imageBlock'`), or a function that returns
23
+ * whether it is for the given element (e.g. `element => element.hasAttribute('linkHref')`).
24
+ */
25
+ element: ElementMatcher;
26
+
27
+ /**
28
+ * The mouse event: `'click'` (the default) or `'dblclick'`.
29
+ */
30
+ event?: ElementClickableEvent;
31
+
32
+ /**
33
+ * Called with the element that was clicked, the editor, and the DOM event.
34
+ */
35
+ callback: (element: ModelElement, editor: Editor, domEvent: MouseEvent) => void;
36
+ }
37
+
38
+ /**
39
+ * Calls a function when an element is clicked or double-clicked, with the model element. Elements are matched by name or
40
+ * by a filter function, and the definitions are the `elementClickable` editor config option (see
41
+ * {@link ElementClickableDefinition}):
42
+ *
43
+ * ```ts
44
+ * elementClickable: [
45
+ * { element: 'imageBlock', callback: (element, editor) => console.log(element.getAttribute('src')) },
46
+ * { element: element => element.name.startsWith('heading'), event: 'dblclick', callback: edit }
47
+ * ];
48
+ * ```
49
+ *
50
+ * The click is matched against the element that was clicked and its ancestors, the innermost first: the first element
51
+ * that matches a definition for the event calls its callback, and the event goes no further. For two definitions that
52
+ * match the same element, the first one in the config wins.
53
+ *
54
+ * It works in read-only mode too. The click still does what it does in the editor, e.g. selects an image: the plugin
55
+ * only reports it, before the other plugins handle it (some of them stop the event), and does not stop it.
56
+ */
57
+ export default class ElementClickable extends Plugin {
58
+ public static get pluginName(): 'ElementClickable' {
59
+ return 'ElementClickable' as const;
60
+ }
61
+
62
+ public init(): void {
63
+ const view = this.editor.editing.view;
64
+
65
+ view.addObserver(ClickObserver);
66
+ view.addObserver(DoubleClickObserver);
67
+
68
+ // With the highest priority, before other plugins: some stop the click, e.g. CKEditor's link handling stops the
69
+ // click on a link, with a high priority. The plugin reports the click and does not stop it.
70
+ this.listenTo<ViewDocumentClickEvent>(
71
+ view.document,
72
+ 'click',
73
+ (evt, data) => {
74
+ this._handle('click', data.target, data.domEvent);
75
+ },
76
+ { priority: 'highest' }
77
+ );
78
+ this.listenTo<ViewDocumentDoubleClickEvent>(
79
+ view.document,
80
+ 'dblclick',
81
+ (evt, data) => {
82
+ this._handle('dblclick', data.target, data.domEvent);
83
+ },
84
+ { priority: 'highest' }
85
+ );
86
+ }
87
+
88
+ /**
89
+ * Returns the definition and the element that a click on the view element is for: the innermost element, among the
90
+ * view element and its ancestors, that matches a definition for the event. `null` when there is none.
91
+ */
92
+ public find(
93
+ viewElement: ViewElement,
94
+ event: ElementClickableEvent = 'click'
95
+ ): { element: ModelElement; definition: ElementClickableDefinition } | null {
96
+ const mapper = this.editor.editing.mapper;
97
+ const definitions = (this.editor.config.get('elementClickable') ?? []).filter(
98
+ definition => (definition.event ?? 'click') === event
99
+ );
100
+
101
+ for (const { element } of getElementsAround(mapper, viewElement)) {
102
+ const definition = definitions.find(({ element: matcher }) => matchesElement(matcher, element));
103
+
104
+ if (definition) {
105
+ return { element, definition };
106
+ }
107
+ }
108
+
109
+ return null;
110
+ }
111
+
112
+ private _handle(event: ElementClickableEvent, target: ViewElement, domEvent: MouseEvent): void {
113
+ const found = this.isEnabled ? this.find(target, event) : null;
114
+
115
+ found?.definition.callback(found.element, this.editor, domEvent);
116
+ }
117
+ }
@@ -0,0 +1,274 @@
1
+ import {
2
+ MouseObserver,
3
+ Plugin,
4
+ keyCodes,
5
+ isArrowKeyCode,
6
+ isForwardArrowKeyCode,
7
+ type DowncastSelectionEvent,
8
+ type ModelElement,
9
+ type ViewDocumentKeyDownEvent,
10
+ type ViewDocumentMouseDownEvent,
11
+ type ViewDowncastWriter,
12
+ type ViewElement
13
+ } from 'ckeditor5';
14
+ import { getElementsAround, keyboardPriorities, matchesElement, type ElementMatcher } from 'ckeditor5-elements-core';
15
+ import { findLeaveTarget, findSelectableAfterStep, findSelectableAround } from './navigation.js';
16
+
17
+ /**
18
+ * Matches the elements that can be selected: the name of a model element (e.g. `'callout'`), or a function that returns
19
+ * whether it is for the given element (e.g. `element => element.hasAttribute('card')`).
20
+ */
21
+ export type ElementSelectableMatcher = ElementMatcher;
22
+
23
+ /**
24
+ * The class of the selected element in the editing view.
25
+ */
26
+ const SELECTED_CLASS = 'ck-elements-selected';
27
+
28
+ /**
29
+ * Makes elements selectable as a whole, which is what CKEditor's widgets do, without them: it does not change the view
30
+ * of the element, and does not need the `Widget` plugin. The elements are matched by name or by a filter function, and
31
+ * they are the `elementSelectable` editor config option:
32
+ *
33
+ * ```ts
34
+ * elementSelectable: ['callout', 'divider', element => element.hasAttribute('card')];
35
+ * ```
36
+ *
37
+ * An element is selected (the model selection is *on* it, and the view has a fake selection and the
38
+ * `ck-elements-selected` class):
39
+ *
40
+ * * **By a click** on the element. An element that has content, e.g. a container, is selected by a click on the element
41
+ * itself, not on its content: a click in its text puts the cursor there, as it does without the plugin. An element
42
+ * with no content (an object) is selected by a click anywhere in it.
43
+ * * **By the left and right arrow keys**: when the next step of the cursor enters or passes a selectable element (at the
44
+ * end of a block, or before an inline element), the key selects it instead. With an element selected, the arrow keys
45
+ * move the cursor to the content after (right, down) or before (left, up) it, or select the next selectable element,
46
+ * if it is just there.
47
+ * * **By the Esc key**: with the cursor in an element, it selects the closest selectable one around the cursor, unless
48
+ * something else handles Esc first, e.g. closes a balloon.
49
+ *
50
+ * A selected element is deleted, replaced by typing and copied, as any selected content.
51
+ *
52
+ * Names are made selectable in the model schema (`isSelectable`), so the selection on them is kept: a filter cannot,
53
+ * so the elements it matches must be selectable already (e.g. objects). Features can add elements with
54
+ * {@link ElementSelectable#add}.
55
+ */
56
+ export default class ElementSelectable extends Plugin {
57
+ private readonly _matchers: ElementSelectableMatcher[] = [];
58
+ private _selected: ViewElement | null = null;
59
+
60
+ public static get pluginName(): 'ElementSelectable' {
61
+ return 'ElementSelectable' as const;
62
+ }
63
+
64
+ public init(): void {
65
+ const editor = this.editor;
66
+ const view = editor.editing.view;
67
+ const viewDocument = view.document;
68
+
69
+ this._matchers.push(...(editor.config.get('elementSelectable') ?? []));
70
+
71
+ // The selection of an element is a fake one, as for widgets: the browser does not draw the selection on its own.
72
+ editor.editing.downcastDispatcher.on<DowncastSelectionEvent>('selection', (evt, data, conversionApi) => {
73
+ const element = data.selection.getSelectedElement();
74
+ const viewElement =
75
+ element && this.isSelectable(element) ? editor.editing.mapper.toViewElement(element) : null;
76
+
77
+ if (element && viewElement && conversionApi.consumable.consume(data.selection, 'selection')) {
78
+ conversionApi.writer.setSelection(conversionApi.writer.createRangeOn(viewElement), {
79
+ fake: true,
80
+ label: `<${element.name}>`
81
+ });
82
+ }
83
+ });
84
+ editor.editing.downcastDispatcher.on<DowncastSelectionEvent>(
85
+ 'selection',
86
+ (evt, data, conversionApi) => {
87
+ this._mark(conversionApi.writer, viewDocument.selection.getSelectedElement());
88
+ },
89
+ { priority: 'low' }
90
+ );
91
+
92
+ view.addObserver(MouseObserver);
93
+ this.listenTo<ViewDocumentMouseDownEvent>(viewDocument, 'mousedown', (evt, data) => {
94
+ const element = this.isEnabled ? this._findToSelect(data.target) : null;
95
+
96
+ if (element) {
97
+ // The browser would put the cursor where the click is, and move the focus.
98
+ data.preventDefault();
99
+ view.focus();
100
+ this._select(element);
101
+ }
102
+ });
103
+
104
+ // The arrow keys are handled on `keydown`, before CKEditor's widgets, and not as the `arrowKey` event: that one
105
+ // bubbles from the selection, and CKEditor's widgets (loaded by other features, e.g. the clipboard) handle it for
106
+ // every object, in a context that is reached before this plugin could.
107
+ this.listenTo<ViewDocumentKeyDownEvent>(
108
+ viewDocument,
109
+ 'keydown',
110
+ (evt, data) => {
111
+ const isPlain = !data.shiftKey && !data.ctrlKey && !data.altKey && !data.metaKey;
112
+
113
+ if (
114
+ this.isEnabled &&
115
+ isPlain &&
116
+ isArrowKeyCode(data.keyCode) &&
117
+ this._handleArrowKey(
118
+ data.keyCode,
119
+ isForwardArrowKeyCode(data.keyCode, editor.locale.contentLanguageDirection)
120
+ )
121
+ ) {
122
+ data.preventDefault();
123
+ evt.stop();
124
+ }
125
+ },
126
+ { priority: keyboardPriorities.selectable }
127
+ );
128
+
129
+ // Esc goes after what else uses it, e.g. closing a balloon. Editor keystrokes (`cancel()`)
130
+ // do not stop this event, they prevent the default action of the DOM event: then Esc is handled already.
131
+ this.listenTo<ViewDocumentKeyDownEvent>(
132
+ viewDocument,
133
+ 'keydown',
134
+ (evt, data) => {
135
+ const isFree = data.keystroke === keyCodes.esc && !data.domEvent.defaultPrevented;
136
+ const element = this.isEnabled && isFree ? this._findAround() : null;
137
+
138
+ if (element) {
139
+ this._select(element);
140
+ data.preventDefault();
141
+ evt.stop();
142
+ }
143
+ },
144
+ { priority: keyboardPriorities.selectableEsc }
145
+ );
146
+ }
147
+
148
+ public afterInit(): void {
149
+ // Before the data is loaded, once all the elements are registered: also the ones that other plugins register in
150
+ // `afterInit()`, e.g. the element schema, whichever plugin comes first.
151
+ this.listenTo(
152
+ this.editor.data,
153
+ 'init',
154
+ () => {
155
+ this._makeSelectable(this._matchers);
156
+ },
157
+ { priority: 'high' }
158
+ );
159
+ }
160
+
161
+ /**
162
+ * Makes more elements selectable, as the `elementSelectable` option does: a name of a model element, or a filter.
163
+ * Used by features that add elements, e.g. the element schema.
164
+ */
165
+ public add(...matchers: ElementSelectableMatcher[]): void {
166
+ this._matchers.push(...matchers);
167
+ this._makeSelectable(matchers);
168
+ }
169
+
170
+ /**
171
+ * Returns whether the element can be selected by this plugin: it matches a name or a filter.
172
+ */
173
+ public isSelectable(element: ModelElement): boolean {
174
+ return !element.is('rootElement') && this._matchers.some(matcher => matchesElement(matcher, element));
175
+ }
176
+
177
+ /**
178
+ * Selects the element as a whole.
179
+ */
180
+ private _select(element: ModelElement): void {
181
+ this.editor.model.change(writer => {
182
+ writer.setSelection(element, 'on');
183
+ });
184
+ }
185
+
186
+ /**
187
+ * Makes the model elements named in the matchers selectable in the schema, for the ones that are registered.
188
+ */
189
+ private _makeSelectable(matchers: readonly ElementSelectableMatcher[]): void {
190
+ const schema = this.editor.model.schema;
191
+
192
+ for (const matcher of matchers) {
193
+ if (typeof matcher === 'string' && schema.isRegistered(matcher) && !schema.isSelectable(matcher)) {
194
+ schema.extend(matcher, { isSelectable: true });
195
+ }
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Returns the element a mouse down on the view element selects: the closest selectable element around it (or it), if
201
+ * the click is on the element itself, or the element has no content.
202
+ */
203
+ private _findToSelect(target: ViewElement): ModelElement | null {
204
+ for (const { element, viewElement } of getElementsAround(this.editor.editing.mapper, target)) {
205
+ if (this.isSelectable(element) && (viewElement === target || element.isEmpty)) {
206
+ return element;
207
+ }
208
+ }
209
+
210
+ return null;
211
+ }
212
+
213
+ /**
214
+ * Handles an arrow key. Returns whether the key moved the selection, and the browser must not.
215
+ */
216
+ private _handleArrowKey(keyCode: number, isForward: boolean): boolean {
217
+ const model = this.editor.model;
218
+ const selection = model.document.selection;
219
+ const selected = selection.getSelectedElement();
220
+ const isSelectable = (element: ModelElement): boolean => this.isSelectable(element);
221
+
222
+ if (selected && this.isSelectable(selected)) {
223
+ const target = findLeaveTarget(model, selected, isForward, isSelectable);
224
+
225
+ if (target) {
226
+ model.change(writer => {
227
+ writer.setSelection(target);
228
+ });
229
+ }
230
+
231
+ // Also when there is nowhere to go: the browser would move the cursor into the fake selection.
232
+ return true;
233
+ }
234
+
235
+ // The up and down keys move in the lines of the text: only the browser knows where.
236
+ const isHorizontal = keyCode === keyCodes.arrowleft || keyCode === keyCodes.arrowright;
237
+ const next =
238
+ isHorizontal && selection.isCollapsed
239
+ ? findSelectableAfterStep(model, selection, isForward, isSelectable)
240
+ : null;
241
+
242
+ if (next) {
243
+ this._select(next);
244
+ }
245
+
246
+ return !!next;
247
+ }
248
+
249
+ /**
250
+ * Returns the closest selectable element around the selection, not the selected one.
251
+ */
252
+ private _findAround(): ModelElement | null {
253
+ return findSelectableAround(this.editor.model.document.selection, element => this.isSelectable(element));
254
+ }
255
+
256
+ /**
257
+ * Marks the selected view element with the class, and unmarks the one that was selected.
258
+ */
259
+ private _mark(writer: ViewDowncastWriter, selected: ViewElement | null): void {
260
+ const mapper = this.editor.editing.mapper;
261
+ const element = selected && mapper.toModelElement(selected);
262
+ const marked = element && this.isSelectable(element) ? selected : null;
263
+
264
+ if (this._selected && this._selected !== marked) {
265
+ writer.removeClass(SELECTED_CLASS, this._selected);
266
+ }
267
+
268
+ if (marked) {
269
+ writer.addClass(SELECTED_CLASS, marked);
270
+ }
271
+
272
+ this._selected = marked;
273
+ }
274
+ }
package/src/index.ts ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Element interaction, without CKEditor's widgets: select an element as a whole by a click or by the arrow keys, and call
3
+ * a function, with the model element, when an element that matches a name or a filter is clicked or double-clicked.
4
+ *
5
+ * @module ckeditor5-elements-interaction
6
+ */
7
+
8
+ // Plugins.
9
+ export { default as ElementSelectable, type ElementSelectableMatcher } from './elementSelectable.js';
10
+ export {
11
+ default as ElementClickable,
12
+ type ElementClickableDefinition,
13
+ type ElementClickableEvent
14
+ } from './elementClickable.js';
15
+
16
+ // Observers.
17
+ export { default as DoubleClickObserver, type ViewDocumentDoubleClickEvent } from './doubleClickObserver.js';
18
+
19
+ import './augmentation.js';
@@ -0,0 +1,79 @@
1
+ import type { Model, ModelDocumentSelection, ModelElement, ModelRange, ModelSelection } from 'ckeditor5';
2
+ import { getAncestorElements } from 'ckeditor5-elements-core';
3
+
4
+ /**
5
+ * Whether an element can be selected as a whole.
6
+ */
7
+ export type IsSelectable = (element: ModelElement) => boolean;
8
+
9
+ /**
10
+ * Returns the selectable element that the cursor reaches with one step in the direction, as the arrow key moves it, or
11
+ * `null`: the next element is not selectable, or the step stays in the text. The step is CKEditor's: a character in the
12
+ * text, and the boundary between blocks, an inline object or an element at the end of a block. The first selectable
13
+ * element the step enters or passes is the one: the outermost, when it enters nested ones.
14
+ */
15
+ export function findSelectableAfterStep(
16
+ model: Model,
17
+ selection: ModelSelection | ModelDocumentSelection,
18
+ isForward: boolean,
19
+ isSelectable: IsSelectable
20
+ ): ModelElement | null {
21
+ const probe = model.createSelection(selection);
22
+
23
+ model.modifySelection(probe, { direction: isForward ? 'forward' : 'backward' });
24
+
25
+ if (!selection.focus || !probe.focus || probe.isEqual(selection)) {
26
+ return null;
27
+ }
28
+
29
+ const range = isForward
30
+ ? model.createRange(selection.focus, probe.focus)
31
+ : model.createRange(probe.focus, selection.focus);
32
+
33
+ // The elements the step enters or passes: their start tag in the range when going forward, their end tag backward.
34
+ const tokenType = isForward ? 'elementStart' : 'elementEnd';
35
+
36
+ for (const { item, type } of range.getWalker({ direction: isForward ? 'forward' : 'backward' })) {
37
+ if (type === tokenType && item.is('element') && isSelectable(item)) {
38
+ return item;
39
+ }
40
+ }
41
+
42
+ return null;
43
+ }
44
+
45
+ /**
46
+ * Returns the range the selection goes to from the selected element in the direction: the next selectable element
47
+ * (selected as a whole) when it is just there, otherwise the nearest place of the content, or `null` when there is none.
48
+ */
49
+ export function findLeaveTarget(
50
+ model: Model,
51
+ element: ModelElement,
52
+ isForward: boolean,
53
+ isSelectable: IsSelectable
54
+ ): ModelRange | null {
55
+ const position = model.createPositionAt(element, isForward ? 'after' : 'before');
56
+ const neighbour = isForward ? position.nodeAfter : position.nodeBefore;
57
+
58
+ return neighbour?.is('element') && isSelectable(neighbour)
59
+ ? model.createRangeOn(neighbour)
60
+ : model.schema.getNearestSelectionRange(position, isForward ? 'forward' : 'backward');
61
+ }
62
+
63
+ /**
64
+ * Returns the closest selectable element around the selection, not the selected one, or `null`.
65
+ */
66
+ export function findSelectableAround(
67
+ selection: ModelSelection | ModelDocumentSelection,
68
+ isSelectable: IsSelectable
69
+ ): ModelElement | null {
70
+ const selected = selection.getSelectedElement();
71
+
72
+ for (const element of getAncestorElements(selected ? selected.parent : selection.getFirstPosition()?.parent)) {
73
+ if (isSelectable(element)) {
74
+ return element;
75
+ }
76
+ }
77
+
78
+ return null;
79
+ }
@@ -0,0 +1,8 @@
1
+ /*
2
+ * A selected element: the editing view marks it with this class, and the selection is a fake one, so the browser draws
3
+ * nothing on the element itself.
4
+ */
5
+ .ck-content .ck-elements-selected {
6
+ outline: 2px solid var(--ck-color-focus-border);
7
+ outline-offset: 1px;
8
+ }