@kubex/zinc 1.1.63 → 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.
@@ -0,0 +1,937 @@
1
+ import {classMap} from "lit/directives/class-map.js";
2
+ import {type CSSResultGroup, html, type PropertyValues, unsafeCSS} from 'lit';
3
+ import {defaultValue} from "../../internal/default-value";
4
+ import {FormControlController} from "../../internal/form";
5
+ import {property, query, state} from 'lit/decorators.js';
6
+ import {parse as remarkdParse} from "remarkd-js";
7
+ import {unsafeHTML} from "lit/directives/unsafe-html.js";
8
+ import {watch} from "../../internal/watch";
9
+ import ZincElement from '../../internal/zinc-element';
10
+ import type {ZincFormControl} from '../../internal/zinc-element';
11
+ import type ZnFile from "../file";
12
+
13
+ import styles from './remarkd-editor.scss';
14
+
15
+ interface UploadResponse {
16
+ uploadPath: string;
17
+ uploadUrl: string;
18
+ originalFilename: string;
19
+ }
20
+
21
+ interface BlockType {
22
+ label: string;
23
+ icon: string;
24
+ prefix?: string;
25
+ image?: boolean;
26
+ }
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
+
37
+ const BLOCK_TYPES: BlockType[] = [
38
+ {label: 'Text', icon: 'text@lu', prefix: ''},
39
+ {label: 'Heading 1', icon: 'heading-1@lu', prefix: '# '},
40
+ {label: 'Heading 2', icon: 'heading-2@lu', prefix: '## '},
41
+ {label: 'Heading 3', icon: 'heading-3@lu', prefix: '### '},
42
+ {label: 'Note', icon: 'info@lu', prefix: 'NOTE: '},
43
+ {label: 'Tip', icon: 'lightbulb@lu', prefix: 'TIP: '},
44
+ {label: 'Warning', icon: 'triangle-alert@lu', prefix: 'WARNING: '},
45
+ {label: 'Code', icon: 'code@lu', prefix: '```\n\n```'},
46
+ {label: 'Image', icon: 'image@lu', image: true},
47
+ ];
48
+
49
+ /**
50
+ * @summary A Notion-style block editor for remarkd content. Blocks render inline; click one to edit its source.
51
+ * @documentation https://zinc.style/components/remarkd-editor
52
+ * @status experimental
53
+ * @since 1.0
54
+ *
55
+ * @dependency zn-button
56
+ * @dependency zn-button-group
57
+ * @dependency zn-icon
58
+ * @dependency zn-file
59
+ *
60
+ * @event zn-input - Emitted on each keystroke while editing a block.
61
+ * @event zn-change - Emitted when a block edit is committed and the value changes.
62
+ *
63
+ * @csspart base - The component's base wrapper.
64
+ * @csspart toolbar - The always-visible block-insert toolbar.
65
+ * @csspart block - A rendered block wrapper.
66
+ * @csspart rendered - The rendered remarkd output of a block.
67
+ * @csspart input - The textarea shown while editing a block.
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.
70
+ */
71
+ export default class ZnRemarkdEditor extends ZincElement implements ZincFormControl {
72
+ static styles: CSSResultGroup = unsafeCSS(styles);
73
+
74
+ private readonly formControlController = new FormControlController(this, {
75
+ assumeInteractionOn: ['zn-input', 'zn-change'],
76
+ });
77
+
78
+ private editingDraft = '';
79
+ private suppressValueSync = false;
80
+ private suppressBlurCommit = false;
81
+
82
+ @query('.remarkd-editor__validation') private validationInput: HTMLTextAreaElement;
83
+
84
+ @state() private blocks: string[] = [];
85
+ @state() private editingIndex: number | null = null;
86
+ @state() private slashMenuOpen = false;
87
+ @state() private slashQuery = '';
88
+ @state() private slashActiveIndex = 0;
89
+ @state() private imagePickerIndex: number | null = null;
90
+ @state() private imageEdit: ImageBlockData | null = null;
91
+ @state() private dropIndicator: number | null = null;
92
+ @state() private dragIndex: number | null = null;
93
+ @state() private editShell = '';
94
+
95
+ private pendingDragHandle: HTMLElement | null = null;
96
+ private dragStartX = 0;
97
+ private dragStartY = 0;
98
+ private dragGhost: HTMLElement | null = null;
99
+
100
+ /** The name of the control, submitted as part of form data. */
101
+ @property() name = '';
102
+
103
+ /** The current remarkd source. */
104
+ @property() value = '';
105
+
106
+ /** The default value — used when resetting the form. */
107
+ @defaultValue() defaultValue = '';
108
+
109
+ /** Placeholder shown when the document is empty. */
110
+ @property() placeholder = 'Type something…';
111
+
112
+ /**
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.
116
+ */
117
+ @property({attribute: 'attachment-url'}) attachmentUrl = '';
118
+
119
+ /** Makes the editor required for form submission. */
120
+ @property({type: Boolean, reflect: true}) required = false;
121
+
122
+ /** Makes the editor read-only. */
123
+ @property({type: Boolean, reflect: true}) readonly = false;
124
+
125
+ /** Disables the editor. */
126
+ @property({type: Boolean, reflect: true}) disabled = false;
127
+
128
+ get validity(): ValidityState {
129
+ return this.validationInput?.validity;
130
+ }
131
+
132
+ get validationMessage(): string {
133
+ return this.validationInput?.validationMessage ?? '';
134
+ }
135
+
136
+ checkValidity(): boolean {
137
+ return this.validationInput?.checkValidity() ?? true;
138
+ }
139
+
140
+ getForm(): HTMLFormElement | null {
141
+ return this.formControlController.getForm();
142
+ }
143
+
144
+ reportValidity(): boolean {
145
+ return this.validationInput?.reportValidity() ?? true;
146
+ }
147
+
148
+ setCustomValidity(message: string): void {
149
+ this.validationInput?.setCustomValidity(message);
150
+ this.formControlController.updateValidity();
151
+ }
152
+
153
+ /** Starts editing the first block, or a new block if the document is empty. */
154
+ focus() {
155
+ if (this.blocks.length) {
156
+ this.startEdit(0);
157
+ } else {
158
+ this.insertDraftBlock(0);
159
+ }
160
+ }
161
+
162
+ /** Commits any in-progress block edit. */
163
+ blur() {
164
+ this.shadowRoot?.querySelector<HTMLTextAreaElement>('.remarkd-editor__input')?.blur();
165
+ }
166
+
167
+ protected firstUpdated(_changedProperties: PropertyValues) {
168
+ super.firstUpdated(_changedProperties);
169
+ this.formControlController.updateValidity();
170
+ }
171
+
172
+ disconnectedCallback() {
173
+ super.disconnectedCallback();
174
+ this.cancelDrag();
175
+ }
176
+
177
+ @watch('value')
178
+ handleValueChange() {
179
+ if (this.suppressValueSync) {
180
+ this.suppressValueSync = false;
181
+ return;
182
+ }
183
+ this.blocks = this.splitBlocks(this.value || '');
184
+ }
185
+
186
+ /**
187
+ * Splits remarkd source into blocks on blank lines, keeping fenced /
188
+ * delimited containers (``` ==== !!!! .... ----) as single blocks.
189
+ */
190
+ private splitBlocks(source: string): string[] {
191
+ const lines = source.replace(/\r\n/g, '\n').split('\n');
192
+ const blocks: string[] = [];
193
+ let current: string[] = [];
194
+ let fence: string | null = null;
195
+
196
+ const push = () => {
197
+ const text = current.join('\n').trim();
198
+ if (text) blocks.push(text);
199
+ current = [];
200
+ };
201
+
202
+ for (const line of lines) {
203
+ const trimmed = line.trimEnd();
204
+ if (fence) {
205
+ current.push(line);
206
+ if (this.closesFence(trimmed, fence)) fence = null;
207
+ continue;
208
+ }
209
+ const marker = this.fenceMarker(trimmed);
210
+ if (marker) {
211
+ current.push(line);
212
+ fence = marker;
213
+ continue;
214
+ }
215
+ if (trimmed === '') {
216
+ push();
217
+ continue;
218
+ }
219
+ current.push(line);
220
+ }
221
+ push();
222
+ return blocks;
223
+ }
224
+
225
+ private fenceMarker(line: string): string | null {
226
+ const backticks = /^`{3,}/.exec(line);
227
+ if (backticks) return backticks[0];
228
+ if (/^(={4,}|\.{4,}|-{4,}|!{4,})$/.test(line)) return line;
229
+ if (/^!!\S+!!$/.test(line)) return '!!!!';
230
+ return null;
231
+ }
232
+
233
+ private closesFence(line: string, fence: string): boolean {
234
+ const char = fence[0];
235
+ let count = 0;
236
+ while (count < line.length && line[count] === char) count++;
237
+ return count === line.length && count >= fence.length;
238
+ }
239
+
240
+ private updateBlocks(blocks: string[]) {
241
+ this.blocks = blocks;
242
+ const joined = blocks.join('\n\n');
243
+ if (joined !== this.value) {
244
+ this.suppressValueSync = true;
245
+ this.value = joined;
246
+ this.formControlController.updateValidity();
247
+ this.emit('zn-change');
248
+ }
249
+ }
250
+
251
+ private handleRenderedClick(e: MouseEvent, index: number) {
252
+ const checkbox = (e.target as HTMLElement).closest<HTMLInputElement>('input[type="checkbox"]');
253
+ if (checkbox) {
254
+ // Toggle the task in the source rather than opening the editor.
255
+ e.preventDefault();
256
+ this.toggleCheckbox(index, checkbox);
257
+ return;
258
+ }
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
+
269
+ this.startEdit(index);
270
+ }
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
+
316
+ private toggleCheckbox(index: number, checkbox: HTMLInputElement) {
317
+ const rendered = checkbox.closest('.remarkd-editor__rendered');
318
+ const ordinal = Array.from(rendered?.querySelectorAll('input[type="checkbox"]') ?? []).indexOf(checkbox);
319
+ if (ordinal < 0) return;
320
+
321
+ let seen = -1;
322
+ const updated = this.blocks[index].replace(
323
+ /^(\s*(?:[-*+]|\d+\.)\s+)\[( |x|X)\]/gm,
324
+ (match, prefix: string, mark: string) => {
325
+ seen++;
326
+ if (seen !== ordinal) return match;
327
+ return `${prefix}[${mark === ' ' ? 'x' : ' '}]`;
328
+ });
329
+ if (updated === this.blocks[index]) return;
330
+
331
+ const blocks = [...this.blocks];
332
+ blocks[index] = updated;
333
+ this.updateBlocks(blocks);
334
+ }
335
+
336
+ private startEdit(index: number, draft?: string) {
337
+ if (this.disabled || this.readonly) return;
338
+ this.editingDraft = draft ?? this.blocks[index] ?? '';
339
+ this.editingIndex = index;
340
+ this.imageEdit = null;
341
+ this.editShell = this.computeEditShell(this.editingDraft);
342
+ this.slashMenuOpen = false;
343
+ void this.focusInput();
344
+ }
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
+
388
+ /**
389
+ * The remarkd chrome the editing block should keep, derived from its first
390
+ * line — so a NOTE still looks like a note while its source is edited.
391
+ */
392
+ private computeEditShell(draft: string): string {
393
+ const first = (draft.split('\n', 1)[0] ?? '').trimStart();
394
+ const hint = /^(NOTE|TIP|WARNING|IMPORTANT|CAUTION|DANGER|SUCCESS|NOTICE):\s/.exec(first);
395
+ if (hint) return `hint-${hint[1].toLowerCase()}`;
396
+ if (first.startsWith('### ')) return 'remarkd-editor__edit-shell--h3';
397
+ if (first.startsWith('## ')) return 'remarkd-editor__edit-shell--h2';
398
+ if (first.startsWith('# ')) return 'remarkd-editor__edit-shell--h1';
399
+ if (first.startsWith('```')) return 'remarkd-editor__edit-shell--code';
400
+ return '';
401
+ }
402
+
403
+ private async focusInput() {
404
+ await this.updateComplete;
405
+ const input = this.shadowRoot?.querySelector<HTMLTextAreaElement>('.remarkd-editor__input');
406
+ if (input) {
407
+ this.autosize(input);
408
+ input.focus();
409
+ input.setSelectionRange(input.value.length, input.value.length);
410
+ }
411
+ this.suppressBlurCommit = false;
412
+ }
413
+
414
+ private addBlockAt(index: number, content: string) {
415
+ if (this.disabled || this.readonly) return;
416
+ const blocks = [...this.blocks];
417
+ blocks.splice(index, 0, content);
418
+ this.updateBlocks(blocks);
419
+ }
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
+
428
+ /** Inserts a draft block — committed (or dropped, if left empty) on blur. */
429
+ private insertDraftBlock(index: number, prefill = '') {
430
+ if (this.disabled || this.readonly) return;
431
+ const blocks = [...this.blocks];
432
+ blocks.splice(index, 0, '');
433
+ this.blocks = blocks;
434
+ this.startEdit(index, prefill);
435
+ }
436
+
437
+ /**
438
+ * Blur handler for the editing textarea. Re-renders that replace the
439
+ * focused textarea (e.g. Shift+Enter committing and opening the next
440
+ * block) fire blur mid-transition — `suppressBlurCommit` masks those.
441
+ */
442
+ private handleEditBlur = () => {
443
+ if (this.suppressBlurCommit) return;
444
+ this.commitEdit();
445
+ };
446
+
447
+ /** Commits the in-progress edit; returns the index after the committed parts. */
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
+ }
455
+ this.slashMenuOpen = false;
456
+ if (this.editingIndex === null) return this.blocks.length;
457
+ const index = this.editingIndex;
458
+ const parts = this.splitBlocks(this.editingDraft);
459
+ const blocks = [...this.blocks];
460
+ blocks.splice(index, 1, ...parts);
461
+ this.editingIndex = null;
462
+ this.updateBlocks(blocks);
463
+ return index + parts.length;
464
+ };
465
+
466
+ private get filteredSlashItems(): BlockType[] {
467
+ const filter = this.slashQuery.toLowerCase();
468
+ return BLOCK_TYPES.filter(item => item.label.toLowerCase().includes(filter));
469
+ }
470
+
471
+ private handleDraftInput = (e: Event) => {
472
+ const input = e.target as HTMLTextAreaElement;
473
+ this.editingDraft = input.value;
474
+ this.autosize(input);
475
+
476
+ const shell = this.computeEditShell(input.value);
477
+ if (shell !== this.editShell) {
478
+ this.editShell = shell;
479
+ void this.updateComplete.then(() => this.autosize(input));
480
+ }
481
+
482
+ // A leading "/" in an otherwise fresh block opens the slash menu; the rest
483
+ // of the line filters it.
484
+ if (input.value === '/') {
485
+ this.slashMenuOpen = true;
486
+ this.slashQuery = '';
487
+ this.slashActiveIndex = 0;
488
+ } else if (this.slashMenuOpen) {
489
+ if (input.value.startsWith('/') && !input.value.includes('\n')) {
490
+ this.slashQuery = input.value.slice(1);
491
+ this.slashActiveIndex = 0;
492
+ } else {
493
+ this.slashMenuOpen = false;
494
+ }
495
+ }
496
+
497
+ this.emit('zn-input');
498
+ };
499
+
500
+ private handleEditKeydown = (e: KeyboardEvent) => {
501
+ const input = e.target as HTMLTextAreaElement;
502
+
503
+ if (this.slashMenuOpen) {
504
+ const items = this.filteredSlashItems;
505
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
506
+ e.preventDefault();
507
+ const step = e.key === 'ArrowDown' ? 1 : -1;
508
+ this.slashActiveIndex = (this.slashActiveIndex + step + items.length) % Math.max(items.length, 1);
509
+ return;
510
+ }
511
+ if (e.key === 'Enter' && items.length) {
512
+ e.preventDefault();
513
+ this.applySlashItem(items[this.slashActiveIndex] ?? items[0]);
514
+ return;
515
+ }
516
+ if (e.key === 'Escape') {
517
+ e.preventDefault();
518
+ this.slashMenuOpen = false;
519
+ return;
520
+ }
521
+ }
522
+
523
+ if (e.key === 'Enter' && e.shiftKey) {
524
+ e.preventDefault();
525
+ this.suppressBlurCommit = true;
526
+ const next = this.commitEdit();
527
+ this.insertDraftBlock(next);
528
+ } else if (e.key === 'Escape' || (e.key === 'Enter' && (e.metaKey || e.ctrlKey))) {
529
+ e.preventDefault();
530
+ input.blur();
531
+ } else if (e.key === 'Backspace' && input.value === '') {
532
+ e.preventDefault();
533
+ input.blur();
534
+ }
535
+ };
536
+
537
+ private applySlashItem(item: BlockType) {
538
+ this.slashMenuOpen = false;
539
+ const index = this.editingIndex ?? this.blocks.length;
540
+ const input = this.shadowRoot?.querySelector<HTMLTextAreaElement>('.remarkd-editor__input');
541
+
542
+ if (item.image) {
543
+ // Drop the "/..." draft block, then run the image flow in its place.
544
+ this.editingDraft = '';
545
+ if (input) input.value = '';
546
+ input?.blur();
547
+ this.pickImage(index);
548
+ return;
549
+ }
550
+
551
+ this.editingDraft = item.prefix ?? '';
552
+ if (input) {
553
+ input.value = this.editingDraft;
554
+ this.autosize(input);
555
+ input.focus();
556
+ input.setSelectionRange(input.value.length, input.value.length);
557
+ }
558
+ }
559
+
560
+ private handleEditPaste = (e: ClipboardEvent) => {
561
+ const file = Array.from(e.clipboardData?.files ?? []).find(f => f.type.startsWith('image/'));
562
+ if (!file) return;
563
+ e.preventDefault();
564
+ const index = this.editingIndex ?? this.blocks.length;
565
+ void this.insertImage(file, index + 1);
566
+ };
567
+
568
+ private handleDragOver = (e: DragEvent) => {
569
+ if (e.dataTransfer?.types.includes('Files')) e.preventDefault();
570
+ };
571
+
572
+ private handleDrop = (e: DragEvent) => {
573
+ const file = Array.from(e.dataTransfer?.files ?? []).find(f => f.type.startsWith('image/'));
574
+ if (!file) return;
575
+ e.preventDefault();
576
+ void this.insertImage(file, this.blocks.length);
577
+ };
578
+
579
+ /** The insertion index a drop at `y` maps to, from the rendered block positions. */
580
+ private insertionIndexFromY(y: number): number {
581
+ const els = Array.from(this.shadowRoot?.querySelectorAll<HTMLElement>('.remarkd-editor__block') ?? []);
582
+ for (let i = 0; i < els.length; i++) {
583
+ const rect = els[i].getBoundingClientRect();
584
+ if (y < rect.top + rect.height / 2) return i;
585
+ }
586
+ return els.length;
587
+ }
588
+
589
+ /*
590
+ * Block dragging uses pointer events rather than native HTML5 drag & drop:
591
+ * the browser owns the cursor during a native drag (CSS can't show a
592
+ * grabbing hand), and WebKit's dnd support inside shadow DOM is patchy.
593
+ */
594
+
595
+ private handleHandlePointerDown = (e: PointerEvent) => {
596
+ if (e.button !== 0) return;
597
+ e.preventDefault();
598
+ if (this.editingIndex !== null) this.commitEdit();
599
+ this.pendingDragHandle = e.currentTarget as HTMLElement;
600
+ this.dragStartX = e.clientX;
601
+ this.dragStartY = e.clientY;
602
+ // Document-level listeners for the whole drag — element-level capture is
603
+ // lost if a re-render replaces the handle, stranding the ghost.
604
+ document.addEventListener('pointermove', this.handleDragPointerMove);
605
+ document.addEventListener('pointerup', this.handleDragPointerUp);
606
+ document.addEventListener('pointercancel', this.cancelDrag);
607
+ };
608
+
609
+ private handleDragPointerMove = (e: PointerEvent) => {
610
+ if (!this.pendingDragHandle) return;
611
+
612
+ if (e.buttons % 2 === 0) {
613
+ // The primary button is no longer held — the pointerup was missed. Abort.
614
+ this.cancelDrag();
615
+ return;
616
+ }
617
+
618
+ if (this.dragIndex === null) {
619
+ const moved = Math.abs(e.clientX - this.dragStartX) + Math.abs(e.clientY - this.dragStartY);
620
+ if (moved < 4) return;
621
+ // Resolve the index from the DOM at drag start, after any edit commit.
622
+ const block = this.pendingDragHandle.closest('.remarkd-editor__block');
623
+ const blocks = Array.from(this.shadowRoot?.querySelectorAll('.remarkd-editor__block') ?? []);
624
+ const index = block ? blocks.indexOf(block) : -1;
625
+ if (index < 0) {
626
+ this.cancelDrag();
627
+ return;
628
+ }
629
+ this.dragIndex = index;
630
+ this.createDragGhost(index);
631
+ document.body.style.cursor = 'grabbing';
632
+ }
633
+
634
+ this.moveDragGhost(e.clientX, e.clientY);
635
+ this.dropIndicator = this.insertionIndexFromY(e.clientY);
636
+ };
637
+
638
+ private handleDragPointerUp = (e: PointerEvent) => {
639
+ const from = this.dragIndex;
640
+ let to = from !== null ? (this.dropIndicator ?? this.insertionIndexFromY(e.clientY)) : null;
641
+ this.cancelDrag();
642
+ if (from === null || to === null || to === from || to === from + 1) return;
643
+ const blocks = [...this.blocks];
644
+ const [moved] = blocks.splice(from, 1);
645
+ if (to > from) to--;
646
+ blocks.splice(to, 0, moved);
647
+ this.updateBlocks(blocks);
648
+ };
649
+
650
+ private cancelDrag = () => {
651
+ this.pendingDragHandle = null;
652
+ document.removeEventListener('pointermove', this.handleDragPointerMove);
653
+ document.removeEventListener('pointerup', this.handleDragPointerUp);
654
+ document.removeEventListener('pointercancel', this.cancelDrag);
655
+ this.dragIndex = null;
656
+ this.dropIndicator = null;
657
+ this.dragGhost?.remove();
658
+ this.dragGhost = null;
659
+ document.body.style.cursor = '';
660
+ };
661
+
662
+ private createDragGhost(index: number) {
663
+ const blocks = this.shadowRoot?.querySelectorAll<HTMLElement>('.remarkd-editor__block');
664
+ const rendered = blocks?.[index]?.querySelector<HTMLElement>('.remarkd-editor__rendered');
665
+ if (!rendered) return;
666
+ const ghost = rendered.cloneNode(true) as HTMLElement;
667
+ ghost.classList.add('remarkd-editor__ghost');
668
+ ghost.style.width = `${rendered.offsetWidth}px`;
669
+ this.shadowRoot?.querySelector('.remarkd-editor')?.appendChild(ghost);
670
+ this.dragGhost = ghost;
671
+ }
672
+
673
+ private moveDragGhost(x: number, y: number) {
674
+ if (this.dragGhost) this.dragGhost.style.transform = `translate(${x + 10}px, ${y + 10}px)`;
675
+ }
676
+
677
+ private pickImage(index: number) {
678
+ if (this.disabled || this.readonly) return;
679
+ this.imagePickerIndex = index;
680
+ }
681
+
682
+ private closeImagePicker = () => {
683
+ this.imagePickerIndex = null;
684
+ };
685
+
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);
692
+ };
693
+
694
+ private async insertImage(file: File, index: number) {
695
+ try {
696
+ const path = await this.uploadImage(file);
697
+ const alt = file.name.replace(/[[\],]/g, '');
698
+ this.addBlockAt(index, `image::${path}[${alt}]`);
699
+ } catch (error) {
700
+ console.error('[zn-remarkd-editor] image upload failed', error);
701
+ }
702
+ }
703
+
704
+ private async uploadImage(file: File): Promise<string> {
705
+ if (!this.attachmentUrl) throw new Error('No attachment-url configured');
706
+ const fd = new FormData();
707
+ fd.append('filename', file.name);
708
+ fd.append('size', file.size.toString());
709
+ fd.append('mimeType', file.type);
710
+
711
+ const res = await fetch(this.attachmentUrl, {method: 'POST', body: fd});
712
+ if (!res.ok) throw new Error(`Upload request failed: ${res.status}`);
713
+ const data = await res.json() as UploadResponse;
714
+
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, {
720
+ method: 'PUT',
721
+ headers: {'Content-Type': file.type},
722
+ body: file,
723
+ });
724
+ if (!put.ok) throw new Error(`Upload failed: ${put.status}`);
725
+ return data.uploadPath;
726
+ }
727
+
728
+ private autosize(input: HTMLTextAreaElement) {
729
+ input.style.height = 'auto';
730
+ input.style.height = `${input.scrollHeight}px`;
731
+ }
732
+
733
+ private handleToolbarInsert(item: BlockType) {
734
+ if (this.editingIndex !== null) this.suppressBlurCommit = true;
735
+ const index = this.editingIndex !== null ? this.commitEdit() : this.blocks.length;
736
+ if (item.image) {
737
+ this.pickImage(index);
738
+ } else {
739
+ this.insertDraftBlock(index, item.prefix ?? '');
740
+ }
741
+ }
742
+
743
+ private renderSlashMenu() {
744
+ const items = this.filteredSlashItems;
745
+ if (!items.length) return '';
746
+ return html`
747
+ <div part="slash-menu" class="remarkd-editor__slash-menu">
748
+ ${items.map((item, i) => html`
749
+ <button type="button"
750
+ class=${classMap({
751
+ 'remarkd-editor__slash-item': true,
752
+ 'remarkd-editor__slash-item--active': i === this.slashActiveIndex,
753
+ })}
754
+ @mousedown=${(e: Event) => {
755
+ e.preventDefault();
756
+ this.applySlashItem(item);
757
+ }}>
758
+ <zn-icon src=${item.icon} size="16"></zn-icon>
759
+ <span>${item.label}</span>
760
+ </button>`)}
761
+ </div>`;
762
+ }
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
+
830
+ private renderBlock(block: string, index: number) {
831
+ if (this.editingIndex === index) {
832
+ if (this.imageEdit) {
833
+ return this.renderImageControls();
834
+ }
835
+ return html`
836
+ <div class="remarkd-editor__edit-wrap remarkd-rendered">
837
+ <div class=${classMap({
838
+ 'remarkd-editor__edit-shell': true,
839
+ [this.editShell]: !!this.editShell,
840
+ })}>
841
+ <textarea part="input"
842
+ class="remarkd-editor__input"
843
+ rows="1"
844
+ .value=${this.editingDraft}
845
+ @input=${this.handleDraftInput}
846
+ @keydown=${this.handleEditKeydown}
847
+ @paste=${this.handleEditPaste}
848
+ @blur=${this.handleEditBlur}></textarea>
849
+ </div>
850
+ ${this.slashMenuOpen ? this.renderSlashMenu() : ''}
851
+ </div>`;
852
+ }
853
+
854
+ return html`
855
+ <div part="block"
856
+ class=${classMap({
857
+ 'remarkd-editor__block': true,
858
+ 'remarkd-editor__block--dragging': this.dragIndex === index,
859
+ 'remarkd-editor__block--drop-before': this.dropIndicator === index,
860
+ 'remarkd-editor__block--drop-after': this.dropIndicator === index + 1 && index === this.blocks.length - 1,
861
+ })}>
862
+ ${this.disabled || this.readonly ? '' : html`
863
+ <div class="remarkd-editor__actions">
864
+ <span class="remarkd-editor__drag-handle"
865
+ title="Drag to move"
866
+ @pointerdown=${this.handleHandlePointerDown}>
867
+ <zn-icon src="grip-vertical@lu" size="16"></zn-icon>
868
+ </span>
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>`}
877
+ <div part="rendered" class="remarkd-editor__rendered remarkd-rendered"
878
+ @click=${(e: MouseEvent) => this.handleRenderedClick(e, index)}>${unsafeHTML(remarkdParse(block))}
879
+ </div>
880
+ </div>`;
881
+ }
882
+
883
+ render() {
884
+ const editable = !this.disabled && !this.readonly;
885
+ return html`
886
+ <div part="base"
887
+ class=${classMap({
888
+ 'remarkd-editor': true,
889
+ 'remarkd-editor--disabled': this.disabled,
890
+ 'remarkd-editor--readonly': this.readonly,
891
+ })}
892
+ @dragover=${this.handleDragOver}
893
+ @drop=${this.handleDrop}>
894
+ ${editable ? html`
895
+ <div part="toolbar" class="remarkd-editor__toolbar">
896
+ ${BLOCK_TYPES.map(item => html`
897
+ <zn-button type="button" icon-button plain icon=${item.icon} icon-size="18"
898
+ tooltip=${item.label}
899
+ @click=${() => this.handleToolbarInsert(item)}></zn-button>`)}
900
+ </div>` : ''}
901
+ <div class="remarkd-editor__body">
902
+ ${this.renderBody()}
903
+ <div class="remarkd-editor__add" @click=${() => this.insertDraftBlock(this.blocks.length)}>
904
+ ${this.blocks.length === 0 && this.editingIndex === null ? this.placeholder : ''}
905
+ </div>
906
+ </div>
907
+ <textarea class="remarkd-editor__validation"
908
+ .value=${this.value}
909
+ ?required=${this.required}
910
+ tabindex="-1"
911
+ aria-hidden="true"></textarea>
912
+ </div>`;
913
+ }
914
+
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() {
925
+ return html`
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>`;
936
+ }
937
+ }