@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.
@@ -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 imageDialogOpen = false;
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 metadata here must return
108
- * `{uploadPath, uploadUrl}`; the file is then PUT to `uploadUrl` and
109
- * `uploadPath` is inserted into the document. When unset, adding an image
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.imageInsertIndex = index;
566
- this.imageDialogOpen = true;
679
+ this.imagePickerIndex = index;
567
680
  }
568
681
 
569
- private handleImageDialogClose = () => {
570
- this.imageDialogOpen = false;
682
+ private closeImagePicker = () => {
683
+ this.imagePickerIndex = null;
571
684
  };
572
685
 
573
- private handleImageInsert = () => {
574
- const index = this.imageInsertIndex;
575
- if (this.attachmentUrl) {
576
- const fileEl = this.shadowRoot?.querySelector<ZnFile>('.remarkd-editor__image-file');
577
- const file = fileEl?.files?.[0];
578
- if (!file) return;
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, `![](${url})`);
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
- this.addBlockAt(index, `![${file.name}](${path})`);
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
- const put = await fetch(data.uploadUrl, {
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="18"></zn-icon>
867
+ <zn-icon src="grip-vertical@lu" size="16"></zn-icon>
692
868
  </span>
693
- </div>`}
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.blocks.map((block, index) => this.renderBlock(block, index))}
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
- private renderImageDialog() {
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
- <zn-dialog class="remarkd-editor__image-dialog"
736
- label="Add image"
737
- size="small"
738
- open
739
- @zn-close=${this.handleImageDialogClose}>
740
- ${this.attachmentUrl ? html`
741
- <zn-file class="remarkd-editor__image-file"
742
- label="Image"
743
- accept="image/*"
744
- droparea></zn-file>` : html`
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) 88px;
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: -80px;
42
- top: 0;
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 open the image dialog from the toolbar', async () => {
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
- const dialog = el.shadowRoot!.querySelector('.remarkd-editor__image-dialog')!;
112
- expect(dialog).to.exist;
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: './',