@refrakt-md/editor 0.8.0 → 0.8.2

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-Ca-wW6uw.js → index-80NtMar1.js} +1 -1
  2. package/app/dist/assets/index-B6H6LF1M.css +1 -0
  3. package/app/dist/assets/{index-Dg4A5Pez.js → index-BDj1XPol.js} +1 -1
  4. package/app/dist/assets/{index-BfYWp0QC.js → index-BXe1fKaT.js} +1 -1
  5. package/app/dist/assets/{index-Cq0Maciq.js → index-BfxTGrHB.js} +1 -1
  6. package/app/dist/assets/{index-BsSUa0GD.js → index-Bn8ajfVl.js} +1 -1
  7. package/app/dist/assets/{index-D6vnTt4b.js → index-CCkzIGTi.js} +2 -2
  8. package/app/dist/assets/{index-BehCztSl.js → index-CXeK-dZx.js} +1 -1
  9. package/app/dist/assets/{index-iGDqoXj_.js → index-CaRBCHaX.js} +1 -1
  10. package/app/dist/assets/index-Cd12jZId.js +479 -0
  11. package/app/dist/assets/{index-D5pMhPrg.js → index-Cgbvx23V.js} +1 -1
  12. package/app/dist/assets/{index-IU6QYZAa.js → index-D5ucdUTo.js} +1 -1
  13. package/app/dist/assets/{index-CdpS6tGk.js → index-DGYxLhpR.js} +1 -1
  14. package/app/dist/assets/{index-RKEq45V5.js → index-DNJBunzP.js} +1 -1
  15. package/app/dist/assets/{index-Cgaw2jCE.js → index-DNtuldOx.js} +1 -1
  16. package/app/dist/assets/{index-BEPqnnsd.js → index-DQUOY-pF.js} +1 -1
  17. package/app/dist/assets/{index-2hOoPFOR.js → index-DskvyNKT.js} +1 -1
  18. package/app/dist/assets/{index-CLZfwYyS.js → index-aPeHMqUX.js} +1 -1
  19. package/app/dist/assets/{index-BobjskUl.js → index-dGztG-54.js} +1 -1
  20. package/app/dist/assets/{index-DHALjxX5.js → index-xo7v6nRB.js} +1 -1
  21. package/app/dist/index.html +2 -2
  22. package/app/src/lib/api/client.ts +81 -0
  23. package/app/src/lib/components/ActionEditPopover.svelte +267 -0
  24. package/app/src/lib/components/BlockCard.svelte +285 -0
  25. package/app/src/lib/components/BlockEditPanel.svelte +640 -260
  26. package/app/src/lib/components/BlockEditor.svelte +513 -52
  27. package/app/src/lib/components/CodeEditPopover.svelte +444 -0
  28. package/app/src/lib/components/ContentModelTree.svelte +835 -0
  29. package/app/src/lib/components/EditorLayout.svelte +1 -6
  30. package/app/src/lib/components/FrontmatterEditPanel.svelte +0 -1
  31. package/app/src/lib/components/IconPickerPopover.svelte +389 -0
  32. package/app/src/lib/components/ImageEditPopover.svelte +519 -0
  33. package/app/src/lib/components/InlineEditPopover.svelte +616 -0
  34. package/app/src/lib/components/RuneAttributes.svelte +51 -0
  35. package/app/src/lib/editor/block-parser.ts +424 -6
  36. package/dist/server.d.ts +1 -0
  37. package/dist/server.d.ts.map +1 -1
  38. package/dist/server.js +189 -2
  39. package/dist/server.js.map +1 -1
  40. package/package.json +6 -6
  41. package/app/dist/assets/index-98ylvoBO.css +0 -1
  42. package/app/dist/assets/index-CVzOx0nV.js +0 -372
@@ -3,6 +3,22 @@
3
3
  import type { ThemeConfig, RendererNode } from '@refrakt-md/transform';
4
4
  import { renderBlockPreview } from '../preview/block-renderer.js';
5
5
  import { initRuneBehaviors } from '@refrakt-md/behaviors';
6
+ export type EditType = 'inline' | 'link' | 'code' | 'image' | 'icon';
7
+
8
+ export interface SectionClickInfo {
9
+ dataName: string;
10
+ text: string;
11
+ rect: DOMRect;
12
+ editType: EditType;
13
+ /** For link-type edits: the href from the <a> child */
14
+ href?: string;
15
+ /** For icon-type edits: the current icon name from data-icon attribute */
16
+ iconName?: string;
17
+ }
18
+
19
+ type InteractiveTarget =
20
+ | { type: 'section'; el: HTMLElement; dataName: string; editType: EditType }
21
+ | { type: 'rune'; el: HTMLElement };
6
22
 
7
23
  interface Props {
8
24
  block: ParsedBlock;
@@ -15,6 +31,9 @@
15
31
  communityStyles?: Record<string, Record<string, unknown>> | null;
16
32
  aggregated?: Record<string, unknown>;
17
33
  dragHandle?: boolean;
34
+ readOnly?: boolean;
35
+ onsectionclick?: (info: SectionClickInfo) => void;
36
+ onruneclick?: (info: { x: number; y: number; nestedRuneIndex?: number }) => void;
18
37
  ondragstart: (e: DragEvent) => void;
19
38
  ondragover: (e: DragEvent) => void;
20
39
  ondrop: (e: DragEvent) => void;
@@ -31,6 +50,9 @@
31
50
  communityStyles = null,
32
51
  aggregated = {},
33
52
  dragHandle = true,
53
+ readOnly = false,
54
+ onsectionclick,
55
+ onruneclick,
34
56
  ondragstart,
35
57
  ondragover,
36
58
  ondrop,
@@ -42,6 +64,209 @@
42
64
  let previewDebounce: ReturnType<typeof setTimeout>;
43
65
  let behaviorCleanup: (() => void) | null = null;
44
66
 
67
+ /** Convert PascalCase to kebab-case: "CallToAction" → "call-to-action" */
68
+ function toKebab(s: string): string {
69
+ return s.replace(/([a-z])([A-Z])/g, '$1-$2').replace(/([A-Z])([A-Z][a-z])/g, '$1-$2').toLowerCase();
70
+ }
71
+
72
+ /** Build a lookup map from kebab-case data-rune values to RuneConfig */
73
+ function buildRuneConfigMap(config: ThemeConfig): Map<string, import('@refrakt-md/transform').RuneConfig> {
74
+ const map = new Map<string, import('@refrakt-md/transform').RuneConfig>();
75
+ for (const [key, cfg] of Object.entries(config.runes)) {
76
+ map.set(toKebab(key), cfg);
77
+ }
78
+ return map;
79
+ }
80
+
81
+ /** Find the nearest interactive target: an editable data-name section, or a data-rune root.
82
+ * Editable data-name sections take priority; data-rune is the fallback for dead-space clicks. */
83
+ function findInteractiveTarget(
84
+ start: HTMLElement,
85
+ root: HTMLElement,
86
+ runeConfigMap: Map<string, import('@refrakt-md/transform').RuneConfig>,
87
+ ): InteractiveTarget | null {
88
+ let el: HTMLElement | null = start;
89
+ let dataNameEl: HTMLElement | null = null;
90
+ let dataRuneEl: HTMLElement | null = null;
91
+
92
+ // Walk up to find the nearest data-name and data-rune elements
93
+ while (el && el !== root) {
94
+ if (el.hasAttribute('data-name') && !dataNameEl) {
95
+ dataNameEl = el;
96
+ }
97
+ if (el.hasAttribute('data-rune') && !dataRuneEl) {
98
+ dataRuneEl = el;
99
+ }
100
+ el = el.parentElement;
101
+ }
102
+
103
+ // Try editable data-name section first (takes priority)
104
+ if (dataNameEl) {
105
+ const dataName = dataNameEl.getAttribute('data-name')!;
106
+
107
+ // Find containing rune's config for editHints lookup
108
+ let configEl: HTMLElement | null = dataNameEl.parentElement;
109
+ let runeConfig: import('@refrakt-md/transform').RuneConfig | undefined;
110
+ while (configEl && configEl !== root) {
111
+ const runeType = configEl.getAttribute('data-rune');
112
+ if (runeType) {
113
+ runeConfig = runeConfigMap.get(runeType);
114
+ break;
115
+ }
116
+ configEl = configEl.parentElement;
117
+ }
118
+
119
+ const hint = runeConfig?.editHints?.[dataName];
120
+
121
+ if (hint && hint !== 'none') {
122
+ return { type: 'section', el: dataNameEl, dataName, editType: hint };
123
+ }
124
+ // data-name found but not editable — fall through to rune
125
+ }
126
+
127
+ // Fall back to data-rune element
128
+ if (dataRuneEl) {
129
+ return { type: 'rune', el: dataRuneEl };
130
+ }
131
+
132
+ return null;
133
+ }
134
+
135
+ /** Attach click and hover handlers to interactive elements within the wrapper */
136
+ function attachSectionHandlers(wrapper: HTMLElement, runeConfigMap: Map<string, import('@refrakt-md/transform').RuneConfig>) {
137
+ let hoveredEl: HTMLElement | null = null;
138
+ let removeTimer: ReturnType<typeof setTimeout> | null = null;
139
+ const HOVER_DEBOUNCE = 75; // ms — bridges list-item gaps
140
+
141
+ // Tooltip element — lives inside the shadow DOM wrapper
142
+ const tooltip = document.createElement('div');
143
+ tooltip.className = 'rf-edit-tooltip';
144
+ wrapper.appendChild(tooltip);
145
+
146
+ function showTooltip(label: string, me: MouseEvent) {
147
+ tooltip.textContent = label;
148
+ tooltip.style.left = `${me.clientX + 12}px`;
149
+ tooltip.style.top = `${me.clientY + 12}px`;
150
+ tooltip.classList.add('rf-tooltip-visible');
151
+ }
152
+
153
+ function hideTooltip() {
154
+ tooltip.classList.remove('rf-tooltip-visible');
155
+ }
156
+
157
+ wrapper.addEventListener('mouseover', (e) => {
158
+ const result = findInteractiveTarget(e.target as HTMLElement, wrapper, runeConfigMap);
159
+ const target = result?.el ?? null;
160
+
161
+ // Cancel any pending deferred removal/switch
162
+ if (removeTimer !== null) {
163
+ clearTimeout(removeTimer);
164
+ removeTimer = null;
165
+ }
166
+
167
+ if (target === hoveredEl) {
168
+ // Re-entered the same target — already highlighted
169
+ } else if (target && hoveredEl && hoveredEl.contains(target)) {
170
+ // Moving to a child of current target — switch immediately
171
+ hoveredEl.classList.remove('rf-editable-hover');
172
+ hoveredEl = target;
173
+ hoveredEl.classList.add('rf-editable-hover');
174
+ } else if (hoveredEl && target && target.contains(hoveredEl)) {
175
+ // Moving to an ancestor (gap-crossing case) — defer the switch
176
+ const oldHovered = hoveredEl;
177
+ removeTimer = setTimeout(() => {
178
+ if (hoveredEl === oldHovered) {
179
+ oldHovered.classList.remove('rf-editable-hover');
180
+ hoveredEl = target;
181
+ hoveredEl.classList.add('rf-editable-hover');
182
+ }
183
+ removeTimer = null;
184
+ }, HOVER_DEBOUNCE);
185
+ return; // Keep current tooltip during deferred switch
186
+ } else {
187
+ // Completely different target — switch immediately
188
+ hoveredEl?.classList.remove('rf-editable-hover');
189
+ hoveredEl = target;
190
+ hoveredEl?.classList.add('rf-editable-hover');
191
+ }
192
+
193
+ if (result) {
194
+ const label = result.type === 'section'
195
+ ? result.dataName
196
+ : result.el.getAttribute('data-rune') ?? '';
197
+ showTooltip(label, e as MouseEvent);
198
+ } else {
199
+ hideTooltip();
200
+ }
201
+ });
202
+
203
+ wrapper.addEventListener('mousemove', (e) => {
204
+ if (tooltip.classList.contains('rf-tooltip-visible')) {
205
+ tooltip.style.left = `${e.clientX + 12}px`;
206
+ tooltip.style.top = `${e.clientY + 12}px`;
207
+ }
208
+ });
209
+
210
+ wrapper.addEventListener('mouseout', (e) => {
211
+ const related = (e as MouseEvent).relatedTarget as HTMLElement | null;
212
+ if (hoveredEl && (!related || !hoveredEl.contains(related))) {
213
+ if (removeTimer !== null) {
214
+ clearTimeout(removeTimer);
215
+ }
216
+ const el = hoveredEl;
217
+ removeTimer = setTimeout(() => {
218
+ el.classList.remove('rf-editable-hover');
219
+ if (hoveredEl === el) hoveredEl = null;
220
+ removeTimer = null;
221
+ }, HOVER_DEBOUNCE);
222
+ hideTooltip();
223
+ }
224
+ });
225
+
226
+ wrapper.addEventListener('click', (e) => {
227
+ hideTooltip();
228
+ const result = findInteractiveTarget(e.target as HTMLElement, wrapper, runeConfigMap);
229
+ if (!result) return;
230
+
231
+ e.preventDefault();
232
+ e.stopPropagation();
233
+
234
+ if (result.type === 'section') {
235
+ const { el, dataName, editType } = result;
236
+ const rect = el.getBoundingClientRect();
237
+ // For link edits, extract the href from the <a> child
238
+ const anchor = editType === 'link' ? el.querySelector('a') as HTMLAnchorElement | null : null;
239
+ // For image edits, extract the src from the <img> child
240
+ const imgEl = editType === 'image' ? el.querySelector('img') as HTMLImageElement | null : null;
241
+ // For code edits, extract text from the <code> element to avoid
242
+ // picking up structural text (language labels, copy buttons, etc.)
243
+ const codeEl = editType === 'code' ? el.querySelector('code') : null;
244
+ // For icon edits, extract the icon name from the data-icon attribute
245
+ const iconEl = editType === 'icon'
246
+ ? (el.hasAttribute('data-icon') ? el : el.querySelector('[data-icon]')) as HTMLElement | null
247
+ : null;
248
+ onsectionclick?.({
249
+ dataName,
250
+ text: (codeEl ?? el).textContent?.trim() ?? '',
251
+ rect,
252
+ editType,
253
+ href: anchor?.getAttribute('href') ?? imgEl?.getAttribute('src') ?? undefined,
254
+ iconName: iconEl?.getAttribute('data-icon') ?? undefined,
255
+ });
256
+ } else {
257
+ // type === 'rune' → open block edit panel
258
+ const me = e as MouseEvent;
259
+ const allRuneEls = Array.from(wrapper.querySelectorAll('[data-rune]'));
260
+ // First data-rune is the root; any others are nested
261
+ let nestedRuneIndex: number | undefined;
262
+ if (allRuneEls.length > 1 && result.el !== allRuneEls[0]) {
263
+ nestedRuneIndex = allRuneEls.indexOf(result.el) - 1;
264
+ }
265
+ onruneclick?.({ x: me.clientX, y: me.clientY, nestedRuneIndex });
266
+ }
267
+ });
268
+ }
269
+
45
270
  $effect(() => {
46
271
  if (!previewContainer || !themeConfig) return;
47
272
 
@@ -119,6 +344,55 @@ ${hlCss}
119
344
  grid-column: full;
120
345
  padding-inline: max(var(--rf-content-gutter, 1.5rem), calc((100% - var(--rf-content-max)) / 2));
121
346
  }
347
+ /* Base: invisible outline + transition (persists on remove for fade-out) */
348
+ [data-name] {
349
+ outline: 2px dashed transparent;
350
+ outline-offset: 4px;
351
+ border-radius: 4px;
352
+ transition: outline-color 150ms ease;
353
+ }
354
+ [data-rune] {
355
+ outline: 2px dashed transparent;
356
+ outline-offset: 4px;
357
+ border-radius: 4px;
358
+ transition: outline-color 150ms ease;
359
+ }
360
+ /* Editable section hover affordance */
361
+ [data-name].rf-editable-hover {
362
+ outline-color: rgba(59, 130, 246, 0.5);
363
+ }
364
+ [data-name].rf-editable-hover,
365
+ [data-name].rf-editable-hover * {
366
+ cursor: text !important;
367
+ }
368
+ /* Rune root hover affordance — opens block edit panel */
369
+ [data-rune].rf-editable-hover {
370
+ outline-color: rgba(59, 130, 246, 0.3);
371
+ }
372
+ [data-rune].rf-editable-hover,
373
+ [data-rune].rf-editable-hover * {
374
+ cursor: pointer !important;
375
+ }
376
+ /* Hover tooltip */
377
+ .rf-edit-tooltip {
378
+ position: fixed;
379
+ padding: 2px 8px;
380
+ font-family: system-ui, -apple-system, sans-serif;
381
+ font-size: 11px;
382
+ font-weight: 600;
383
+ line-height: 1.4;
384
+ color: #fff;
385
+ background: rgba(30, 41, 59, 0.9);
386
+ border-radius: 4px;
387
+ pointer-events: none;
388
+ z-index: 10000;
389
+ white-space: nowrap;
390
+ opacity: 0;
391
+ transition: opacity 120ms ease;
392
+ }
393
+ .rf-edit-tooltip.rf-tooltip-visible {
394
+ opacity: 1;
395
+ }
122
396
  </style>
123
397
  <div class="rf-preview-wrapper">${html}</div>`;
124
398
 
@@ -126,6 +400,17 @@ ${hlCss}
126
400
  const wrapper = shadowRoot.querySelector('.rf-preview-wrapper') as HTMLElement | null;
127
401
  if (wrapper) {
128
402
  behaviorCleanup = initRuneBehaviors(wrapper);
403
+
404
+ // Prevent link navigation in preview (editor is for editing, not browsing)
405
+ wrapper.addEventListener('click', (e) => {
406
+ const anchor = (e.target as HTMLElement).closest('a');
407
+ if (anchor) e.preventDefault();
408
+ }, true);
409
+
410
+ // Attach inline-edit click + hover handlers for rune sections
411
+ if (!readOnly && (onsectionclick || onruneclick) && block.type === 'rune') {
412
+ attachSectionHandlers(wrapper, buildRuneConfigMap(config));
413
+ }
129
414
  }
130
415
  }
131
416
  } catch {