@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.
- package/app/dist/assets/{index-Ca-wW6uw.js → index-80NtMar1.js} +1 -1
- package/app/dist/assets/index-B6H6LF1M.css +1 -0
- package/app/dist/assets/{index-Dg4A5Pez.js → index-BDj1XPol.js} +1 -1
- package/app/dist/assets/{index-BfYWp0QC.js → index-BXe1fKaT.js} +1 -1
- package/app/dist/assets/{index-Cq0Maciq.js → index-BfxTGrHB.js} +1 -1
- package/app/dist/assets/{index-BsSUa0GD.js → index-Bn8ajfVl.js} +1 -1
- package/app/dist/assets/{index-D6vnTt4b.js → index-CCkzIGTi.js} +2 -2
- package/app/dist/assets/{index-BehCztSl.js → index-CXeK-dZx.js} +1 -1
- package/app/dist/assets/{index-iGDqoXj_.js → index-CaRBCHaX.js} +1 -1
- package/app/dist/assets/index-Cd12jZId.js +479 -0
- package/app/dist/assets/{index-D5pMhPrg.js → index-Cgbvx23V.js} +1 -1
- package/app/dist/assets/{index-IU6QYZAa.js → index-D5ucdUTo.js} +1 -1
- package/app/dist/assets/{index-CdpS6tGk.js → index-DGYxLhpR.js} +1 -1
- package/app/dist/assets/{index-RKEq45V5.js → index-DNJBunzP.js} +1 -1
- package/app/dist/assets/{index-Cgaw2jCE.js → index-DNtuldOx.js} +1 -1
- package/app/dist/assets/{index-BEPqnnsd.js → index-DQUOY-pF.js} +1 -1
- package/app/dist/assets/{index-2hOoPFOR.js → index-DskvyNKT.js} +1 -1
- package/app/dist/assets/{index-CLZfwYyS.js → index-aPeHMqUX.js} +1 -1
- package/app/dist/assets/{index-BobjskUl.js → index-dGztG-54.js} +1 -1
- package/app/dist/assets/{index-DHALjxX5.js → index-xo7v6nRB.js} +1 -1
- package/app/dist/index.html +2 -2
- package/app/src/lib/api/client.ts +81 -0
- package/app/src/lib/components/ActionEditPopover.svelte +267 -0
- package/app/src/lib/components/BlockCard.svelte +285 -0
- package/app/src/lib/components/BlockEditPanel.svelte +640 -260
- package/app/src/lib/components/BlockEditor.svelte +513 -52
- package/app/src/lib/components/CodeEditPopover.svelte +444 -0
- package/app/src/lib/components/ContentModelTree.svelte +835 -0
- package/app/src/lib/components/EditorLayout.svelte +1 -6
- package/app/src/lib/components/FrontmatterEditPanel.svelte +0 -1
- package/app/src/lib/components/IconPickerPopover.svelte +389 -0
- package/app/src/lib/components/ImageEditPopover.svelte +519 -0
- package/app/src/lib/components/InlineEditPopover.svelte +616 -0
- package/app/src/lib/components/RuneAttributes.svelte +51 -0
- package/app/src/lib/editor/block-parser.ts +424 -6
- package/dist/server.d.ts +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +189 -2
- package/dist/server.js.map +1 -1
- package/package.json +6 -6
- package/app/dist/assets/index-98ylvoBO.css +0 -1
- 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 {
|