@kubex/zinc 1.1.43 → 1.1.44
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/dist/custom-elements.json +98 -74
- package/dist/vscode.html-custom-data.json +11 -11
- package/dist/web-types.json +23 -23
- package/dist/zn.d.ts +36 -0
- package/dist/zn.min.js +513 -514
- package/package.json +1 -1
- package/src/components/editor/editor.component.ts +50 -0
- package/src/components/editor/modules/context-menu/context-menu-tool.test.ts +83 -0
- package/src/components/editor/modules/context-menu/context-menu.ts +14 -1
- package/src/components/editor/modules/dialog/dialog.component.ts +33 -9
- package/src/components/editor/modules/events/zn-dialog-header.ts +13 -0
- package/src/components/editor/modules/events/zn-editor-insert.ts +11 -0
- package/src/components/editor/modules/toolbar/tool/tool.component.ts +4 -1
- package/src/components/editor/modules/toolbar/toolbar.ts +25 -1
package/package.json
CHANGED
|
@@ -21,6 +21,7 @@ import ZnTextarea from "../textarea";
|
|
|
21
21
|
import type {OnEvent} from "../../utilities/on";
|
|
22
22
|
import type {Range} from "quill";
|
|
23
23
|
import type {ZincFormControl} from '../../internal/zinc-element';
|
|
24
|
+
import type {ZnEditorInsertEvent} from "./modules/events/zn-editor-insert";
|
|
24
25
|
import type ToolbarComponent from "./modules/toolbar/toolbar.component";
|
|
25
26
|
|
|
26
27
|
import styles from './editor.scss';
|
|
@@ -281,6 +282,9 @@ export default class ZnEditor extends ZincElement implements ZincFormControl {
|
|
|
281
282
|
|
|
282
283
|
this.quillElement = quill;
|
|
283
284
|
|
|
285
|
+
const dialogModule = quill.getModule('dialog') as Dialog | undefined;
|
|
286
|
+
dialogModule?.component?.addEventListener('zn-editor-insert', this._handleEditorInsert);
|
|
287
|
+
|
|
284
288
|
this.getForm()?.addEventListener('submit', () => {
|
|
285
289
|
this._clearCachedContent();
|
|
286
290
|
setTimeout(() => {
|
|
@@ -509,6 +513,52 @@ export default class ZnEditor extends ZincElement implements ZincFormControl {
|
|
|
509
513
|
}
|
|
510
514
|
}
|
|
511
515
|
|
|
516
|
+
private _handleEditorInsert = (e: ZnEditorInsertEvent) => {
|
|
517
|
+
const detail: Partial<ZnEditorInsertEvent['detail']> = e.detail ?? {};
|
|
518
|
+
const mode = detail.mode;
|
|
519
|
+
if (mode !== 'insert' && mode !== 'replace') return;
|
|
520
|
+
|
|
521
|
+
e.stopPropagation();
|
|
522
|
+
|
|
523
|
+
if (detail.html) {
|
|
524
|
+
this._insertHtml(mode, detail.html);
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (detail.text) {
|
|
529
|
+
this._content = detail.text.trim();
|
|
530
|
+
if (mode === 'replace') {
|
|
531
|
+
this._replaceTextAtSelection();
|
|
532
|
+
} else {
|
|
533
|
+
this._insertTextAtSelection();
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
private _insertHtml(mode: 'insert' | 'replace', content: string) {
|
|
539
|
+
const quill = this.quillElement;
|
|
540
|
+
const range = quill.getSelection();
|
|
541
|
+
|
|
542
|
+
let index: number;
|
|
543
|
+
if (mode === 'replace') {
|
|
544
|
+
if (range && range.length > 0) {
|
|
545
|
+
quill.deleteText(range.index, range.length);
|
|
546
|
+
index = range.index;
|
|
547
|
+
} else {
|
|
548
|
+
quill.setText('');
|
|
549
|
+
index = 0;
|
|
550
|
+
}
|
|
551
|
+
} else {
|
|
552
|
+
index = range ? range.index : this._selectionRange.index;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const lengthBefore = quill.getLength();
|
|
556
|
+
quill.clipboard.dangerouslyPasteHTML(index, content, Quill.sources.USER);
|
|
557
|
+
quill.setSelection(index + Math.max(0, quill.getLength() - lengthBefore), 0);
|
|
558
|
+
|
|
559
|
+
this._closePopups();
|
|
560
|
+
}
|
|
561
|
+
|
|
512
562
|
private _replaceTextAtSelection() {
|
|
513
563
|
const range = this.quillElement.getSelection();
|
|
514
564
|
if (range) {
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import '../../../../../dist/zn.min.js';
|
|
2
|
+
import {aTimeout, expect, fixture, html} from '@open-wc/testing';
|
|
3
|
+
import type ZnEditor from '../../editor.component';
|
|
4
|
+
|
|
5
|
+
interface QuillLike {
|
|
6
|
+
focus: () => void;
|
|
7
|
+
insertText: (index: number, text: string, source: string) => void;
|
|
8
|
+
setSelection: (index: number, length: number, source: string) => void;
|
|
9
|
+
root: HTMLElement;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface ContextMenuLike extends HTMLElement {
|
|
13
|
+
open: boolean;
|
|
14
|
+
results: { label: string }[];
|
|
15
|
+
setActiveIndex: (index: number) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface EditorDialogLike extends HTMLElement {
|
|
19
|
+
dialogEl: HTMLDialogElement;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('<zn-editor> context menu tools', () => {
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
document.querySelectorAll('zn-context-menu, zn-editor-dialog').forEach(el => el.remove());
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const editorWithTool = () => fixture<ZnEditor>(html`
|
|
28
|
+
<zn-editor id="test-editor">
|
|
29
|
+
<zn-editor-tool uri="/canned"
|
|
30
|
+
label="Canned Responses"
|
|
31
|
+
icon="messages-square@lu"
|
|
32
|
+
key="canned-responses"
|
|
33
|
+
context-menu
|
|
34
|
+
slot="tools"></zn-editor-tool>
|
|
35
|
+
</zn-editor>`);
|
|
36
|
+
|
|
37
|
+
const openContextMenu = async (el: ZnEditor): Promise<QuillLike> => {
|
|
38
|
+
const quill = (el as unknown as { quillElement: QuillLike }).quillElement;
|
|
39
|
+
quill.focus();
|
|
40
|
+
quill.insertText(0, '/', 'user');
|
|
41
|
+
quill.setSelection(1, 0, 'user');
|
|
42
|
+
await aTimeout(50);
|
|
43
|
+
return quill;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const contextMenu = (): ContextMenuLike =>
|
|
47
|
+
document.querySelector('zn-context-menu') as unknown as ContextMenuLike;
|
|
48
|
+
|
|
49
|
+
it('lists a context-menu flagged tool in the slash menu', async () => {
|
|
50
|
+
const el = await editorWithTool();
|
|
51
|
+
await openContextMenu(el);
|
|
52
|
+
|
|
53
|
+
const menu = contextMenu();
|
|
54
|
+
expect(menu, 'context menu should exist').to.exist;
|
|
55
|
+
expect(menu.open, 'context menu should be open').to.be.true;
|
|
56
|
+
|
|
57
|
+
const labels = menu.results.map(r => r.label);
|
|
58
|
+
expect(labels).to.include('Canned Responses');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('opens the tool dialog when the slash menu entry is activated with Enter', async () => {
|
|
62
|
+
const el = await editorWithTool();
|
|
63
|
+
const quill = await openContextMenu(el);
|
|
64
|
+
|
|
65
|
+
const menu = contextMenu();
|
|
66
|
+
const index = menu.results.findIndex(r => r.label === 'Canned Responses');
|
|
67
|
+
expect(index).to.be.greaterThan(-1);
|
|
68
|
+
menu.setActiveIndex(index);
|
|
69
|
+
|
|
70
|
+
quill.root.dispatchEvent(new KeyboardEvent('keydown', {
|
|
71
|
+
key: 'Enter',
|
|
72
|
+
bubbles: true,
|
|
73
|
+
composed: true,
|
|
74
|
+
cancelable: true,
|
|
75
|
+
}));
|
|
76
|
+
await aTimeout(100);
|
|
77
|
+
|
|
78
|
+
const dialog = document.querySelector('zn-editor-dialog') as unknown as EditorDialogLike;
|
|
79
|
+
expect(dialog, 'editor dialog should exist').to.exist;
|
|
80
|
+
expect(dialog.dialogEl.open, 'dialog should be open').to.be.true;
|
|
81
|
+
expect(dialog.innerHTML).to.contain('/canned');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -2,9 +2,10 @@ import './context-menu-component';
|
|
|
2
2
|
import {html} from "lit";
|
|
3
3
|
import {litToHTML} from "../../../../utilities/lit-to-html";
|
|
4
4
|
import {type ResultItem} from "./context-menu-component";
|
|
5
|
-
import Quill from "quill";
|
|
6
5
|
import Delta from "quill-delta";
|
|
6
|
+
import Quill from "quill";
|
|
7
7
|
import ZnEditorQuickAction from "./quick-action";
|
|
8
|
+
import ZnEditorTool from "../toolbar/tool";
|
|
8
9
|
import type {EditorFeatureConfig} from "../../editor.component";
|
|
9
10
|
import type ContextMenuComponent from "./context-menu-component";
|
|
10
11
|
import type Toolbar from "../toolbar/toolbar";
|
|
@@ -258,6 +259,18 @@ class ContextMenu {
|
|
|
258
259
|
}
|
|
259
260
|
}
|
|
260
261
|
});
|
|
262
|
+
|
|
263
|
+
const toolSlot = root.querySelector('slot[name="tools"]') as HTMLSlotElement | null;
|
|
264
|
+
const tools = toolSlot ? toolSlot.assignedElements({flatten: true}) : [];
|
|
265
|
+
tools.forEach((tool: Element) => {
|
|
266
|
+
if (!(tool instanceof ZnEditorTool) || !tool.contextMenu) return;
|
|
267
|
+
|
|
268
|
+
const {label, icon, key} = tool;
|
|
269
|
+
if (!label || !icon || !key) return;
|
|
270
|
+
|
|
271
|
+
const order = typeof tool.order === 'number' ? tool.order : orderCounter++;
|
|
272
|
+
options.push({icon, label, format: 'toolbar', key: key, order});
|
|
273
|
+
});
|
|
261
274
|
}
|
|
262
275
|
|
|
263
276
|
// 2) Built-in Actions (In order they should appear)
|
|
@@ -11,6 +11,11 @@ export default class DialogComponent extends ZincElement {
|
|
|
11
11
|
|
|
12
12
|
@state() private hasFocus = false;
|
|
13
13
|
|
|
14
|
+
/** Set when loaded content declares it renders its own header (including a
|
|
15
|
+
* [dialog-closer] control) via a composed zn-dialog-header event, replacing
|
|
16
|
+
* the floating chrome close button. */
|
|
17
|
+
@state() private hasContentHeader = false;
|
|
18
|
+
|
|
14
19
|
@query('dialog') dialogEl!: HTMLDialogElement;
|
|
15
20
|
|
|
16
21
|
@property({type: Boolean, reflect: true}) open = false;
|
|
@@ -33,9 +38,29 @@ export default class DialogComponent extends ZincElement {
|
|
|
33
38
|
this.dialogEl.addEventListener("close", () => {
|
|
34
39
|
this.open = false;
|
|
35
40
|
this.innerHTML = '';
|
|
41
|
+
this.hasContentHeader = false;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Content is nested inside the app-space's shadow tree, so both contracts
|
|
45
|
+
// are composed: clicks on [dialog-closer] close the dialog (same contract
|
|
46
|
+
// as zn-dialog), and zn-dialog-header swaps the chrome closer for one the
|
|
47
|
+
// content lays out itself.
|
|
48
|
+
this.addEventListener('click', this.handleContentCloserClick);
|
|
49
|
+
this.addEventListener('zn-dialog-header', () => {
|
|
50
|
+
this.hasContentHeader = true;
|
|
36
51
|
});
|
|
37
52
|
}
|
|
38
53
|
|
|
54
|
+
private handleContentCloserClick = (e: Event) => {
|
|
55
|
+
for (const node of e.composedPath()) {
|
|
56
|
+
if (node === this) return;
|
|
57
|
+
if (node instanceof HTMLElement && node.hasAttribute('dialog-closer')) {
|
|
58
|
+
this.dialogEl.close();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
39
64
|
setContent(content: string) {
|
|
40
65
|
this.innerHTML = content;
|
|
41
66
|
}
|
|
@@ -94,15 +119,14 @@ export default class DialogComponent extends ZincElement {
|
|
|
94
119
|
'editor-dialog--has-focus': this.hasFocus,
|
|
95
120
|
})}"
|
|
96
121
|
context-data=${JSON.stringify({'editor-id': this._editorId})}>
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
></zn-button>
|
|
122
|
+
${this.hasContentHeader ? nothing : html`
|
|
123
|
+
<zn-button
|
|
124
|
+
class="editor-dialog__close"
|
|
125
|
+
icon="x@lu"
|
|
126
|
+
icon-button="small"
|
|
127
|
+
icon-size="20"
|
|
128
|
+
@click="${() => this.dialogEl.close()}"
|
|
129
|
+
></zn-button>`}
|
|
106
130
|
<div class="editor-dialog__content">
|
|
107
131
|
<slot>${this._getLoadingState()}</slot>
|
|
108
132
|
</div>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dispatched (composed, bubbling) by editor-dialog content that renders its
|
|
3
|
+
* own header row, including a [dialog-closer] control. The dialog responds by
|
|
4
|
+
* removing its floating chrome close button so content doesn't need to
|
|
5
|
+
* reserve space for it.
|
|
6
|
+
*/
|
|
7
|
+
export type ZnDialogHeaderEvent = CustomEvent<void>;
|
|
8
|
+
|
|
9
|
+
declare global {
|
|
10
|
+
interface GlobalEventHandlersEventMap {
|
|
11
|
+
'zn-dialog-header': ZnDialogHeaderEvent;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -9,12 +9,15 @@ export default class ZnEditorTool extends ZincElement {
|
|
|
9
9
|
@property() icon: string;
|
|
10
10
|
@property() handler: string = 'dialog';
|
|
11
11
|
|
|
12
|
+
@property({type: Boolean, attribute: 'context-menu', reflect: true}) contextMenu = false;
|
|
13
|
+
@property({type: Number}) order?: number | null;
|
|
14
|
+
|
|
12
15
|
render() {
|
|
13
16
|
return html`
|
|
14
17
|
<zn-button class="tool-action"
|
|
15
18
|
color="transparent"
|
|
16
19
|
icon="${this.icon}"
|
|
17
|
-
icon-size="
|
|
20
|
+
icon-size="20"
|
|
18
21
|
data-format="${this.handler}"
|
|
19
22
|
data-format-type="${this.uri}"
|
|
20
23
|
data-toolbar-key="${this.key}"
|
|
@@ -98,8 +98,20 @@ class Toolbar extends QuillToolbar {
|
|
|
98
98
|
formatter => formatter.getAttribute('data-toolbar-key') === key && formatter.tagName === 'ZN-BUTTON'
|
|
99
99
|
);
|
|
100
100
|
|
|
101
|
-
|
|
101
|
+
// _formatters is collected once at init, before slotted zn-editor-tool
|
|
102
|
+
// elements have necessarily rendered their shadow roots — resolve slotted
|
|
103
|
+
// tools again at trigger time so context-menu activation finds them.
|
|
104
|
+
const tool = (matches?.length ? (matches[0] as HTMLElement) : null)
|
|
105
|
+
?? this._slottedToolButton(key);
|
|
102
106
|
if (tool) {
|
|
107
|
+
// Tool buttons live in the tool's own shadow root; a synthetic click
|
|
108
|
+
// doesn't propagate across the slot boundary to the toolbar's click
|
|
109
|
+
// listener, so invoke the format directly.
|
|
110
|
+
const format = tool.getAttribute('data-format');
|
|
111
|
+
if (format) {
|
|
112
|
+
this.callFormat(format, tool.getAttribute('data-format-type') ?? undefined);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
103
115
|
tool.click();
|
|
104
116
|
return;
|
|
105
117
|
}
|
|
@@ -115,6 +127,18 @@ class Toolbar extends QuillToolbar {
|
|
|
115
127
|
}
|
|
116
128
|
}
|
|
117
129
|
|
|
130
|
+
private _slottedToolButton(key: string): HTMLElement | null {
|
|
131
|
+
const slot = this._component.shadowRoot?.querySelector('slot');
|
|
132
|
+
const assigned = slot ? slot.assignedElements({flatten: true}) : [];
|
|
133
|
+
for (const element of assigned) {
|
|
134
|
+
const button = element.shadowRoot?.querySelector(`[data-toolbar-key="${key}"]`);
|
|
135
|
+
if (button instanceof HTMLElement) {
|
|
136
|
+
return button;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
118
142
|
private _attachToolbarHandlers() {
|
|
119
143
|
const shadowRoot = this._component.shadowRoot;
|
|
120
144
|
const shadowFormatters = shadowRoot?.querySelectorAll('[data-format]') ?? [];
|