@refrakt-md/editor 0.8.1 → 0.8.3

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.
Files changed (42) hide show
  1. package/app/dist/assets/{index-D3TQo8gu.js → index-3MvwKRVQ.js} +1 -1
  2. package/app/dist/assets/{index-CeU_s7BB.js → index-B7e694w6.js} +1 -1
  3. package/app/dist/assets/{index-DzHt8ZRh.js → index-BBljOYQu.js} +1 -1
  4. package/app/dist/assets/{index-C72UC2ga.js → index-BEGy_i8o.js} +1 -1
  5. package/app/dist/assets/{index-CqHjo2YT.js → index-BGy7ixjW.js} +1 -1
  6. package/app/dist/assets/{index-DVM3uoxc.js → index-BaLgiiKk.js} +1 -1
  7. package/app/dist/assets/{index-CW02bulk.js → index-BjlNcvOf.js} +1 -1
  8. package/app/dist/assets/{index-DmY6uqAw.js → index-CKfKYVw7.js} +1 -1
  9. package/app/dist/assets/{index-BLuaHLN3.js → index-COFbngzR.js} +1 -1
  10. package/app/dist/assets/{index-BBinZAiy.js → index-CPEo_rvd.js} +1 -1
  11. package/app/dist/assets/{index-D_Y6J00B.js → index-CQDCT-XT.js} +1 -1
  12. package/app/dist/assets/{index-COIPZ34u.js → index-CUmEjEeR.js} +1 -1
  13. package/app/dist/assets/{index-BgCNqcSo.js → index-CeV-Af4N.js} +1 -1
  14. package/app/dist/assets/{index-DW2zI-Ss.js → index-ChbH55h5.js} +1 -1
  15. package/app/dist/assets/index-CzvG5PZT.css +1 -0
  16. package/app/dist/assets/{index-ZLvRNfLb.js → index-D9-aYc3I.js} +1 -1
  17. package/app/dist/assets/{index-BwFn9q4x.js → index-DezxtfNV.js} +1 -1
  18. package/app/dist/assets/{index-CXFMPmtf.js → index-DrI4IfXE.js} +1 -1
  19. package/app/dist/assets/{index-DgIg-QAA.js → index-DwfxgjnU.js} +2 -2
  20. package/app/dist/assets/index-ogrpJNou.js +555 -0
  21. package/app/dist/index.html +2 -2
  22. package/app/src/lib/api/client.ts +32 -0
  23. package/app/src/lib/components/ActionEditPopover.svelte +41 -19
  24. package/app/src/lib/components/BlockCard.svelte +74 -17
  25. package/app/src/lib/components/BlockEditPanel.svelte +142 -9
  26. package/app/src/lib/components/BlockEditor.svelte +534 -48
  27. package/app/src/lib/components/CodeEditPopover.svelte +281 -63
  28. package/app/src/lib/components/ContentModelTree.svelte +340 -67
  29. package/app/src/lib/components/IconPickerPopover.svelte +389 -0
  30. package/app/src/lib/components/ImageEditPopover.svelte +519 -0
  31. package/app/src/lib/components/InlineEditPopover.svelte +79 -56
  32. package/app/src/lib/components/InlineEditor.svelte +15 -5
  33. package/app/src/lib/components/ProseBlockCard.svelte +446 -0
  34. package/app/src/lib/components/ProseEditPanel.svelte +470 -0
  35. package/app/src/lib/components/RuneAttributes.svelte +51 -0
  36. package/app/src/lib/editor/block-parser.ts +211 -9
  37. package/dist/server.d.ts.map +1 -1
  38. package/dist/server.js +129 -1
  39. package/dist/server.js.map +1 -1
  40. package/package.json +6 -6
  41. package/app/dist/assets/index-BD2EBUrQ.css +0 -1
  42. package/app/dist/assets/index-BlAOhWAQ.js +0 -453
@@ -26,6 +26,7 @@
26
26
 
27
27
  let container: HTMLElement;
28
28
  let view = $state<EditorView | undefined>(undefined);
29
+ let lastEmitted = '';
29
30
 
30
31
  const langCompartment = new Compartment();
31
32
  const markdocCompartment = new Compartment();
@@ -145,7 +146,8 @@
145
146
  ]),
146
147
  EditorView.updateListener.of((update) => {
147
148
  if (update.docChanged) {
148
- onchange(update.state.doc.toString());
149
+ lastEmitted = update.state.doc.toString();
150
+ onchange(lastEmitted);
149
151
  }
150
152
  }),
151
153
  EditorView.lineWrapping,
@@ -214,11 +216,19 @@
214
216
  if (!editor) return;
215
217
 
216
218
  const cmContent = editor.state.doc.toString();
217
- if (current !== cmContent) {
218
- editor.dispatch({
219
- changes: { from: 0, to: editor.state.doc.length, insert: current },
220
- });
219
+ if (current === cmContent) return;
220
+
221
+ // If CM still has what we last emitted, the incoming content
222
+ // is our own edit coming back (possibly normalized) — skip sync
223
+ if (lastEmitted && cmContent === lastEmitted) {
224
+ lastEmitted = '';
225
+ return;
221
226
  }
227
+
228
+ lastEmitted = '';
229
+ editor.dispatch({
230
+ changes: { from: 0, to: editor.state.doc.length, insert: current },
231
+ });
222
232
  });
223
233
  </script>
224
234
 
@@ -0,0 +1,446 @@
1
+ <script lang="ts">
2
+ import type { ProseBlock, ParsedBlock } from '../editor/block-parser.js';
3
+ import type { ThemeConfig, RendererNode } from '@refrakt-md/transform';
4
+ import { renderBlockPreview } from '../preview/block-renderer.js';
5
+ import { initRuneBehaviors } from '@refrakt-md/behaviors';
6
+ import { stripInlineMarkdown } from '../editor/inline-markdown.js';
7
+
8
+ export interface ProseElementClickInfo {
9
+ /** Type of the clicked element: heading, paragraph, fence, list, quote, hr, image */
10
+ elementType: string;
11
+ /** Plain text content of the element */
12
+ text: string;
13
+ /** Bounding rect for popover positioning */
14
+ rect: DOMRect;
15
+ /** Index of the child block within the prose block's children array */
16
+ childIndex: number;
17
+ /** For headings: the level (1-6) */
18
+ headingLevel?: number;
19
+ /** For fences: the language string */
20
+ fenceLanguage?: string;
21
+ /** For fences: the code content */
22
+ fenceCode?: string;
23
+ }
24
+
25
+ interface Props {
26
+ block: ProseBlock;
27
+ themeConfig: ThemeConfig | null;
28
+ themeCss: string;
29
+ highlightCss?: string;
30
+ highlightTransform?: ((tree: RendererNode) => RendererNode) | null;
31
+ communityTags?: Record<string, unknown> | null;
32
+ communityPostTransforms?: Record<string, Function> | null;
33
+ communityStyles?: Record<string, Record<string, unknown>> | null;
34
+ aggregated?: Record<string, unknown>;
35
+ dragHandle?: boolean;
36
+ readOnly?: boolean;
37
+ onsectionclick?: (info: ProseElementClickInfo) => void;
38
+ onblockclick?: (info: { x: number; y: number }) => void;
39
+ ondragstart: (e: DragEvent) => void;
40
+ ondragover: (e: DragEvent) => void;
41
+ ondrop: (e: DragEvent) => void;
42
+ }
43
+
44
+ let {
45
+ block,
46
+ themeConfig,
47
+ themeCss,
48
+ highlightCss: hlCssProp = '',
49
+ highlightTransform: hlTransformProp = null,
50
+ communityTags = null,
51
+ communityPostTransforms = null,
52
+ communityStyles = null,
53
+ aggregated = {},
54
+ dragHandle = true,
55
+ readOnly = false,
56
+ onsectionclick,
57
+ onblockclick,
58
+ ondragstart,
59
+ ondragover,
60
+ ondrop,
61
+ }: Props = $props();
62
+
63
+ // ── Shadow DOM preview ─────────────────────────────────────
64
+ let previewContainer: HTMLDivElement | undefined = $state();
65
+ let shadowRoot: ShadowRoot | null = null;
66
+ let previewDebounce: ReturnType<typeof setTimeout>;
67
+ let behaviorCleanup: (() => void) | null = null;
68
+
69
+ /** Block-level element tag names we consider hoverable prose targets */
70
+ const PROSE_BLOCK_TAGS = new Set([
71
+ 'H1', 'H2', 'H3', 'H4', 'H5', 'H6',
72
+ 'P', 'PRE', 'UL', 'OL', 'BLOCKQUOTE', 'HR', 'TABLE',
73
+ ]);
74
+
75
+ /** Human-readable label for a DOM element tag */
76
+ function tagLabel(tag: string): string {
77
+ if (tag.startsWith('H') && tag.length === 2) return `h${tag[1]}`;
78
+ const labels: Record<string, string> = {
79
+ P: 'paragraph',
80
+ PRE: 'code',
81
+ UL: 'list',
82
+ OL: 'list',
83
+ BLOCKQUOTE: 'quote',
84
+ HR: 'divider',
85
+ TABLE: 'table',
86
+ };
87
+ return labels[tag] ?? tag.toLowerCase();
88
+ }
89
+
90
+ /** Find the nearest block-level prose target from the event target */
91
+ function findProseTarget(start: HTMLElement, root: HTMLElement): HTMLElement | null {
92
+ let el: HTMLElement | null = start;
93
+ while (el && el !== root) {
94
+ if (PROSE_BLOCK_TAGS.has(el.tagName)) return el;
95
+ el = el.parentElement;
96
+ }
97
+ return null;
98
+ }
99
+
100
+ /** Map a DOM element back to its child index in the prose block.
101
+ * Uses text-matching: compare the element's text content against each child's stripped text. */
102
+ function findChildIndex(el: HTMLElement): number {
103
+ const elText = (el.textContent ?? '').trim();
104
+ const tag = el.tagName;
105
+
106
+ for (let i = 0; i < block.children.length; i++) {
107
+ const child = block.children[i];
108
+
109
+ // Match by type + text content
110
+ if (tag.startsWith('H') && tag.length === 2 && child.type === 'heading') {
111
+ const childText = stripInlineMarkdown((child as any).text ?? '').trim();
112
+ if (elText === childText) return i;
113
+ } else if (tag === 'P' && child.type === 'paragraph') {
114
+ const childText = stripInlineMarkdown(child.source).trim();
115
+ if (elText === childText) return i;
116
+ } else if (tag === 'PRE' && child.type === 'fence') {
117
+ // Code fences render as <pre><code>...</code></pre>
118
+ const codeEl = el.querySelector('code');
119
+ const codeText = (codeEl ?? el).textContent?.trim() ?? '';
120
+ const childCode = ((child as any).code ?? '').trim();
121
+ if (codeText === childCode) return i;
122
+ } else if ((tag === 'UL' || tag === 'OL') && child.type === 'list') {
123
+ const childText = child.source.replace(/^[-*+\d.]+\s*/gm, '').trim();
124
+ if (elText.includes(childText.slice(0, 30)) || childText.includes(elText.slice(0, 30))) return i;
125
+ } else if (tag === 'BLOCKQUOTE' && child.type === 'quote') {
126
+ const childText = child.source.replace(/^>\s*/gm, '').trim();
127
+ if (elText === childText) return i;
128
+ } else if (tag === 'HR' && child.type === 'hr') {
129
+ return i;
130
+ }
131
+ }
132
+
133
+ return -1;
134
+ }
135
+
136
+ /** Attach hover + click handlers to the shadow DOM wrapper */
137
+ function attachProseHandlers(wrapper: HTMLElement) {
138
+ let hoveredEl: HTMLElement | null = null;
139
+ let removeTimer: ReturnType<typeof setTimeout> | null = null;
140
+ const HOVER_DEBOUNCE = 75;
141
+
142
+ // Tooltip element
143
+ const tooltip = document.createElement('div');
144
+ tooltip.className = 'rf-edit-tooltip';
145
+ wrapper.appendChild(tooltip);
146
+
147
+ function showTooltip(label: string, me: MouseEvent) {
148
+ tooltip.textContent = label;
149
+ tooltip.style.left = `${me.clientX + 12}px`;
150
+ tooltip.style.top = `${me.clientY + 12}px`;
151
+ tooltip.classList.add('rf-tooltip-visible');
152
+ }
153
+
154
+ function hideTooltip() {
155
+ tooltip.classList.remove('rf-tooltip-visible');
156
+ }
157
+
158
+ wrapper.addEventListener('mouseover', (e) => {
159
+ const target = findProseTarget(e.target as HTMLElement, wrapper);
160
+
161
+ if (removeTimer !== null) {
162
+ clearTimeout(removeTimer);
163
+ removeTimer = null;
164
+ }
165
+
166
+ if (target === hoveredEl) {
167
+ // Same target — already highlighted
168
+ } else {
169
+ hoveredEl?.classList.remove('rf-editable-hover');
170
+ hoveredEl = target;
171
+ hoveredEl?.classList.add('rf-editable-hover');
172
+ }
173
+
174
+ if (target) {
175
+ wrapper.classList.remove('rf-block-hover');
176
+ showTooltip(tagLabel(target.tagName), e as MouseEvent);
177
+ } else if (onblockclick) {
178
+ wrapper.classList.add('rf-block-hover');
179
+ showTooltip('prose', e as MouseEvent);
180
+ } else {
181
+ hideTooltip();
182
+ }
183
+ });
184
+
185
+ wrapper.addEventListener('mousemove', (e) => {
186
+ if (tooltip.classList.contains('rf-tooltip-visible')) {
187
+ tooltip.style.left = `${e.clientX + 12}px`;
188
+ tooltip.style.top = `${e.clientY + 12}px`;
189
+ }
190
+ });
191
+
192
+ wrapper.addEventListener('mouseout', (e) => {
193
+ const related = (e as MouseEvent).relatedTarget as HTMLElement | null;
194
+ wrapper.classList.remove('rf-block-hover');
195
+ if (hoveredEl && (!related || !hoveredEl.contains(related))) {
196
+ if (removeTimer !== null) clearTimeout(removeTimer);
197
+ const el = hoveredEl;
198
+ removeTimer = setTimeout(() => {
199
+ el.classList.remove('rf-editable-hover');
200
+ if (hoveredEl === el) hoveredEl = null;
201
+ removeTimer = null;
202
+ }, HOVER_DEBOUNCE);
203
+ hideTooltip();
204
+ }
205
+ });
206
+
207
+ wrapper.addEventListener('click', (e) => {
208
+ hideTooltip();
209
+ const target = findProseTarget(e.target as HTMLElement, wrapper);
210
+ if (!target) {
211
+ // Dead space — open prose edit panel
212
+ onblockclick?.({ x: (e as MouseEvent).clientX, y: (e as MouseEvent).clientY });
213
+ return;
214
+ }
215
+
216
+ e.preventDefault();
217
+ e.stopPropagation();
218
+
219
+ const childIndex = findChildIndex(target);
220
+ if (childIndex === -1) return;
221
+
222
+ const child = block.children[childIndex];
223
+ const rect = target.getBoundingClientRect();
224
+ const tag = target.tagName;
225
+
226
+ const info: ProseElementClickInfo = {
227
+ elementType: child.type,
228
+ text: (target.textContent ?? '').trim(),
229
+ rect,
230
+ childIndex,
231
+ };
232
+
233
+ if (child.type === 'heading') {
234
+ info.headingLevel = (child as any).level;
235
+ } else if (child.type === 'fence') {
236
+ info.fenceLanguage = (child as any).language ?? '';
237
+ info.fenceCode = (child as any).code ?? '';
238
+ }
239
+
240
+ onsectionclick?.(info);
241
+ });
242
+ }
243
+
244
+ // ── Hover CSS injected into shadow DOM ───────────────────────
245
+ const hoverCss = `
246
+ /* Wrapper: inset border on dead-space hover */
247
+ .rf-preview-wrapper {
248
+ transition: box-shadow 150ms ease;
249
+ cursor: pointer;
250
+ }
251
+ .rf-preview-wrapper.rf-block-hover {
252
+ box-shadow: inset 0 0 0 2px rgba(59, 130, 246, 0.5);
253
+ border-radius: 4px;
254
+ }
255
+ /* Block-level elements: invisible outline + transition */
256
+ h1, h2, h3, h4, h5, h6, p, pre, ul, ol, blockquote, hr, table {
257
+ outline: 2px dashed transparent;
258
+ outline-offset: 4px;
259
+ border-radius: 4px;
260
+ transition: outline-color 150ms ease;
261
+ }
262
+ /* Hover affordance */
263
+ .rf-editable-hover {
264
+ outline-color: rgba(59, 130, 246, 0.5);
265
+ }
266
+ .rf-editable-hover, .rf-editable-hover * {
267
+ cursor: text !important;
268
+ }
269
+ /* Hover tooltip */
270
+ .rf-edit-tooltip {
271
+ position: fixed;
272
+ padding: 2px 8px;
273
+ font-family: system-ui, -apple-system, sans-serif;
274
+ font-size: 11px;
275
+ font-weight: 600;
276
+ line-height: 1.4;
277
+ color: #fff;
278
+ background: rgba(30, 41, 59, 0.9);
279
+ border-radius: 4px;
280
+ pointer-events: none;
281
+ z-index: 10000;
282
+ white-space: nowrap;
283
+ opacity: 0;
284
+ transition: opacity 120ms ease;
285
+ }
286
+ .rf-edit-tooltip.rf-tooltip-visible {
287
+ opacity: 1;
288
+ }
289
+ `;
290
+
291
+ // ── Preview rendering ───────────────────────────────────────
292
+ $effect(() => {
293
+ if (!previewContainer || !themeConfig) return;
294
+
295
+ if (!shadowRoot || shadowRoot.host !== previewContainer) {
296
+ shadowRoot = previewContainer.attachShadow({ mode: 'open' });
297
+ }
298
+
299
+ const source = block.source;
300
+ const config = themeConfig;
301
+ const css = themeCss;
302
+ const hlCss = hlCssProp || '';
303
+ const hlTransform = hlTransformProp;
304
+
305
+ clearTimeout(previewDebounce);
306
+ previewDebounce = setTimeout(() => {
307
+ if (!shadowRoot) return;
308
+
309
+ if (behaviorCleanup) {
310
+ behaviorCleanup();
311
+ behaviorCleanup = null;
312
+ }
313
+
314
+ try {
315
+ const { html } = renderBlockPreview(
316
+ source,
317
+ config,
318
+ hlTransform,
319
+ communityTags ?? undefined,
320
+ communityPostTransforms ?? undefined,
321
+ aggregated,
322
+ communityStyles ?? undefined,
323
+ );
324
+ const scopedCss = css.replace(/:root/g, ':host');
325
+ shadowRoot.innerHTML = `<style>${scopedCss}
326
+ ${hlCss}
327
+ :host { display: block; }
328
+ .rf-preview-wrapper {
329
+ font-family: var(--rf-font-sans, system-ui, -apple-system, sans-serif);
330
+ color: var(--rf-color-text, #1a1a2e);
331
+ line-height: 1.6;
332
+ --rf-content-max: calc(100% - 6rem);
333
+ --rf-content-gutter: 1.5rem;
334
+ }
335
+ .rf-preview-wrapper > article {
336
+ display: grid;
337
+ grid-template-columns:
338
+ [full-start] 1fr
339
+ [wide-start] minmax(0, var(--rf-wide-inset, 8rem))
340
+ [content-start] min(var(--rf-content-max), 100% - var(--rf-content-gutter) * 2)
341
+ [content-end] minmax(0, var(--rf-wide-inset, 8rem))
342
+ [wide-end] 1fr
343
+ [full-end];
344
+ }
345
+ .rf-preview-wrapper > article > * { grid-column: content; }
346
+ ${!readOnly && onsectionclick ? hoverCss : ''}
347
+ </style>
348
+ <div class="rf-preview-wrapper">${html}</div>`;
349
+
350
+ const wrapper = shadowRoot.querySelector('.rf-preview-wrapper') as HTMLElement | null;
351
+ if (wrapper) {
352
+ behaviorCleanup = initRuneBehaviors(wrapper);
353
+ wrapper.addEventListener('click', (e) => {
354
+ const anchor = (e.target as HTMLElement).closest('a');
355
+ if (anchor) e.preventDefault();
356
+ }, true);
357
+
358
+ // Attach inline-edit hover + click handlers for prose elements
359
+ if (!readOnly && onsectionclick) {
360
+ attachProseHandlers(wrapper);
361
+ }
362
+ }
363
+ } catch {
364
+ if (shadowRoot) {
365
+ shadowRoot.innerHTML = `<style>:host { display: block; padding: 0.75rem 1.5rem; color: #999; font-family: system-ui; font-size: 12px; }</style><em>Preview unavailable</em>`;
366
+ }
367
+ }
368
+ }, 50);
369
+
370
+ return () => {
371
+ clearTimeout(previewDebounce);
372
+ if (behaviorCleanup) {
373
+ behaviorCleanup();
374
+ behaviorCleanup = null;
375
+ }
376
+ };
377
+ });
378
+
379
+ </script>
380
+
381
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
382
+ <div
383
+ class="prose-card"
384
+ draggable={dragHandle ? 'true' : 'false'}
385
+ ondragstart={ondragstart}
386
+ ondragover={ondragover}
387
+ ondrop={ondrop}
388
+ >
389
+ {#if dragHandle}
390
+ <span class="prose-card__drag" title="Drag to reorder">
391
+ <svg width="8" height="14" viewBox="0 0 8 14" fill="currentColor">
392
+ <circle cx="2" cy="2" r="1.2" />
393
+ <circle cx="6" cy="2" r="1.2" />
394
+ <circle cx="2" cy="7" r="1.2" />
395
+ <circle cx="6" cy="7" r="1.2" />
396
+ <circle cx="2" cy="12" r="1.2" />
397
+ <circle cx="6" cy="12" r="1.2" />
398
+ </svg>
399
+ </span>
400
+ {/if}
401
+
402
+ {#if themeConfig}
403
+ <div class="prose-card__preview" bind:this={previewContainer}></div>
404
+ {/if}
405
+ </div>
406
+
407
+ <style>
408
+ .prose-card {
409
+ position: relative;
410
+ }
411
+
412
+ /* Drag handle */
413
+ .prose-card__drag {
414
+ position: absolute;
415
+ left: 6px;
416
+ top: 6px;
417
+ cursor: grab;
418
+ color: var(--ed-text-muted);
419
+ padding: 0.2rem;
420
+ user-select: none;
421
+ display: flex;
422
+ align-items: center;
423
+ opacity: 0;
424
+ transition: opacity var(--ed-transition-fast);
425
+ z-index: 1;
426
+ border-radius: var(--ed-radius-sm);
427
+ }
428
+
429
+ .prose-card:hover .prose-card__drag {
430
+ opacity: 0.35;
431
+ }
432
+
433
+ .prose-card:hover .prose-card__drag:hover {
434
+ opacity: 1;
435
+ background: var(--ed-surface-2);
436
+ }
437
+
438
+ .prose-card__drag:active {
439
+ cursor: grabbing;
440
+ }
441
+
442
+ /* Preview */
443
+ .prose-card__preview {
444
+ overflow: hidden;
445
+ }
446
+ </style>