@kubex/zinc 1.1.63 → 1.1.64
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 +928 -3
- package/dist/vscode.html-custom-data.json +47 -0
- package/dist/web-types.json +109 -1
- package/dist/zn.d.ts +152 -0
- package/dist/zn.min.js +523 -418
- package/docs/pages/components/remarkd-editor.md +103 -0
- package/package.json +3 -2
- package/src/components/file/file.component.ts +16 -1
- package/src/components/query-builder/query-builder.component.ts +14 -6
- package/src/components/remarkd-editor/index.ts +12 -0
- package/src/components/remarkd-editor/remarkd-editor.component.ts +752 -0
- package/src/components/remarkd-editor/remarkd-editor.scss +241 -0
- package/src/components/remarkd-editor/remarkd-editor.test.ts +147 -0
- package/src/components/tabs/tabs.component.ts +9 -4
- package/src/components/tabs/tabs.test.ts +42 -1
- package/src/zinc.ts +1 -0
|
@@ -0,0 +1,752 @@
|
|
|
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 ZnDialog from "../dialog";
|
|
12
|
+
import type ZnFile from "../file";
|
|
13
|
+
import type ZnInput from "../input";
|
|
14
|
+
|
|
15
|
+
import styles from './remarkd-editor.scss';
|
|
16
|
+
|
|
17
|
+
interface UploadResponse {
|
|
18
|
+
uploadPath: string;
|
|
19
|
+
uploadUrl: string;
|
|
20
|
+
originalFilename: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface BlockType {
|
|
24
|
+
label: string;
|
|
25
|
+
icon: string;
|
|
26
|
+
prefix?: string;
|
|
27
|
+
image?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const BLOCK_TYPES: BlockType[] = [
|
|
31
|
+
{label: 'Text', icon: 'text@lu', prefix: ''},
|
|
32
|
+
{label: 'Heading 1', icon: 'heading-1@lu', prefix: '# '},
|
|
33
|
+
{label: 'Heading 2', icon: 'heading-2@lu', prefix: '## '},
|
|
34
|
+
{label: 'Heading 3', icon: 'heading-3@lu', prefix: '### '},
|
|
35
|
+
{label: 'Note', icon: 'info@lu', prefix: 'NOTE: '},
|
|
36
|
+
{label: 'Tip', icon: 'lightbulb@lu', prefix: 'TIP: '},
|
|
37
|
+
{label: 'Warning', icon: 'triangle-alert@lu', prefix: 'WARNING: '},
|
|
38
|
+
{label: 'Code', icon: 'code@lu', prefix: '```\n\n```'},
|
|
39
|
+
{label: 'Image', icon: 'image@lu', image: true},
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @summary A Notion-style block editor for remarkd content. Blocks render inline; click one to edit its source.
|
|
44
|
+
* @documentation https://zinc.style/components/remarkd-editor
|
|
45
|
+
* @status experimental
|
|
46
|
+
* @since 1.0
|
|
47
|
+
*
|
|
48
|
+
* @dependency zn-button
|
|
49
|
+
* @dependency zn-button-group
|
|
50
|
+
* @dependency zn-icon
|
|
51
|
+
* @dependency zn-dialog
|
|
52
|
+
* @dependency zn-file
|
|
53
|
+
* @dependency zn-input
|
|
54
|
+
*
|
|
55
|
+
* @event zn-input - Emitted on each keystroke while editing a block.
|
|
56
|
+
* @event zn-change - Emitted when a block edit is committed and the value changes.
|
|
57
|
+
*
|
|
58
|
+
* @csspart base - The component's base wrapper.
|
|
59
|
+
* @csspart toolbar - The always-visible block-insert toolbar.
|
|
60
|
+
* @csspart block - A rendered block wrapper.
|
|
61
|
+
* @csspart rendered - The rendered remarkd output of a block.
|
|
62
|
+
* @csspart input - The textarea shown while editing a block.
|
|
63
|
+
* @csspart slash-menu - The context menu opened by typing "/" in a block.
|
|
64
|
+
*/
|
|
65
|
+
export default class ZnRemarkdEditor extends ZincElement implements ZincFormControl {
|
|
66
|
+
static styles: CSSResultGroup = unsafeCSS(styles);
|
|
67
|
+
|
|
68
|
+
private readonly formControlController = new FormControlController(this, {
|
|
69
|
+
assumeInteractionOn: ['zn-input', 'zn-change'],
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
private editingDraft = '';
|
|
73
|
+
private suppressValueSync = false;
|
|
74
|
+
private suppressBlurCommit = false;
|
|
75
|
+
|
|
76
|
+
@query('.remarkd-editor__validation') private validationInput: HTMLTextAreaElement;
|
|
77
|
+
|
|
78
|
+
@state() private blocks: string[] = [];
|
|
79
|
+
@state() private editingIndex: number | null = null;
|
|
80
|
+
@state() private slashMenuOpen = false;
|
|
81
|
+
@state() private slashQuery = '';
|
|
82
|
+
@state() private slashActiveIndex = 0;
|
|
83
|
+
@state() private imageDialogOpen = false;
|
|
84
|
+
@state() private dropIndicator: number | null = null;
|
|
85
|
+
@state() private dragIndex: number | null = null;
|
|
86
|
+
@state() private editShell = '';
|
|
87
|
+
|
|
88
|
+
private imageInsertIndex = 0;
|
|
89
|
+
private pendingDragHandle: HTMLElement | null = null;
|
|
90
|
+
private dragStartX = 0;
|
|
91
|
+
private dragStartY = 0;
|
|
92
|
+
private dragGhost: HTMLElement | null = null;
|
|
93
|
+
|
|
94
|
+
/** The name of the control, submitted as part of form data. */
|
|
95
|
+
@property() name = '';
|
|
96
|
+
|
|
97
|
+
/** The current remarkd source. */
|
|
98
|
+
@property() value = '';
|
|
99
|
+
|
|
100
|
+
/** The default value — used when resetting the form. */
|
|
101
|
+
@defaultValue() defaultValue = '';
|
|
102
|
+
|
|
103
|
+
/** Placeholder shown when the document is empty. */
|
|
104
|
+
@property() placeholder = 'Type something…';
|
|
105
|
+
|
|
106
|
+
/**
|
|
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.
|
|
111
|
+
*/
|
|
112
|
+
@property({attribute: 'attachment-url'}) attachmentUrl = '';
|
|
113
|
+
|
|
114
|
+
/** Makes the editor required for form submission. */
|
|
115
|
+
@property({type: Boolean, reflect: true}) required = false;
|
|
116
|
+
|
|
117
|
+
/** Makes the editor read-only. */
|
|
118
|
+
@property({type: Boolean, reflect: true}) readonly = false;
|
|
119
|
+
|
|
120
|
+
/** Disables the editor. */
|
|
121
|
+
@property({type: Boolean, reflect: true}) disabled = false;
|
|
122
|
+
|
|
123
|
+
get validity(): ValidityState {
|
|
124
|
+
return this.validationInput?.validity;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
get validationMessage(): string {
|
|
128
|
+
return this.validationInput?.validationMessage ?? '';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
checkValidity(): boolean {
|
|
132
|
+
return this.validationInput?.checkValidity() ?? true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
getForm(): HTMLFormElement | null {
|
|
136
|
+
return this.formControlController.getForm();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
reportValidity(): boolean {
|
|
140
|
+
return this.validationInput?.reportValidity() ?? true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
setCustomValidity(message: string): void {
|
|
144
|
+
this.validationInput?.setCustomValidity(message);
|
|
145
|
+
this.formControlController.updateValidity();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Starts editing the first block, or a new block if the document is empty. */
|
|
149
|
+
focus() {
|
|
150
|
+
if (this.blocks.length) {
|
|
151
|
+
this.startEdit(0);
|
|
152
|
+
} else {
|
|
153
|
+
this.insertDraftBlock(0);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Commits any in-progress block edit. */
|
|
158
|
+
blur() {
|
|
159
|
+
this.shadowRoot?.querySelector<HTMLTextAreaElement>('.remarkd-editor__input')?.blur();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
protected firstUpdated(_changedProperties: PropertyValues) {
|
|
163
|
+
super.firstUpdated(_changedProperties);
|
|
164
|
+
this.formControlController.updateValidity();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
disconnectedCallback() {
|
|
168
|
+
super.disconnectedCallback();
|
|
169
|
+
this.cancelDrag();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
@watch('value')
|
|
173
|
+
handleValueChange() {
|
|
174
|
+
if (this.suppressValueSync) {
|
|
175
|
+
this.suppressValueSync = false;
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
this.blocks = this.splitBlocks(this.value || '');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Splits remarkd source into blocks on blank lines, keeping fenced /
|
|
183
|
+
* delimited containers (``` ==== !!!! .... ----) as single blocks.
|
|
184
|
+
*/
|
|
185
|
+
private splitBlocks(source: string): string[] {
|
|
186
|
+
const lines = source.replace(/\r\n/g, '\n').split('\n');
|
|
187
|
+
const blocks: string[] = [];
|
|
188
|
+
let current: string[] = [];
|
|
189
|
+
let fence: string | null = null;
|
|
190
|
+
|
|
191
|
+
const push = () => {
|
|
192
|
+
const text = current.join('\n').trim();
|
|
193
|
+
if (text) blocks.push(text);
|
|
194
|
+
current = [];
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
for (const line of lines) {
|
|
198
|
+
const trimmed = line.trimEnd();
|
|
199
|
+
if (fence) {
|
|
200
|
+
current.push(line);
|
|
201
|
+
if (this.closesFence(trimmed, fence)) fence = null;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const marker = this.fenceMarker(trimmed);
|
|
205
|
+
if (marker) {
|
|
206
|
+
current.push(line);
|
|
207
|
+
fence = marker;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (trimmed === '') {
|
|
211
|
+
push();
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
current.push(line);
|
|
215
|
+
}
|
|
216
|
+
push();
|
|
217
|
+
return blocks;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private fenceMarker(line: string): string | null {
|
|
221
|
+
const backticks = /^`{3,}/.exec(line);
|
|
222
|
+
if (backticks) return backticks[0];
|
|
223
|
+
if (/^(={4,}|\.{4,}|-{4,}|!{4,})$/.test(line)) return line;
|
|
224
|
+
if (/^!!\S+!!$/.test(line)) return '!!!!';
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private closesFence(line: string, fence: string): boolean {
|
|
229
|
+
const char = fence[0];
|
|
230
|
+
let count = 0;
|
|
231
|
+
while (count < line.length && line[count] === char) count++;
|
|
232
|
+
return count === line.length && count >= fence.length;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private updateBlocks(blocks: string[]) {
|
|
236
|
+
this.blocks = blocks;
|
|
237
|
+
const joined = blocks.join('\n\n');
|
|
238
|
+
if (joined !== this.value) {
|
|
239
|
+
this.suppressValueSync = true;
|
|
240
|
+
this.value = joined;
|
|
241
|
+
this.formControlController.updateValidity();
|
|
242
|
+
this.emit('zn-change');
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private handleRenderedClick(e: MouseEvent, index: number) {
|
|
247
|
+
const checkbox = (e.target as HTMLElement).closest<HTMLInputElement>('input[type="checkbox"]');
|
|
248
|
+
if (checkbox) {
|
|
249
|
+
// Toggle the task in the source rather than opening the editor.
|
|
250
|
+
e.preventDefault();
|
|
251
|
+
this.toggleCheckbox(index, checkbox);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
this.startEdit(index);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private toggleCheckbox(index: number, checkbox: HTMLInputElement) {
|
|
259
|
+
const rendered = checkbox.closest('.remarkd-editor__rendered');
|
|
260
|
+
const ordinal = Array.from(rendered?.querySelectorAll('input[type="checkbox"]') ?? []).indexOf(checkbox);
|
|
261
|
+
if (ordinal < 0) return;
|
|
262
|
+
|
|
263
|
+
let seen = -1;
|
|
264
|
+
const updated = this.blocks[index].replace(
|
|
265
|
+
/^(\s*(?:[-*+]|\d+\.)\s+)\[( |x|X)\]/gm,
|
|
266
|
+
(match, prefix: string, mark: string) => {
|
|
267
|
+
seen++;
|
|
268
|
+
if (seen !== ordinal) return match;
|
|
269
|
+
return `${prefix}[${mark === ' ' ? 'x' : ' '}]`;
|
|
270
|
+
});
|
|
271
|
+
if (updated === this.blocks[index]) return;
|
|
272
|
+
|
|
273
|
+
const blocks = [...this.blocks];
|
|
274
|
+
blocks[index] = updated;
|
|
275
|
+
this.updateBlocks(blocks);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private startEdit(index: number, draft?: string) {
|
|
279
|
+
if (this.disabled || this.readonly) return;
|
|
280
|
+
this.editingDraft = draft ?? this.blocks[index] ?? '';
|
|
281
|
+
this.editingIndex = index;
|
|
282
|
+
this.editShell = this.computeEditShell(this.editingDraft);
|
|
283
|
+
this.slashMenuOpen = false;
|
|
284
|
+
void this.focusInput();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The remarkd chrome the editing block should keep, derived from its first
|
|
289
|
+
* line — so a NOTE still looks like a note while its source is edited.
|
|
290
|
+
*/
|
|
291
|
+
private computeEditShell(draft: string): string {
|
|
292
|
+
const first = (draft.split('\n', 1)[0] ?? '').trimStart();
|
|
293
|
+
const hint = /^(NOTE|TIP|WARNING|IMPORTANT|CAUTION|DANGER|SUCCESS|NOTICE):\s/.exec(first);
|
|
294
|
+
if (hint) return `hint-${hint[1].toLowerCase()}`;
|
|
295
|
+
if (first.startsWith('### ')) return 'remarkd-editor__edit-shell--h3';
|
|
296
|
+
if (first.startsWith('## ')) return 'remarkd-editor__edit-shell--h2';
|
|
297
|
+
if (first.startsWith('# ')) return 'remarkd-editor__edit-shell--h1';
|
|
298
|
+
if (first.startsWith('```')) return 'remarkd-editor__edit-shell--code';
|
|
299
|
+
return '';
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private async focusInput() {
|
|
303
|
+
await this.updateComplete;
|
|
304
|
+
const input = this.shadowRoot?.querySelector<HTMLTextAreaElement>('.remarkd-editor__input');
|
|
305
|
+
if (input) {
|
|
306
|
+
this.autosize(input);
|
|
307
|
+
input.focus();
|
|
308
|
+
input.setSelectionRange(input.value.length, input.value.length);
|
|
309
|
+
}
|
|
310
|
+
this.suppressBlurCommit = false;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private addBlockAt(index: number, content: string) {
|
|
314
|
+
if (this.disabled || this.readonly) return;
|
|
315
|
+
const blocks = [...this.blocks];
|
|
316
|
+
blocks.splice(index, 0, content);
|
|
317
|
+
this.updateBlocks(blocks);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Inserts a draft block — committed (or dropped, if left empty) on blur. */
|
|
321
|
+
private insertDraftBlock(index: number, prefill = '') {
|
|
322
|
+
if (this.disabled || this.readonly) return;
|
|
323
|
+
const blocks = [...this.blocks];
|
|
324
|
+
blocks.splice(index, 0, '');
|
|
325
|
+
this.blocks = blocks;
|
|
326
|
+
this.startEdit(index, prefill);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Blur handler for the editing textarea. Re-renders that replace the
|
|
331
|
+
* focused textarea (e.g. Shift+Enter committing and opening the next
|
|
332
|
+
* block) fire blur mid-transition — `suppressBlurCommit` masks those.
|
|
333
|
+
*/
|
|
334
|
+
private handleEditBlur = () => {
|
|
335
|
+
if (this.suppressBlurCommit) return;
|
|
336
|
+
this.commitEdit();
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
/** Commits the in-progress edit; returns the index after the committed parts. */
|
|
340
|
+
private commitEdit = (): number => {
|
|
341
|
+
this.slashMenuOpen = false;
|
|
342
|
+
if (this.editingIndex === null) return this.blocks.length;
|
|
343
|
+
const index = this.editingIndex;
|
|
344
|
+
const parts = this.splitBlocks(this.editingDraft);
|
|
345
|
+
const blocks = [...this.blocks];
|
|
346
|
+
blocks.splice(index, 1, ...parts);
|
|
347
|
+
this.editingIndex = null;
|
|
348
|
+
this.updateBlocks(blocks);
|
|
349
|
+
return index + parts.length;
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
private get filteredSlashItems(): BlockType[] {
|
|
353
|
+
const filter = this.slashQuery.toLowerCase();
|
|
354
|
+
return BLOCK_TYPES.filter(item => item.label.toLowerCase().includes(filter));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
private handleDraftInput = (e: Event) => {
|
|
358
|
+
const input = e.target as HTMLTextAreaElement;
|
|
359
|
+
this.editingDraft = input.value;
|
|
360
|
+
this.autosize(input);
|
|
361
|
+
|
|
362
|
+
const shell = this.computeEditShell(input.value);
|
|
363
|
+
if (shell !== this.editShell) {
|
|
364
|
+
this.editShell = shell;
|
|
365
|
+
void this.updateComplete.then(() => this.autosize(input));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// A leading "/" in an otherwise fresh block opens the slash menu; the rest
|
|
369
|
+
// of the line filters it.
|
|
370
|
+
if (input.value === '/') {
|
|
371
|
+
this.slashMenuOpen = true;
|
|
372
|
+
this.slashQuery = '';
|
|
373
|
+
this.slashActiveIndex = 0;
|
|
374
|
+
} else if (this.slashMenuOpen) {
|
|
375
|
+
if (input.value.startsWith('/') && !input.value.includes('\n')) {
|
|
376
|
+
this.slashQuery = input.value.slice(1);
|
|
377
|
+
this.slashActiveIndex = 0;
|
|
378
|
+
} else {
|
|
379
|
+
this.slashMenuOpen = false;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
this.emit('zn-input');
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
private handleEditKeydown = (e: KeyboardEvent) => {
|
|
387
|
+
const input = e.target as HTMLTextAreaElement;
|
|
388
|
+
|
|
389
|
+
if (this.slashMenuOpen) {
|
|
390
|
+
const items = this.filteredSlashItems;
|
|
391
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
392
|
+
e.preventDefault();
|
|
393
|
+
const step = e.key === 'ArrowDown' ? 1 : -1;
|
|
394
|
+
this.slashActiveIndex = (this.slashActiveIndex + step + items.length) % Math.max(items.length, 1);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (e.key === 'Enter' && items.length) {
|
|
398
|
+
e.preventDefault();
|
|
399
|
+
this.applySlashItem(items[this.slashActiveIndex] ?? items[0]);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
if (e.key === 'Escape') {
|
|
403
|
+
e.preventDefault();
|
|
404
|
+
this.slashMenuOpen = false;
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (e.key === 'Enter' && e.shiftKey) {
|
|
410
|
+
e.preventDefault();
|
|
411
|
+
this.suppressBlurCommit = true;
|
|
412
|
+
const next = this.commitEdit();
|
|
413
|
+
this.insertDraftBlock(next);
|
|
414
|
+
} else if (e.key === 'Escape' || (e.key === 'Enter' && (e.metaKey || e.ctrlKey))) {
|
|
415
|
+
e.preventDefault();
|
|
416
|
+
input.blur();
|
|
417
|
+
} else if (e.key === 'Backspace' && input.value === '') {
|
|
418
|
+
e.preventDefault();
|
|
419
|
+
input.blur();
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
private applySlashItem(item: BlockType) {
|
|
424
|
+
this.slashMenuOpen = false;
|
|
425
|
+
const index = this.editingIndex ?? this.blocks.length;
|
|
426
|
+
const input = this.shadowRoot?.querySelector<HTMLTextAreaElement>('.remarkd-editor__input');
|
|
427
|
+
|
|
428
|
+
if (item.image) {
|
|
429
|
+
// Drop the "/..." draft block, then run the image flow in its place.
|
|
430
|
+
this.editingDraft = '';
|
|
431
|
+
if (input) input.value = '';
|
|
432
|
+
input?.blur();
|
|
433
|
+
this.pickImage(index);
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
this.editingDraft = item.prefix ?? '';
|
|
438
|
+
if (input) {
|
|
439
|
+
input.value = this.editingDraft;
|
|
440
|
+
this.autosize(input);
|
|
441
|
+
input.focus();
|
|
442
|
+
input.setSelectionRange(input.value.length, input.value.length);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
private handleEditPaste = (e: ClipboardEvent) => {
|
|
447
|
+
const file = Array.from(e.clipboardData?.files ?? []).find(f => f.type.startsWith('image/'));
|
|
448
|
+
if (!file) return;
|
|
449
|
+
e.preventDefault();
|
|
450
|
+
const index = this.editingIndex ?? this.blocks.length;
|
|
451
|
+
void this.insertImage(file, index + 1);
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
private handleDragOver = (e: DragEvent) => {
|
|
455
|
+
if (e.dataTransfer?.types.includes('Files')) e.preventDefault();
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
private handleDrop = (e: DragEvent) => {
|
|
459
|
+
const file = Array.from(e.dataTransfer?.files ?? []).find(f => f.type.startsWith('image/'));
|
|
460
|
+
if (!file) return;
|
|
461
|
+
e.preventDefault();
|
|
462
|
+
void this.insertImage(file, this.blocks.length);
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
/** The insertion index a drop at `y` maps to, from the rendered block positions. */
|
|
466
|
+
private insertionIndexFromY(y: number): number {
|
|
467
|
+
const els = Array.from(this.shadowRoot?.querySelectorAll<HTMLElement>('.remarkd-editor__block') ?? []);
|
|
468
|
+
for (let i = 0; i < els.length; i++) {
|
|
469
|
+
const rect = els[i].getBoundingClientRect();
|
|
470
|
+
if (y < rect.top + rect.height / 2) return i;
|
|
471
|
+
}
|
|
472
|
+
return els.length;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/*
|
|
476
|
+
* Block dragging uses pointer events rather than native HTML5 drag & drop:
|
|
477
|
+
* the browser owns the cursor during a native drag (CSS can't show a
|
|
478
|
+
* grabbing hand), and WebKit's dnd support inside shadow DOM is patchy.
|
|
479
|
+
*/
|
|
480
|
+
|
|
481
|
+
private handleHandlePointerDown = (e: PointerEvent) => {
|
|
482
|
+
if (e.button !== 0) return;
|
|
483
|
+
e.preventDefault();
|
|
484
|
+
if (this.editingIndex !== null) this.commitEdit();
|
|
485
|
+
this.pendingDragHandle = e.currentTarget as HTMLElement;
|
|
486
|
+
this.dragStartX = e.clientX;
|
|
487
|
+
this.dragStartY = e.clientY;
|
|
488
|
+
// Document-level listeners for the whole drag — element-level capture is
|
|
489
|
+
// lost if a re-render replaces the handle, stranding the ghost.
|
|
490
|
+
document.addEventListener('pointermove', this.handleDragPointerMove);
|
|
491
|
+
document.addEventListener('pointerup', this.handleDragPointerUp);
|
|
492
|
+
document.addEventListener('pointercancel', this.cancelDrag);
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
private handleDragPointerMove = (e: PointerEvent) => {
|
|
496
|
+
if (!this.pendingDragHandle) return;
|
|
497
|
+
|
|
498
|
+
if (e.buttons % 2 === 0) {
|
|
499
|
+
// The primary button is no longer held — the pointerup was missed. Abort.
|
|
500
|
+
this.cancelDrag();
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (this.dragIndex === null) {
|
|
505
|
+
const moved = Math.abs(e.clientX - this.dragStartX) + Math.abs(e.clientY - this.dragStartY);
|
|
506
|
+
if (moved < 4) return;
|
|
507
|
+
// Resolve the index from the DOM at drag start, after any edit commit.
|
|
508
|
+
const block = this.pendingDragHandle.closest('.remarkd-editor__block');
|
|
509
|
+
const blocks = Array.from(this.shadowRoot?.querySelectorAll('.remarkd-editor__block') ?? []);
|
|
510
|
+
const index = block ? blocks.indexOf(block) : -1;
|
|
511
|
+
if (index < 0) {
|
|
512
|
+
this.cancelDrag();
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
this.dragIndex = index;
|
|
516
|
+
this.createDragGhost(index);
|
|
517
|
+
document.body.style.cursor = 'grabbing';
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
this.moveDragGhost(e.clientX, e.clientY);
|
|
521
|
+
this.dropIndicator = this.insertionIndexFromY(e.clientY);
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
private handleDragPointerUp = (e: PointerEvent) => {
|
|
525
|
+
const from = this.dragIndex;
|
|
526
|
+
let to = from !== null ? (this.dropIndicator ?? this.insertionIndexFromY(e.clientY)) : null;
|
|
527
|
+
this.cancelDrag();
|
|
528
|
+
if (from === null || to === null || to === from || to === from + 1) return;
|
|
529
|
+
const blocks = [...this.blocks];
|
|
530
|
+
const [moved] = blocks.splice(from, 1);
|
|
531
|
+
if (to > from) to--;
|
|
532
|
+
blocks.splice(to, 0, moved);
|
|
533
|
+
this.updateBlocks(blocks);
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
private cancelDrag = () => {
|
|
537
|
+
this.pendingDragHandle = null;
|
|
538
|
+
document.removeEventListener('pointermove', this.handleDragPointerMove);
|
|
539
|
+
document.removeEventListener('pointerup', this.handleDragPointerUp);
|
|
540
|
+
document.removeEventListener('pointercancel', this.cancelDrag);
|
|
541
|
+
this.dragIndex = null;
|
|
542
|
+
this.dropIndicator = null;
|
|
543
|
+
this.dragGhost?.remove();
|
|
544
|
+
this.dragGhost = null;
|
|
545
|
+
document.body.style.cursor = '';
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
private createDragGhost(index: number) {
|
|
549
|
+
const blocks = this.shadowRoot?.querySelectorAll<HTMLElement>('.remarkd-editor__block');
|
|
550
|
+
const rendered = blocks?.[index]?.querySelector<HTMLElement>('.remarkd-editor__rendered');
|
|
551
|
+
if (!rendered) return;
|
|
552
|
+
const ghost = rendered.cloneNode(true) as HTMLElement;
|
|
553
|
+
ghost.classList.add('remarkd-editor__ghost');
|
|
554
|
+
ghost.style.width = `${rendered.offsetWidth}px`;
|
|
555
|
+
this.shadowRoot?.querySelector('.remarkd-editor')?.appendChild(ghost);
|
|
556
|
+
this.dragGhost = ghost;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
private moveDragGhost(x: number, y: number) {
|
|
560
|
+
if (this.dragGhost) this.dragGhost.style.transform = `translate(${x + 10}px, ${y + 10}px)`;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
private pickImage(index: number) {
|
|
564
|
+
if (this.disabled || this.readonly) return;
|
|
565
|
+
this.imageInsertIndex = index;
|
|
566
|
+
this.imageDialogOpen = true;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
private handleImageDialogClose = () => {
|
|
570
|
+
this.imageDialogOpen = false;
|
|
571
|
+
};
|
|
572
|
+
|
|
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, ``);
|
|
585
|
+
}
|
|
586
|
+
this.shadowRoot?.querySelector<ZnDialog>('.remarkd-editor__image-dialog')?.hide();
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
private async insertImage(file: File, index: number) {
|
|
590
|
+
try {
|
|
591
|
+
const path = await this.uploadImage(file);
|
|
592
|
+
this.addBlockAt(index, ``);
|
|
593
|
+
} catch (error) {
|
|
594
|
+
console.error('[zn-remarkd-editor] image upload failed', error);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
private async uploadImage(file: File): Promise<string> {
|
|
599
|
+
if (!this.attachmentUrl) throw new Error('No attachment-url configured');
|
|
600
|
+
const fd = new FormData();
|
|
601
|
+
fd.append('filename', file.name);
|
|
602
|
+
fd.append('size', file.size.toString());
|
|
603
|
+
fd.append('mimeType', file.type);
|
|
604
|
+
|
|
605
|
+
const res = await fetch(this.attachmentUrl, {method: 'POST', body: fd});
|
|
606
|
+
if (!res.ok) throw new Error(`Upload request failed: ${res.status}`);
|
|
607
|
+
const data = await res.json() as UploadResponse;
|
|
608
|
+
|
|
609
|
+
const put = await fetch(data.uploadUrl, {
|
|
610
|
+
method: 'PUT',
|
|
611
|
+
headers: {'Content-Type': file.type},
|
|
612
|
+
body: file,
|
|
613
|
+
});
|
|
614
|
+
if (!put.ok) throw new Error(`Upload failed: ${put.status}`);
|
|
615
|
+
return data.uploadPath;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
private autosize(input: HTMLTextAreaElement) {
|
|
619
|
+
input.style.height = 'auto';
|
|
620
|
+
input.style.height = `${input.scrollHeight}px`;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
private handleToolbarInsert(item: BlockType) {
|
|
624
|
+
if (this.editingIndex !== null) this.suppressBlurCommit = true;
|
|
625
|
+
const index = this.editingIndex !== null ? this.commitEdit() : this.blocks.length;
|
|
626
|
+
if (item.image) {
|
|
627
|
+
this.pickImage(index);
|
|
628
|
+
} else {
|
|
629
|
+
this.insertDraftBlock(index, item.prefix ?? '');
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
private renderSlashMenu() {
|
|
634
|
+
const items = this.filteredSlashItems;
|
|
635
|
+
if (!items.length) return '';
|
|
636
|
+
return html`
|
|
637
|
+
<div part="slash-menu" class="remarkd-editor__slash-menu">
|
|
638
|
+
${items.map((item, i) => html`
|
|
639
|
+
<button type="button"
|
|
640
|
+
class=${classMap({
|
|
641
|
+
'remarkd-editor__slash-item': true,
|
|
642
|
+
'remarkd-editor__slash-item--active': i === this.slashActiveIndex,
|
|
643
|
+
})}
|
|
644
|
+
@mousedown=${(e: Event) => {
|
|
645
|
+
e.preventDefault();
|
|
646
|
+
this.applySlashItem(item);
|
|
647
|
+
}}>
|
|
648
|
+
<zn-icon src=${item.icon} size="16"></zn-icon>
|
|
649
|
+
<span>${item.label}</span>
|
|
650
|
+
</button>`)}
|
|
651
|
+
</div>`;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
private renderBlock(block: string, index: number) {
|
|
655
|
+
if (this.editingIndex === index) {
|
|
656
|
+
return html`
|
|
657
|
+
<div class="remarkd-editor__edit-wrap remarkd-rendered">
|
|
658
|
+
<div class=${classMap({
|
|
659
|
+
'remarkd-editor__edit-shell': true,
|
|
660
|
+
[this.editShell]: !!this.editShell,
|
|
661
|
+
})}>
|
|
662
|
+
<textarea part="input"
|
|
663
|
+
class="remarkd-editor__input"
|
|
664
|
+
rows="1"
|
|
665
|
+
.value=${this.editingDraft}
|
|
666
|
+
@input=${this.handleDraftInput}
|
|
667
|
+
@keydown=${this.handleEditKeydown}
|
|
668
|
+
@paste=${this.handleEditPaste}
|
|
669
|
+
@blur=${this.handleEditBlur}></textarea>
|
|
670
|
+
</div>
|
|
671
|
+
${this.slashMenuOpen ? this.renderSlashMenu() : ''}
|
|
672
|
+
</div>`;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
return html`
|
|
676
|
+
<div part="block"
|
|
677
|
+
class=${classMap({
|
|
678
|
+
'remarkd-editor__block': true,
|
|
679
|
+
'remarkd-editor__block--dragging': this.dragIndex === index,
|
|
680
|
+
'remarkd-editor__block--drop-before': this.dropIndicator === index,
|
|
681
|
+
'remarkd-editor__block--drop-after': this.dropIndicator === index + 1 && index === this.blocks.length - 1,
|
|
682
|
+
})}>
|
|
683
|
+
${this.disabled || this.readonly ? '' : html`
|
|
684
|
+
<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
|
+
<span class="remarkd-editor__drag-handle"
|
|
689
|
+
title="Drag to move"
|
|
690
|
+
@pointerdown=${this.handleHandlePointerDown}>
|
|
691
|
+
<zn-icon src="grip-vertical@lu" size="18"></zn-icon>
|
|
692
|
+
</span>
|
|
693
|
+
</div>`}
|
|
694
|
+
<div part="rendered" class="remarkd-editor__rendered remarkd-rendered"
|
|
695
|
+
@click=${(e: MouseEvent) => this.handleRenderedClick(e, index)}>${unsafeHTML(remarkdParse(block))}
|
|
696
|
+
</div>
|
|
697
|
+
</div>`;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
render() {
|
|
701
|
+
const editable = !this.disabled && !this.readonly;
|
|
702
|
+
return html`
|
|
703
|
+
<div part="base"
|
|
704
|
+
class=${classMap({
|
|
705
|
+
'remarkd-editor': true,
|
|
706
|
+
'remarkd-editor--disabled': this.disabled,
|
|
707
|
+
'remarkd-editor--readonly': this.readonly,
|
|
708
|
+
})}
|
|
709
|
+
@dragover=${this.handleDragOver}
|
|
710
|
+
@drop=${this.handleDrop}>
|
|
711
|
+
${editable ? html`
|
|
712
|
+
<div part="toolbar" class="remarkd-editor__toolbar">
|
|
713
|
+
${BLOCK_TYPES.map(item => html`
|
|
714
|
+
<zn-button type="button" icon-button plain icon=${item.icon}
|
|
715
|
+
tooltip=${item.label}
|
|
716
|
+
@click=${() => this.handleToolbarInsert(item)}></zn-button>`)}
|
|
717
|
+
</div>` : ''}
|
|
718
|
+
<div class="remarkd-editor__body">
|
|
719
|
+
${this.blocks.map((block, index) => this.renderBlock(block, index))}
|
|
720
|
+
<div class="remarkd-editor__add" @click=${() => this.insertDraftBlock(this.blocks.length)}>
|
|
721
|
+
${this.blocks.length === 0 && this.editingIndex === null ? this.placeholder : ''}
|
|
722
|
+
</div>
|
|
723
|
+
</div>
|
|
724
|
+
<textarea class="remarkd-editor__validation"
|
|
725
|
+
.value=${this.value}
|
|
726
|
+
?required=${this.required}
|
|
727
|
+
tabindex="-1"
|
|
728
|
+
aria-hidden="true"></textarea>
|
|
729
|
+
${this.imageDialogOpen ? this.renderImageDialog() : ''}
|
|
730
|
+
</div>`;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
private renderImageDialog() {
|
|
734
|
+
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>`;
|
|
751
|
+
}
|
|
752
|
+
}
|