@kubex/zinc 1.1.42 → 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 +102 -77
- package/dist/vscode.html-custom-data.json +12 -12
- package/dist/web-types.json +25 -25
- package/dist/zn.d.ts +42 -1
- package/dist/zn.min.js +513 -514
- package/package.json +1 -1
- package/src/components/chat-message-attachment/chat-message-attachment.component.ts +7 -2
- package/src/components/chat-message-attachment/chat-message-attachment.test.ts +14 -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 +26 -2
package/package.json
CHANGED
|
@@ -37,8 +37,13 @@ export default class ZnChatMessageAttachment extends ZincElement {
|
|
|
37
37
|
/** The leading icon name. */
|
|
38
38
|
@property() icon: string = 'paperclip@lu';
|
|
39
39
|
|
|
40
|
-
/**
|
|
41
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Where to open the link. Defaults to a new tab. Reflected so the console's
|
|
42
|
+
* pagelet link interception (`[href]:not([target])`) skips the host and the
|
|
43
|
+
* browser handles the click natively (download / new tab) instead of
|
|
44
|
+
* loading the file URL as a pagelet.
|
|
45
|
+
*/
|
|
46
|
+
@property({reflect: true}) target: string = '_blank';
|
|
42
47
|
|
|
43
48
|
/** Prompt a download rather than navigating to the link. */
|
|
44
49
|
@property({type: Boolean}) download = false;
|
|
@@ -28,11 +28,24 @@ describe('<zn-chat-message-attachment>', () => {
|
|
|
28
28
|
<zn-chat-message-attachment href="#" name="file.pdf"></zn-chat-message-attachment>
|
|
29
29
|
</zn-chat-message>`);
|
|
30
30
|
|
|
31
|
-
const row = el.shadowRoot!.querySelector('.message__attachments')
|
|
31
|
+
const row = el.shadowRoot!.querySelector('.message__attachments')!;
|
|
32
32
|
// The row is display:none until something is slotted in; a slotted attachment must reveal it.
|
|
33
33
|
expect(getComputedStyle(row).display).to.not.equal('none');
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
+
it('should reflect target onto the host so pagelet link interception skips it', async () => {
|
|
37
|
+
const el = await fixture<HTMLElement>(html`
|
|
38
|
+
<zn-chat-message>
|
|
39
|
+
<zn-chat-message-attachment href="https://example.com/file.pdf" name="file.pdf"></zn-chat-message-attachment>
|
|
40
|
+
</zn-chat-message>`);
|
|
41
|
+
|
|
42
|
+
const attachment = el.querySelector('zn-chat-message-attachment')!;
|
|
43
|
+
// The console intercepts clicks on `[href]:not([target])` — without a reflected
|
|
44
|
+
// target attribute the click is hijacked and the attachment never downloads.
|
|
45
|
+
expect(attachment.getAttribute('target')).to.equal('_blank');
|
|
46
|
+
expect(attachment.matches('[href]:not([href^="#"]):not([href=""]):not([target])')).to.be.false;
|
|
47
|
+
});
|
|
48
|
+
|
|
36
49
|
it('should render the name in the link', async () => {
|
|
37
50
|
const el = await fixture<HTMLElement>(html`
|
|
38
51
|
<zn-chat-message>
|
|
@@ -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]') ?? [];
|
|
@@ -331,7 +355,7 @@ class Toolbar extends QuillToolbar {
|
|
|
331
355
|
if (!dialog) return;
|
|
332
356
|
|
|
333
357
|
dialog.dialogEl.showModal();
|
|
334
|
-
dialog.setContent(`<app-space id="app-editor-modal" allow-scripts auto-load loading-text="Loading, please wait..." uri="${uri}"></app-space>`);
|
|
358
|
+
dialog.setContent(`<app-space id="app-editor-modal" fetch-style="app-space-inline" allow-scripts auto-load loading-text="Loading, please wait..." uri="${uri}"></app-space>`);
|
|
335
359
|
}
|
|
336
360
|
|
|
337
361
|
private _handleOverflowUpdate = () => {
|