@kubex/zinc 1.1.64 → 1.1.65
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 +246 -27
- package/dist/vscode.html-custom-data.json +13 -3
- package/dist/web-types.json +44 -5
- package/dist/zn.d.ts +46 -11
- package/dist/zn.min.js +441 -389
- package/docs/pages/components/remarkd-editor.md +25 -9
- package/package.json +2 -2
- package/src/components/page-builder/page-builder.component.ts +118 -54
- package/src/components/query-builder/query-builder.component.ts +2 -1
- package/src/components/remarkd-editor/remarkd-editor.component.ts +240 -55
- package/src/components/remarkd-editor/remarkd-editor.scss +106 -3
- package/src/components/remarkd-editor/remarkd-editor.test.ts +40 -6
- package/web-test-runner.config.js +2 -2
|
@@ -8,9 +8,7 @@ import {unsafeHTML} from "lit/directives/unsafe-html.js";
|
|
|
8
8
|
import {watch} from "../../internal/watch";
|
|
9
9
|
import ZincElement from '../../internal/zinc-element';
|
|
10
10
|
import type {ZincFormControl} from '../../internal/zinc-element';
|
|
11
|
-
import type ZnDialog from "../dialog";
|
|
12
11
|
import type ZnFile from "../file";
|
|
13
|
-
import type ZnInput from "../input";
|
|
14
12
|
|
|
15
13
|
import styles from './remarkd-editor.scss';
|
|
16
14
|
|
|
@@ -27,6 +25,15 @@ interface BlockType {
|
|
|
27
25
|
image?: boolean;
|
|
28
26
|
}
|
|
29
27
|
|
|
28
|
+
interface ImageBlockData {
|
|
29
|
+
caption: string;
|
|
30
|
+
align: '' | 'center' | 'right';
|
|
31
|
+
src: string;
|
|
32
|
+
alt: string;
|
|
33
|
+
width: string;
|
|
34
|
+
height: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
30
37
|
const BLOCK_TYPES: BlockType[] = [
|
|
31
38
|
{label: 'Text', icon: 'text@lu', prefix: ''},
|
|
32
39
|
{label: 'Heading 1', icon: 'heading-1@lu', prefix: '# '},
|
|
@@ -48,9 +55,7 @@ const BLOCK_TYPES: BlockType[] = [
|
|
|
48
55
|
* @dependency zn-button
|
|
49
56
|
* @dependency zn-button-group
|
|
50
57
|
* @dependency zn-icon
|
|
51
|
-
* @dependency zn-dialog
|
|
52
58
|
* @dependency zn-file
|
|
53
|
-
* @dependency zn-input
|
|
54
59
|
*
|
|
55
60
|
* @event zn-input - Emitted on each keystroke while editing a block.
|
|
56
61
|
* @event zn-change - Emitted when a block edit is committed and the value changes.
|
|
@@ -61,6 +66,7 @@ const BLOCK_TYPES: BlockType[] = [
|
|
|
61
66
|
* @csspart rendered - The rendered remarkd output of a block.
|
|
62
67
|
* @csspart input - The textarea shown while editing a block.
|
|
63
68
|
* @csspart slash-menu - The context menu opened by typing "/" in a block.
|
|
69
|
+
* @csspart image-controls - The caption / alignment / size panel shown when an image block is clicked.
|
|
64
70
|
*/
|
|
65
71
|
export default class ZnRemarkdEditor extends ZincElement implements ZincFormControl {
|
|
66
72
|
static styles: CSSResultGroup = unsafeCSS(styles);
|
|
@@ -80,12 +86,12 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
80
86
|
@state() private slashMenuOpen = false;
|
|
81
87
|
@state() private slashQuery = '';
|
|
82
88
|
@state() private slashActiveIndex = 0;
|
|
83
|
-
@state() private
|
|
89
|
+
@state() private imagePickerIndex: number | null = null;
|
|
90
|
+
@state() private imageEdit: ImageBlockData | null = null;
|
|
84
91
|
@state() private dropIndicator: number | null = null;
|
|
85
92
|
@state() private dragIndex: number | null = null;
|
|
86
93
|
@state() private editShell = '';
|
|
87
94
|
|
|
88
|
-
private imageInsertIndex = 0;
|
|
89
95
|
private pendingDragHandle: HTMLElement | null = null;
|
|
90
96
|
private dragStartX = 0;
|
|
91
97
|
private dragStartY = 0;
|
|
@@ -104,10 +110,9 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
104
110
|
@property() placeholder = 'Type something…';
|
|
105
111
|
|
|
106
112
|
/**
|
|
107
|
-
* Endpoint for image uploads. Posting the file
|
|
108
|
-
* `{uploadPath, uploadUrl}`; the file is then PUT
|
|
109
|
-
* `
|
|
110
|
-
* prompts for a URL instead.
|
|
113
|
+
* Endpoint for image uploads — required for image support. Posting the file
|
|
114
|
+
* metadata here must return `{uploadPath, uploadUrl}`; the file is then PUT
|
|
115
|
+
* to `uploadUrl` and the returned `uploadPath` is embedded as the image URL.
|
|
111
116
|
*/
|
|
112
117
|
@property({attribute: 'attachment-url'}) attachmentUrl = '';
|
|
113
118
|
|
|
@@ -252,9 +257,62 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
252
257
|
return;
|
|
253
258
|
}
|
|
254
259
|
|
|
260
|
+
// Image blocks get a controls panel instead of source editing.
|
|
261
|
+
const image = this.parseImageBlock(this.blocks[index]);
|
|
262
|
+
if (image) {
|
|
263
|
+
this.editingIndex = index;
|
|
264
|
+
this.imageEdit = image;
|
|
265
|
+
this.slashMenuOpen = false;
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
255
269
|
this.startEdit(index);
|
|
256
270
|
}
|
|
257
271
|
|
|
272
|
+
/** Parses a block that is purely an image (with optional caption/align lines). */
|
|
273
|
+
private parseImageBlock(block: string): ImageBlockData | null {
|
|
274
|
+
const data: ImageBlockData = {caption: '', align: '', src: '', alt: '', width: '', height: ''};
|
|
275
|
+
let found = false;
|
|
276
|
+
for (const raw of (block ?? '').split('\n')) {
|
|
277
|
+
const line = raw.trim();
|
|
278
|
+
if (!line) continue;
|
|
279
|
+
const macro = /^image::([^[]+)\[([^\]]*)]$/.exec(line);
|
|
280
|
+
const markdown = /^!\[([^\]]*)]\(([^)\s]+)\s*(?:"[^"]*")?\)$/.exec(line);
|
|
281
|
+
const align = /^\[\.align-(center|right)]$/.exec(line);
|
|
282
|
+
if (macro || markdown) {
|
|
283
|
+
if (found) return null;
|
|
284
|
+
found = true;
|
|
285
|
+
if (macro) {
|
|
286
|
+
data.src = macro[1].trim();
|
|
287
|
+
const parts = macro[2].split(',').map(part => part.trim());
|
|
288
|
+
data.alt = parts[0] ?? '';
|
|
289
|
+
data.width = parts[1] ?? '';
|
|
290
|
+
data.height = parts[2] ?? '';
|
|
291
|
+
} else if (markdown) {
|
|
292
|
+
data.alt = markdown[1];
|
|
293
|
+
data.src = markdown[2];
|
|
294
|
+
}
|
|
295
|
+
} else if (align) {
|
|
296
|
+
data.align = align[1] as ImageBlockData['align'];
|
|
297
|
+
} else if (line.startsWith('.') && !line.startsWith('..')) {
|
|
298
|
+
data.caption = line.slice(1);
|
|
299
|
+
} else {
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return found ? data : null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private serializeImageBlock(data: ImageBlockData): string {
|
|
307
|
+
const lines: string[] = [];
|
|
308
|
+
if (data.caption.trim()) lines.push(`.${data.caption.trim()}`);
|
|
309
|
+
if (data.align) lines.push(`[.align-${data.align}]`);
|
|
310
|
+
const attrs = [data.alt, data.width, data.height].map(attr => attr.trim());
|
|
311
|
+
while (attrs.length && !attrs[attrs.length - 1]) attrs.pop();
|
|
312
|
+
lines.push(`image::${data.src}[${attrs.join(',')}]`);
|
|
313
|
+
return lines.join('\n');
|
|
314
|
+
}
|
|
315
|
+
|
|
258
316
|
private toggleCheckbox(index: number, checkbox: HTMLInputElement) {
|
|
259
317
|
const rendered = checkbox.closest('.remarkd-editor__rendered');
|
|
260
318
|
const ordinal = Array.from(rendered?.querySelectorAll('input[type="checkbox"]') ?? []).indexOf(checkbox);
|
|
@@ -279,11 +337,54 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
279
337
|
if (this.disabled || this.readonly) return;
|
|
280
338
|
this.editingDraft = draft ?? this.blocks[index] ?? '';
|
|
281
339
|
this.editingIndex = index;
|
|
340
|
+
this.imageEdit = null;
|
|
282
341
|
this.editShell = this.computeEditShell(this.editingDraft);
|
|
283
342
|
this.slashMenuOpen = false;
|
|
284
343
|
void this.focusInput();
|
|
285
344
|
}
|
|
286
345
|
|
|
346
|
+
private updateImageEdit(patch: Partial<ImageBlockData>) {
|
|
347
|
+
if (this.imageEdit) this.imageEdit = {...this.imageEdit, ...patch};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
private saveImageEdit = () => {
|
|
351
|
+
if (this.editingIndex === null || !this.imageEdit) return;
|
|
352
|
+
const blocks = [...this.blocks];
|
|
353
|
+
blocks[this.editingIndex] = this.serializeImageBlock(this.imageEdit);
|
|
354
|
+
this.closeImageEdit();
|
|
355
|
+
this.updateBlocks(blocks);
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
private closeImageEdit = () => {
|
|
359
|
+
this.editingIndex = null;
|
|
360
|
+
this.imageEdit = null;
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
private deleteImageBlock = () => {
|
|
364
|
+
if (this.editingIndex === null) return;
|
|
365
|
+
const blocks = [...this.blocks];
|
|
366
|
+
blocks.splice(this.editingIndex, 1);
|
|
367
|
+
this.closeImageEdit();
|
|
368
|
+
this.updateBlocks(blocks);
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
private editImageSource = () => {
|
|
372
|
+
if (this.editingIndex === null) return;
|
|
373
|
+
const index = this.editingIndex;
|
|
374
|
+
this.imageEdit = null;
|
|
375
|
+
this.startEdit(index);
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
private handleImageControlsKeydown = (e: KeyboardEvent) => {
|
|
379
|
+
if (e.key === 'Escape') {
|
|
380
|
+
e.preventDefault();
|
|
381
|
+
this.closeImageEdit();
|
|
382
|
+
} else if (e.key === 'Enter' && (e.target as HTMLElement).tagName === 'INPUT') {
|
|
383
|
+
e.preventDefault();
|
|
384
|
+
this.saveImageEdit();
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
|
|
287
388
|
/**
|
|
288
389
|
* The remarkd chrome the editing block should keep, derived from its first
|
|
289
390
|
* line — so a NOTE still looks like a note while its source is edited.
|
|
@@ -317,6 +418,13 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
317
418
|
this.updateBlocks(blocks);
|
|
318
419
|
}
|
|
319
420
|
|
|
421
|
+
private deleteBlock(index: number) {
|
|
422
|
+
if (this.disabled || this.readonly) return;
|
|
423
|
+
const blocks = [...this.blocks];
|
|
424
|
+
blocks.splice(index, 1);
|
|
425
|
+
this.updateBlocks(blocks);
|
|
426
|
+
}
|
|
427
|
+
|
|
320
428
|
/** Inserts a draft block — committed (or dropped, if left empty) on blur. */
|
|
321
429
|
private insertDraftBlock(index: number, prefill = '') {
|
|
322
430
|
if (this.disabled || this.readonly) return;
|
|
@@ -338,6 +446,12 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
338
446
|
|
|
339
447
|
/** Commits the in-progress edit; returns the index after the committed parts. */
|
|
340
448
|
private commitEdit = (): number => {
|
|
449
|
+
if (this.imageEdit) {
|
|
450
|
+
// The image panel saves explicitly — just close it.
|
|
451
|
+
const index = this.editingIndex ?? this.blocks.length;
|
|
452
|
+
this.closeImageEdit();
|
|
453
|
+
return index + 1;
|
|
454
|
+
}
|
|
341
455
|
this.slashMenuOpen = false;
|
|
342
456
|
if (this.editingIndex === null) return this.blocks.length;
|
|
343
457
|
const index = this.editingIndex;
|
|
@@ -562,34 +676,26 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
562
676
|
|
|
563
677
|
private pickImage(index: number) {
|
|
564
678
|
if (this.disabled || this.readonly) return;
|
|
565
|
-
this.
|
|
566
|
-
this.imageDialogOpen = true;
|
|
679
|
+
this.imagePickerIndex = index;
|
|
567
680
|
}
|
|
568
681
|
|
|
569
|
-
private
|
|
570
|
-
this.
|
|
682
|
+
private closeImagePicker = () => {
|
|
683
|
+
this.imagePickerIndex = null;
|
|
571
684
|
};
|
|
572
685
|
|
|
573
|
-
private
|
|
574
|
-
const
|
|
575
|
-
if (
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
void this.insertImage(file, index);
|
|
580
|
-
} else {
|
|
581
|
-
const input = this.shadowRoot?.querySelector<ZnInput>('.remarkd-editor__image-url');
|
|
582
|
-
const url = String(input?.value ?? '').trim();
|
|
583
|
-
if (!url) return;
|
|
584
|
-
this.addBlockAt(index, ``);
|
|
585
|
-
}
|
|
586
|
-
this.shadowRoot?.querySelector<ZnDialog>('.remarkd-editor__image-dialog')?.hide();
|
|
686
|
+
private handleImagePicked = (e: Event) => {
|
|
687
|
+
const file = (e.target as ZnFile).files?.[0];
|
|
688
|
+
if (!file) return;
|
|
689
|
+
const index = this.imagePickerIndex ?? this.blocks.length;
|
|
690
|
+
this.imagePickerIndex = null;
|
|
691
|
+
void this.insertImage(file, index);
|
|
587
692
|
};
|
|
588
693
|
|
|
589
694
|
private async insertImage(file: File, index: number) {
|
|
590
695
|
try {
|
|
591
696
|
const path = await this.uploadImage(file);
|
|
592
|
-
|
|
697
|
+
const alt = file.name.replace(/[[\],]/g, '');
|
|
698
|
+
this.addBlockAt(index, `image::${path}[${alt}]`);
|
|
593
699
|
} catch (error) {
|
|
594
700
|
console.error('[zn-remarkd-editor] image upload failed', error);
|
|
595
701
|
}
|
|
@@ -606,7 +712,11 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
606
712
|
if (!res.ok) throw new Error(`Upload request failed: ${res.status}`);
|
|
607
713
|
const data = await res.json() as UploadResponse;
|
|
608
714
|
|
|
609
|
-
|
|
715
|
+
// resolve relative upload targets against the attachment url so both keep
|
|
716
|
+
// the same base path when the host page rewrites attachment-url (e.g. the
|
|
717
|
+
// kubex console proxying apps under an app base); absolute urls pass through
|
|
718
|
+
const target = new URL(data.uploadUrl, new URL(this.attachmentUrl, window.location.href)).toString();
|
|
719
|
+
const put = await fetch(target, {
|
|
610
720
|
method: 'PUT',
|
|
611
721
|
headers: {'Content-Type': file.type},
|
|
612
722
|
body: file,
|
|
@@ -651,8 +761,77 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
651
761
|
</div>`;
|
|
652
762
|
}
|
|
653
763
|
|
|
764
|
+
private renderImageControls() {
|
|
765
|
+
const data = this.imageEdit;
|
|
766
|
+
if (!data) return '';
|
|
767
|
+
const aligns: {value: ImageBlockData['align']; icon: string; label: string}[] = [
|
|
768
|
+
{value: '', icon: 'align-left@lu', label: 'Align left'},
|
|
769
|
+
{value: 'center', icon: 'align-center@lu', label: 'Align center'},
|
|
770
|
+
{value: 'right', icon: 'align-right@lu', label: 'Align right'},
|
|
771
|
+
];
|
|
772
|
+
return html`
|
|
773
|
+
<div part="image-controls" class="remarkd-editor__image-controls"
|
|
774
|
+
@keydown=${this.handleImageControlsKeydown}>
|
|
775
|
+
${data.src ? html`
|
|
776
|
+
<div class=${classMap({
|
|
777
|
+
'remarkd-editor__image-controls-preview': true,
|
|
778
|
+
[`remarkd-editor__image-controls-preview--${data.align}`]: !!data.align,
|
|
779
|
+
})}>
|
|
780
|
+
<img src=${data.src}
|
|
781
|
+
alt=${data.alt}
|
|
782
|
+
width=${data.width || ''}
|
|
783
|
+
height=${data.height || ''}>
|
|
784
|
+
</div>` : ''}
|
|
785
|
+
<label class="remarkd-editor__image-field">
|
|
786
|
+
<span>Caption</span>
|
|
787
|
+
<input .value=${data.caption}
|
|
788
|
+
placeholder="Optional caption"
|
|
789
|
+
@input=${(e: Event) => this.updateImageEdit({caption: (e.target as HTMLInputElement).value})}>
|
|
790
|
+
</label>
|
|
791
|
+
<div class="remarkd-editor__image-row">
|
|
792
|
+
<div class="remarkd-editor__image-field">
|
|
793
|
+
<span>Alignment</span>
|
|
794
|
+
<zn-button-group>
|
|
795
|
+
${aligns.map(align => html`
|
|
796
|
+
<zn-button type="button" icon-button="small" icon=${align.icon}
|
|
797
|
+
?plain=${data.align !== align.value}
|
|
798
|
+
tooltip=${align.label}
|
|
799
|
+
@click=${() => this.updateImageEdit({align: align.value})}></zn-button>`)}
|
|
800
|
+
</zn-button-group>
|
|
801
|
+
</div>
|
|
802
|
+
<label class="remarkd-editor__image-field">
|
|
803
|
+
<span>Width</span>
|
|
804
|
+
<input .value=${data.width} placeholder="auto" size="6"
|
|
805
|
+
@input=${(e: Event) => this.updateImageEdit({width: (e.target as HTMLInputElement).value})}>
|
|
806
|
+
</label>
|
|
807
|
+
<label class="remarkd-editor__image-field">
|
|
808
|
+
<span>Height</span>
|
|
809
|
+
<input .value=${data.height} placeholder="auto" size="6"
|
|
810
|
+
@input=${(e: Event) => this.updateImageEdit({height: (e.target as HTMLInputElement).value})}>
|
|
811
|
+
</label>
|
|
812
|
+
<label class="remarkd-editor__image-field">
|
|
813
|
+
<span>Alt text</span>
|
|
814
|
+
<input .value=${data.alt}
|
|
815
|
+
@input=${(e: Event) => this.updateImageEdit({alt: (e.target as HTMLInputElement).value})}>
|
|
816
|
+
</label>
|
|
817
|
+
</div>
|
|
818
|
+
<div class="remarkd-editor__image-buttons">
|
|
819
|
+
<zn-button type="button" color="primary" size="small" @click=${this.saveImageEdit}>Save</zn-button>
|
|
820
|
+
<zn-button type="button" color="secondary" size="small" @click=${this.closeImageEdit}>Cancel</zn-button>
|
|
821
|
+
<span class="remarkd-editor__image-buttons-spacer"></span>
|
|
822
|
+
<zn-button type="button" icon-button="small" plain icon="code@lu"
|
|
823
|
+
tooltip="Edit source" @click=${this.editImageSource}></zn-button>
|
|
824
|
+
<zn-button type="button" icon-button="small" plain icon="trash-2@lu" color="error"
|
|
825
|
+
tooltip="Delete image" @click=${this.deleteImageBlock}></zn-button>
|
|
826
|
+
</div>
|
|
827
|
+
</div>`;
|
|
828
|
+
}
|
|
829
|
+
|
|
654
830
|
private renderBlock(block: string, index: number) {
|
|
655
831
|
if (this.editingIndex === index) {
|
|
832
|
+
if (this.imageEdit) {
|
|
833
|
+
return this.renderImageControls();
|
|
834
|
+
}
|
|
656
835
|
return html`
|
|
657
836
|
<div class="remarkd-editor__edit-wrap remarkd-rendered">
|
|
658
837
|
<div class=${classMap({
|
|
@@ -682,15 +861,19 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
682
861
|
})}>
|
|
683
862
|
${this.disabled || this.readonly ? '' : html`
|
|
684
863
|
<div class="remarkd-editor__actions">
|
|
685
|
-
<zn-button type="button" icon-button="small" plain icon="plus@lu"
|
|
686
|
-
tooltip="Add block below"
|
|
687
|
-
@click=${() => this.insertDraftBlock(index + 1)}></zn-button>
|
|
688
864
|
<span class="remarkd-editor__drag-handle"
|
|
689
865
|
title="Drag to move"
|
|
690
866
|
@pointerdown=${this.handleHandlePointerDown}>
|
|
691
|
-
<zn-icon src="grip-vertical@lu" size="
|
|
867
|
+
<zn-icon src="grip-vertical@lu" size="16"></zn-icon>
|
|
692
868
|
</span>
|
|
693
|
-
|
|
869
|
+
<zn-button type="button" icon-button="small" plain icon="plus@lu" icon-size="16"
|
|
870
|
+
tooltip="Add block below"
|
|
871
|
+
@click=${() => this.insertDraftBlock(index + 1)}></zn-button>
|
|
872
|
+
</div>
|
|
873
|
+
<zn-button class="remarkd-editor__delete"
|
|
874
|
+
type="button" icon-button="small" plain icon="x@lu" icon-size="16" color="error"
|
|
875
|
+
tooltip="Delete block"
|
|
876
|
+
@click=${() => this.deleteBlock(index)}></zn-button>`}
|
|
694
877
|
<div part="rendered" class="remarkd-editor__rendered remarkd-rendered"
|
|
695
878
|
@click=${(e: MouseEvent) => this.handleRenderedClick(e, index)}>${unsafeHTML(remarkdParse(block))}
|
|
696
879
|
</div>
|
|
@@ -711,12 +894,12 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
711
894
|
${editable ? html`
|
|
712
895
|
<div part="toolbar" class="remarkd-editor__toolbar">
|
|
713
896
|
${BLOCK_TYPES.map(item => html`
|
|
714
|
-
<zn-button type="button" icon-button plain icon=${item.icon}
|
|
897
|
+
<zn-button type="button" icon-button plain icon=${item.icon} icon-size="18"
|
|
715
898
|
tooltip=${item.label}
|
|
716
899
|
@click=${() => this.handleToolbarInsert(item)}></zn-button>`)}
|
|
717
900
|
</div>` : ''}
|
|
718
901
|
<div class="remarkd-editor__body">
|
|
719
|
-
${this.
|
|
902
|
+
${this.renderBody()}
|
|
720
903
|
<div class="remarkd-editor__add" @click=${() => this.insertDraftBlock(this.blocks.length)}>
|
|
721
904
|
${this.blocks.length === 0 && this.editingIndex === null ? this.placeholder : ''}
|
|
722
905
|
</div>
|
|
@@ -726,27 +909,29 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
726
909
|
?required=${this.required}
|
|
727
910
|
tabindex="-1"
|
|
728
911
|
aria-hidden="true"></textarea>
|
|
729
|
-
${this.imageDialogOpen ? this.renderImageDialog() : ''}
|
|
730
912
|
</div>`;
|
|
731
913
|
}
|
|
732
914
|
|
|
733
|
-
|
|
915
|
+
/** The block views, with the inline image picker spliced in when active. */
|
|
916
|
+
private renderBody() {
|
|
917
|
+
const views = this.blocks.map((block, index) => this.renderBlock(block, index));
|
|
918
|
+
if (this.imagePickerIndex !== null) {
|
|
919
|
+
views.splice(this.imagePickerIndex, 0, this.renderImagePicker());
|
|
920
|
+
}
|
|
921
|
+
return views;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
private renderImagePicker() {
|
|
734
925
|
return html`
|
|
735
|
-
<
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
<zn-input class="remarkd-editor__image-url"
|
|
746
|
-
label="Image URL"
|
|
747
|
-
placeholder="https://example.com/image.png"></zn-input>`}
|
|
748
|
-
<zn-button slot="footer" color="secondary" @click=${this.handleImageDialogClose}>Cancel</zn-button>
|
|
749
|
-
<zn-button slot="footer" color="primary" @click=${this.handleImageInsert}>Insert</zn-button>
|
|
750
|
-
</zn-dialog>`;
|
|
926
|
+
<div class="remarkd-editor__image-picker">
|
|
927
|
+
<zn-file class="remarkd-editor__image-file"
|
|
928
|
+
label="Image"
|
|
929
|
+
accept="image/*"
|
|
930
|
+
droparea
|
|
931
|
+
@zn-change=${this.handleImagePicked}></zn-file>
|
|
932
|
+
<zn-button type="button" icon-button="small" plain icon="x@lu"
|
|
933
|
+
tooltip="Cancel"
|
|
934
|
+
@click=${this.closeImagePicker}></zn-button>
|
|
935
|
+
</div>`;
|
|
751
936
|
}
|
|
752
937
|
}
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
.remarkd-editor__body {
|
|
25
|
-
padding: var(--zn-spacing-large) var(--zn-spacing-large) var(--zn-spacing-large)
|
|
25
|
+
padding: var(--zn-spacing-large) var(--zn-spacing-large) var(--zn-spacing-large) 52px;
|
|
26
26
|
min-height: 10rem;
|
|
27
27
|
}
|
|
28
28
|
|
|
@@ -38,9 +38,10 @@
|
|
|
38
38
|
|
|
39
39
|
.remarkd-editor__actions {
|
|
40
40
|
position: absolute;
|
|
41
|
-
left: -
|
|
42
|
-
top:
|
|
41
|
+
left: -40px;
|
|
42
|
+
top: 4px;
|
|
43
43
|
display: flex;
|
|
44
|
+
flex-direction: column;
|
|
44
45
|
align-items: center;
|
|
45
46
|
gap: 2px;
|
|
46
47
|
opacity: 0;
|
|
@@ -52,6 +53,22 @@
|
|
|
52
53
|
opacity: 1;
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
.remarkd-editor__delete {
|
|
57
|
+
position: absolute;
|
|
58
|
+
top: 2px;
|
|
59
|
+
right: 2px;
|
|
60
|
+
z-index: 5;
|
|
61
|
+
opacity: 0;
|
|
62
|
+
transition: opacity 0.15s ease;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.remarkd-editor__block:hover .remarkd-editor__delete,
|
|
66
|
+
.remarkd-editor__delete:focus-within {
|
|
67
|
+
opacity: 1;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
|
|
55
72
|
.remarkd-editor__drag-handle {
|
|
56
73
|
display: inline-flex;
|
|
57
74
|
align-items: center;
|
|
@@ -183,6 +200,92 @@
|
|
|
183
200
|
}
|
|
184
201
|
}
|
|
185
202
|
|
|
203
|
+
.remarkd-editor__image-controls {
|
|
204
|
+
display: flex;
|
|
205
|
+
flex-direction: column;
|
|
206
|
+
gap: var(--zn-spacing-small);
|
|
207
|
+
border: 1px solid rgb(var(--zn-primary));
|
|
208
|
+
border-radius: var(--zn-border-radius);
|
|
209
|
+
background: var(--zn-color-neutral-0, white);
|
|
210
|
+
padding: var(--zn-spacing-small);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
.remarkd-editor__image-controls-preview {
|
|
214
|
+
text-align: left;
|
|
215
|
+
|
|
216
|
+
&--center {
|
|
217
|
+
text-align: center;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
&--right {
|
|
221
|
+
text-align: right;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
img {
|
|
225
|
+
max-width: 100%;
|
|
226
|
+
max-height: 240px;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
.remarkd-editor__image-row {
|
|
231
|
+
display: flex;
|
|
232
|
+
align-items: flex-end;
|
|
233
|
+
gap: var(--zn-spacing-small);
|
|
234
|
+
flex-wrap: wrap;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
.remarkd-editor__image-field {
|
|
238
|
+
display: flex;
|
|
239
|
+
flex-direction: column;
|
|
240
|
+
gap: 2px;
|
|
241
|
+
|
|
242
|
+
span {
|
|
243
|
+
font-size: var(--zn-input-label-font-size-small);
|
|
244
|
+
color: var(--zn-input-label-color);
|
|
245
|
+
font-weight: var(--zn-font-weight-semibold);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
input {
|
|
249
|
+
border: 1px solid rgb(var(--zn-border-color));
|
|
250
|
+
border-radius: var(--zn-border-radius);
|
|
251
|
+
background: var(--zn-color-neutral-0, white);
|
|
252
|
+
color: inherit;
|
|
253
|
+
font: inherit;
|
|
254
|
+
font-size: 0.875rem;
|
|
255
|
+
padding: 4px 8px;
|
|
256
|
+
outline: none;
|
|
257
|
+
|
|
258
|
+
&:focus {
|
|
259
|
+
border-color: rgb(var(--zn-primary));
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
.remarkd-editor__image-field:first-child input {
|
|
265
|
+
width: 100%;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
.remarkd-editor__image-buttons {
|
|
269
|
+
display: flex;
|
|
270
|
+
align-items: center;
|
|
271
|
+
gap: var(--zn-spacing-x-small);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
.remarkd-editor__image-buttons-spacer {
|
|
275
|
+
margin-left: auto;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
.remarkd-editor__image-picker {
|
|
279
|
+
display: flex;
|
|
280
|
+
align-items: flex-start;
|
|
281
|
+
gap: var(--zn-spacing-x-small);
|
|
282
|
+
margin: var(--zn-spacing-x-small) 0;
|
|
283
|
+
|
|
284
|
+
zn-file {
|
|
285
|
+
flex: 1;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
186
289
|
.remarkd-editor__add {
|
|
187
290
|
padding: var(--zn-spacing-x-small);
|
|
188
291
|
min-height: 1.8em;
|
|
@@ -100,18 +100,16 @@ Second"></zn-remarkd-editor>`);
|
|
|
100
100
|
expect(draft.value).to.equal('');
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
-
it('should
|
|
103
|
+
it('should show an inline zn-file picker from the toolbar image button', async () => {
|
|
104
104
|
const el = await fixture<ZnRemarkdEditor>(html`
|
|
105
|
-
<zn-remarkd-editor></zn-remarkd-editor>`);
|
|
105
|
+
<zn-remarkd-editor attachment-url="/upload"></zn-remarkd-editor>`);
|
|
106
106
|
const buttons = el.shadowRoot!.querySelectorAll('.remarkd-editor__toolbar zn-button');
|
|
107
107
|
const imageButton = buttons[buttons.length - 1];
|
|
108
108
|
imageButton.dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true}));
|
|
109
109
|
await el.updateComplete;
|
|
110
110
|
|
|
111
|
-
|
|
112
|
-
expect(
|
|
113
|
-
// No attachment-url configured — the dialog falls back to a URL input.
|
|
114
|
-
expect(el.shadowRoot!.querySelector('.remarkd-editor__image-url')).to.exist;
|
|
111
|
+
expect(el.shadowRoot!.querySelector('.remarkd-editor__body .remarkd-editor__image-picker')).to.exist;
|
|
112
|
+
expect(el.shadowRoot!.querySelector('.remarkd-editor__image-file')).to.exist;
|
|
115
113
|
});
|
|
116
114
|
|
|
117
115
|
it('should toggle a checkbox in the source instead of opening the editor', async () => {
|
|
@@ -126,6 +124,42 @@ Second"></zn-remarkd-editor>`);
|
|
|
126
124
|
expect(el.shadowRoot!.querySelector('.remarkd-editor__input')).to.not.exist;
|
|
127
125
|
});
|
|
128
126
|
|
|
127
|
+
it('should open image controls for an image block and save caption/align/size', async () => {
|
|
128
|
+
const el = await fixture<ZnRemarkdEditor>(html`
|
|
129
|
+
<zn-remarkd-editor value="image::photo.png[Alt,640,480]"></zn-remarkd-editor>`);
|
|
130
|
+
el.shadowRoot!.querySelector<HTMLElement>('.remarkd-editor__rendered')!.click();
|
|
131
|
+
await el.updateComplete;
|
|
132
|
+
|
|
133
|
+
const panel = el.shadowRoot!.querySelector('.remarkd-editor__image-controls')!;
|
|
134
|
+
expect(panel).to.exist;
|
|
135
|
+
expect(el.shadowRoot!.querySelector('.remarkd-editor__input')).to.not.exist;
|
|
136
|
+
|
|
137
|
+
const caption = panel.querySelector<HTMLInputElement>('.remarkd-editor__image-field input')!;
|
|
138
|
+
caption.value = 'A caption';
|
|
139
|
+
caption.dispatchEvent(new Event('input', {bubbles: true}));
|
|
140
|
+
await el.updateComplete;
|
|
141
|
+
|
|
142
|
+
const buttons = el.shadowRoot!.querySelectorAll('.remarkd-editor__image-buttons zn-button');
|
|
143
|
+
buttons[0].dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true}));
|
|
144
|
+
await el.updateComplete;
|
|
145
|
+
|
|
146
|
+
expect(el.value).to.equal('.A caption\nimage::photo.png[Alt,640,480]');
|
|
147
|
+
expect(el.shadowRoot!.querySelector('.remarkd-editor__rendered')!.innerHTML).to.contain('A caption');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('should delete a block from its hover delete button', async () => {
|
|
151
|
+
const el = await fixture<ZnRemarkdEditor>(html`
|
|
152
|
+
<zn-remarkd-editor value="# Title
|
|
153
|
+
|
|
154
|
+
Second"></zn-remarkd-editor>`);
|
|
155
|
+
const deleteButton = el.shadowRoot!.querySelectorAll('.remarkd-editor__delete')[0];
|
|
156
|
+
deleteButton.dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true}));
|
|
157
|
+
await el.updateComplete;
|
|
158
|
+
|
|
159
|
+
expect(el.value).to.equal('Second');
|
|
160
|
+
expect(el.shadowRoot!.querySelectorAll('.remarkd-editor__block').length).to.equal(1);
|
|
161
|
+
});
|
|
162
|
+
|
|
129
163
|
it('should emit zn-change when a block edit changes the value', async () => {
|
|
130
164
|
const el = await fixture<ZnRemarkdEditor>(html`
|
|
131
165
|
<zn-remarkd-editor value="Hello"></zn-remarkd-editor>`);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {removeViteLogging, vitePlugin} from '@remcovaes/web-test-runner-vite-plugin';
|
|
2
|
-
import {playwrightLauncher} from '@web/test-runner-playwright';
|
|
3
1
|
import {globbySync} from 'globby';
|
|
2
|
+
import {playwrightLauncher} from '@web/test-runner-playwright';
|
|
3
|
+
import {removeViteLogging, vitePlugin} from '@remcovaes/web-test-runner-vite-plugin';
|
|
4
4
|
|
|
5
5
|
export default {
|
|
6
6
|
rootDir: './',
|