fancy-ui-svelte 0.5.0 → 0.6.0

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,581 @@
1
+ // Editorial layout engine powered by @chenglou/pretext.
2
+ // Inspired by the pretext "editorial engine" demo
3
+ // (https://chenglou.me/pretext/editorial-engine/).
4
+ //
5
+ // Every frame: orb physics → obstacle intersection per text line → slot
6
+ // carving → pretext line layout with cursor handoff across columns → DOM
7
+ // writes only (left/top/textContent). No DOM measurement anywhere in the
8
+ // render loop.
9
+ import { prepareWithSegments, layoutWithLines, layoutNextLine, walkLineRanges, } from "@chenglou/pretext";
10
+ const MIN_SLOT_WIDTH = 50;
11
+ /** Subtract blocked intervals from a base interval, keeping viable slots. */
12
+ function carveTextLineSlots(base, blocked) {
13
+ let slots = [base];
14
+ for (const interval of blocked) {
15
+ const next = [];
16
+ for (const slot of slots) {
17
+ if (interval.right <= slot.left || interval.left >= slot.right) {
18
+ next.push(slot);
19
+ continue;
20
+ }
21
+ if (interval.left > slot.left)
22
+ next.push({ left: slot.left, right: interval.left });
23
+ if (interval.right < slot.right)
24
+ next.push({ left: interval.right, right: slot.right });
25
+ }
26
+ slots = next;
27
+ }
28
+ return slots.filter((slot) => slot.right - slot.left >= MIN_SLOT_WIDTH);
29
+ }
30
+ /** Horizontal interval blocked by a circle within a vertical band, or null. */
31
+ function circleIntervalForBand(cx, cy, r, bandTop, bandBottom, hPad, vPad) {
32
+ const top = bandTop - vPad;
33
+ const bottom = bandBottom + vPad;
34
+ if (top >= cy + r || bottom <= cy - r)
35
+ return null;
36
+ const minDy = cy >= top && cy <= bottom ? 0 : cy < top ? top - cy : cy - bottom;
37
+ if (minDy >= r)
38
+ return null;
39
+ const maxDx = Math.sqrt(r * r - minDy * minDy);
40
+ return { left: cx - maxDx - hPad, right: cx + maxDx + hPad };
41
+ }
42
+ // ============================================================================
43
+ // Text layout
44
+ // ============================================================================
45
+ /**
46
+ * Flow text into a rectangular region, line by line, skipping circle and rect
47
+ * obstacles. Returns positioned lines plus the cursor where the text stopped,
48
+ * so the next column can pick up exactly where this one ended.
49
+ */
50
+ function layoutColumn(prepared, startCursor, regionX, regionY, regionW, regionH, lineHeight, circleObstacles, rectObstacles, singleSlotOnly) {
51
+ let cursor = startCursor;
52
+ let lineTop = regionY;
53
+ const lines = [];
54
+ let textExhausted = false;
55
+ while (lineTop + lineHeight <= regionY + regionH && !textExhausted) {
56
+ const bandTop = lineTop;
57
+ const bandBottom = lineTop + lineHeight;
58
+ const blocked = [];
59
+ for (const obstacle of circleObstacles) {
60
+ const interval = circleIntervalForBand(obstacle.cx, obstacle.cy, obstacle.r, bandTop, bandBottom, obstacle.hPad, obstacle.vPad);
61
+ if (interval !== null)
62
+ blocked.push(interval);
63
+ }
64
+ for (const rect of rectObstacles) {
65
+ if (bandBottom <= rect.y || bandTop >= rect.y + rect.h)
66
+ continue;
67
+ blocked.push({ left: rect.x, right: rect.x + rect.w });
68
+ }
69
+ const slots = carveTextLineSlots({ left: regionX, right: regionX + regionW }, blocked);
70
+ if (slots.length === 0) {
71
+ lineTop += lineHeight;
72
+ continue;
73
+ }
74
+ // Narrow screens: fill only the widest slot per band to stay readable.
75
+ const orderedSlots = singleSlotOnly
76
+ ? [
77
+ slots.reduce((best, slot) => slot.right - slot.left > best.right - best.left ? slot : best),
78
+ ]
79
+ : [...slots].sort((a, b) => a.left - b.left);
80
+ for (const slot of orderedSlots) {
81
+ const line = layoutNextLine(prepared, cursor, slot.right - slot.left);
82
+ if (line === null) {
83
+ textExhausted = true;
84
+ break;
85
+ }
86
+ lines.push({
87
+ x: Math.round(slot.left),
88
+ y: Math.round(lineTop),
89
+ text: line.text,
90
+ width: line.width,
91
+ });
92
+ cursor = line.end;
93
+ }
94
+ lineTop += lineHeight;
95
+ }
96
+ return { lines, cursor };
97
+ }
98
+ const ORB_DEFS = [
99
+ { fx: 0.52, fy: 0.22, r: 110, vx: 24, vy: 16, color: [196, 163, 90] },
100
+ { fx: 0.18, fy: 0.48, r: 85, vx: -19, vy: 26, color: [100, 140, 255] },
101
+ { fx: 0.74, fy: 0.58, r: 95, vx: 16, vy: -21, color: [232, 100, 130] },
102
+ { fx: 0.38, fy: 0.72, r: 75, vx: -26, vy: -14, color: [80, 200, 140] },
103
+ { fx: 0.86, fy: 0.18, r: 65, vx: -13, vy: 19, color: [150, 100, 220] },
104
+ ];
105
+ const BODY_FONT_SIZE = 18;
106
+ const BODY_LINE_HEIGHT = 30;
107
+ const GUTTER = 48;
108
+ const COL_GAP = 40;
109
+ const BOTTOM_GAP = 20;
110
+ const DROP_CAP_LINES = 3;
111
+ const PQ_LINE_HEIGHT = 27;
112
+ const NARROW_BREAKPOINT = 760;
113
+ const NARROW_GUTTER = 20;
114
+ const NARROW_COL_GAP = 20;
115
+ const NARROW_BOTTOM_GAP = 16;
116
+ const NARROW_ORB_SCALE = 0.58;
117
+ const NARROW_ACTIVE_ORBS = 3;
118
+ function hitTestOrbs(orbs, px, py, activeCount, radiusScale) {
119
+ for (let index = activeCount - 1; index >= 0; index--) {
120
+ const orb = orbs[index];
121
+ const radius = orb.r * radiusScale;
122
+ const dx = px - orb.x;
123
+ const dy = py - orb.y;
124
+ if (dx * dx + dy * dy <= radius * radius)
125
+ return index;
126
+ }
127
+ return -1;
128
+ }
129
+ function positionedLinesEqual(a, b) {
130
+ if (a.length !== b.length)
131
+ return false;
132
+ for (let i = 0; i < a.length; i++) {
133
+ if (a[i].x !== b[i].x || a[i].y !== b[i].y || a[i].text !== b[i].text)
134
+ return false;
135
+ }
136
+ return true;
137
+ }
138
+ export function createEditorialEngine(stage, options) {
139
+ const { headline, body, pullquotes, fontFamily } = options;
140
+ const bodyFont = `${BODY_FONT_SIZE}px ${fontFamily}`;
141
+ const pullquoteFont = `italic 19px ${fontFamily}`;
142
+ let destroyed = false;
143
+ let stageW = stage.clientWidth;
144
+ let stageH = stage.clientHeight;
145
+ // --- DOM pools (write-only after creation) ---
146
+ const bodyLinePool = [];
147
+ const headlinePool = [];
148
+ const pullquoteLinePool = [];
149
+ const pullquoteBoxPool = [];
150
+ function syncPool(pool, count, create) {
151
+ while (pool.length < count) {
152
+ const element = create();
153
+ stage.appendChild(element);
154
+ pool.push(element);
155
+ }
156
+ for (let index = 0; index < pool.length; index++) {
157
+ pool[index].style.display = index < count ? "" : "none";
158
+ }
159
+ }
160
+ function makeLineEl(className) {
161
+ const element = document.createElement("span");
162
+ element.className = className;
163
+ return element;
164
+ }
165
+ const orbEls = ORB_DEFS.map((def) => {
166
+ const element = document.createElement("div");
167
+ element.className = "ee-orb";
168
+ const [r, g, b] = def.color;
169
+ element.style.background = `radial-gradient(circle at 35% 35%, rgba(${r},${g},${b},0.35), rgba(${r},${g},${b},0.12) 55%, transparent 72%)`;
170
+ element.style.boxShadow = `0 0 60px 15px rgba(${r},${g},${b},0.18), 0 0 120px 40px rgba(${r},${g},${b},0.07)`;
171
+ stage.appendChild(element);
172
+ return element;
173
+ });
174
+ const dropCapEl = document.createElement("div");
175
+ dropCapEl.className = "ee-drop-cap";
176
+ stage.appendChild(dropCapEl);
177
+ // --- Prepared text (one-time measurement, cached) ---
178
+ // pre-wrap keeps the blank-line paragraph breaks the `body` prop advertises.
179
+ const preparedBody = prepareWithSegments(body, bodyFont, { whiteSpace: "pre-wrap" });
180
+ const preparedPullquotes = pullquotes.map((text) => prepareWithSegments(text, pullquoteFont));
181
+ const pullquoteSpecs = [
182
+ { prepared: preparedPullquotes[0], colIdx: 0, yFrac: 0.48, wFrac: 0.52, side: "right" },
183
+ { prepared: preparedPullquotes[1], colIdx: 1, yFrac: 0.32, wFrac: 0.5, side: "left" },
184
+ ].filter((spec) => spec.prepared !== undefined);
185
+ const DROP_CAP_SIZE = BODY_LINE_HEIGHT * DROP_CAP_LINES - 4;
186
+ const dropCapFont = `700 ${DROP_CAP_SIZE}px ${fontFamily}`;
187
+ // Empty body: no drop cap, render (nothing) from the very first grapheme.
188
+ const dropCapText = body.length > 0 ? body[0] : "";
189
+ let dropCapTotalW = 0;
190
+ if (dropCapText !== "") {
191
+ const preparedDropCap = prepareWithSegments(dropCapText, dropCapFont);
192
+ let dropCapWidth = 0;
193
+ walkLineRanges(preparedDropCap, 9999, (line) => {
194
+ dropCapWidth = line.width;
195
+ });
196
+ dropCapTotalW = Math.ceil(dropCapWidth) + 10;
197
+ dropCapEl.textContent = dropCapText;
198
+ dropCapEl.style.font = dropCapFont;
199
+ dropCapEl.style.lineHeight = `${DROP_CAP_SIZE}px`;
200
+ }
201
+ else {
202
+ dropCapEl.style.display = "none";
203
+ }
204
+ // --- Headline auto-fit: binary search over font sizes, rejecting any size
205
+ // that breaks a word mid-line. Each probe is pure arithmetic.
206
+ let cachedKey = "";
207
+ let cachedFit = { fontSize: 24, lines: [] };
208
+ function fitHeadline(maxWidth, maxHeight, maxSize) {
209
+ const key = `${maxWidth}|${maxHeight}|${maxSize}`;
210
+ if (key === cachedKey)
211
+ return cachedFit;
212
+ let lo = 20;
213
+ let hi = maxSize;
214
+ let best = lo;
215
+ let bestLines = [];
216
+ while (lo <= hi) {
217
+ const size = Math.floor((lo + hi) / 2);
218
+ const lineHeight = Math.round(size * 0.93);
219
+ const prepared = prepareWithSegments(headline, `700 ${size}px ${fontFamily}`);
220
+ let breaksWord = false;
221
+ let lineCount = 0;
222
+ walkLineRanges(prepared, maxWidth, (line) => {
223
+ lineCount++;
224
+ if (line.end.graphemeIndex !== 0)
225
+ breaksWord = true;
226
+ });
227
+ if (!breaksWord && lineCount * lineHeight <= maxHeight) {
228
+ best = size;
229
+ bestLines = layoutWithLines(prepared, maxWidth, lineHeight).lines.map((line, i) => ({
230
+ x: 0,
231
+ y: i * lineHeight,
232
+ text: line.text,
233
+ width: line.width,
234
+ }));
235
+ lo = size + 1;
236
+ }
237
+ else {
238
+ hi = size - 1;
239
+ }
240
+ }
241
+ // No size fit (overlong word or tiny stage): fall back to the minimum
242
+ // size with word breaks allowed rather than dropping the headline.
243
+ if (bestLines.length === 0 && headline.length > 0) {
244
+ best = 20;
245
+ const lineHeight = Math.round(best * 0.93);
246
+ const prepared = prepareWithSegments(headline, `700 ${best}px ${fontFamily}`);
247
+ bestLines = layoutWithLines(prepared, maxWidth, lineHeight).lines.map((line, i) => ({
248
+ x: 0,
249
+ y: i * lineHeight,
250
+ text: line.text,
251
+ width: line.width,
252
+ }));
253
+ }
254
+ cachedKey = key;
255
+ cachedFit = { fontSize: best, lines: bestLines };
256
+ return cachedFit;
257
+ }
258
+ // --- Interaction state ---
259
+ const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
260
+ const orbs = ORB_DEFS.map((def) => ({
261
+ x: def.fx * stageW,
262
+ y: def.fy * stageH,
263
+ r: def.r,
264
+ vx: def.vx,
265
+ vy: def.vy,
266
+ paused: reducedMotion,
267
+ }));
268
+ let pointer = { x: -9999, y: -9999 };
269
+ let drag = null;
270
+ let selectionActive = false;
271
+ let lastFrameTime = null;
272
+ let committedLines = null;
273
+ function stagePoint(event) {
274
+ const rect = stage.getBoundingClientRect();
275
+ return { x: event.clientX - rect.left, y: event.clientY - rect.top };
276
+ }
277
+ // --- Render loop ---
278
+ let scheduledRaf = null;
279
+ function scheduleRender() {
280
+ if (scheduledRaf !== null || destroyed)
281
+ return;
282
+ scheduledRaf = requestAnimationFrame((now) => {
283
+ scheduledRaf = null;
284
+ if (render(now))
285
+ scheduleRender();
286
+ });
287
+ }
288
+ function render(now) {
289
+ // Re-laying lines out would destroy an active text selection.
290
+ if (selectionActive && drag === null)
291
+ return false;
292
+ const isNarrow = stageW < NARROW_BREAKPOINT;
293
+ const gutter = isNarrow ? NARROW_GUTTER : GUTTER;
294
+ const colGap = isNarrow ? NARROW_COL_GAP : COL_GAP;
295
+ const bottomGap = isNarrow ? NARROW_BOTTOM_GAP : BOTTOM_GAP;
296
+ const orbRadiusScale = isNarrow ? NARROW_ORB_SCALE : 1;
297
+ const activeOrbCount = isNarrow ? Math.min(NARROW_ACTIVE_ORBS, orbs.length) : orbs.length;
298
+ // Orb physics: integrate velocity, bounce on walls, soft repulsion.
299
+ const dt = Math.min((now - (lastFrameTime ?? now)) / 1000, 0.05);
300
+ const draggedOrbIndex = drag?.orbIndex ?? -1;
301
+ let stillAnimating = false;
302
+ for (let index = 0; index < activeOrbCount; index++) {
303
+ const orb = orbs[index];
304
+ const radius = orb.r * orbRadiusScale;
305
+ if (orb.paused || index === draggedOrbIndex)
306
+ continue;
307
+ stillAnimating = true;
308
+ orb.x += orb.vx * dt;
309
+ orb.y += orb.vy * dt;
310
+ if (orb.x - radius < 0) {
311
+ orb.x = radius;
312
+ orb.vx = Math.abs(orb.vx);
313
+ }
314
+ if (orb.x + radius > stageW) {
315
+ orb.x = stageW - radius;
316
+ orb.vx = -Math.abs(orb.vx);
317
+ }
318
+ if (orb.y - radius < gutter * 0.5) {
319
+ orb.y = radius + gutter * 0.5;
320
+ orb.vy = Math.abs(orb.vy);
321
+ }
322
+ if (orb.y + radius > stageH - bottomGap) {
323
+ orb.y = stageH - bottomGap - radius;
324
+ orb.vy = -Math.abs(orb.vy);
325
+ }
326
+ }
327
+ for (let i = 0; i < activeOrbCount; i++) {
328
+ const a = orbs[i];
329
+ for (let j = i + 1; j < activeOrbCount; j++) {
330
+ const b = orbs[j];
331
+ const dx = b.x - a.x;
332
+ const dy = b.y - a.y;
333
+ const dist = Math.sqrt(dx * dx + dy * dy);
334
+ const minDist = a.r * orbRadiusScale + b.r * orbRadiusScale + (isNarrow ? 12 : 20);
335
+ if (dist >= minDist || dist <= 0.1)
336
+ continue;
337
+ const force = (minDist - dist) * 0.8;
338
+ const nx = dx / dist;
339
+ const ny = dy / dist;
340
+ if (!a.paused && i !== draggedOrbIndex) {
341
+ a.vx -= nx * force * dt;
342
+ a.vy -= ny * force * dt;
343
+ }
344
+ if (!b.paused && j !== draggedOrbIndex) {
345
+ b.vx += nx * force * dt;
346
+ b.vy += ny * force * dt;
347
+ }
348
+ }
349
+ }
350
+ const circleObstacles = [];
351
+ for (let index = 0; index < activeOrbCount; index++) {
352
+ const orb = orbs[index];
353
+ circleObstacles.push({
354
+ cx: orb.x,
355
+ cy: orb.y,
356
+ r: orb.r * orbRadiusScale,
357
+ hPad: isNarrow ? 10 : 14,
358
+ vPad: isNarrow ? 2 : 4,
359
+ });
360
+ }
361
+ // Headline
362
+ const headlineWidth = Math.min(stageW - gutter * 2 - (isNarrow ? 12 : 0), 1000);
363
+ const maxHeadlineHeight = Math.floor(stageH * (isNarrow ? 0.2 : 0.24));
364
+ const { fontSize: headlineSize, lines: headlineLines } = fitHeadline(headlineWidth, maxHeadlineHeight, isNarrow ? 38 : 92);
365
+ const headlineLineHeight = Math.round(headlineSize * 0.93);
366
+ const headlineFont = `700 ${headlineSize}px ${fontFamily}`;
367
+ // Columns
368
+ const bodyTop = gutter + headlineLines.length * headlineLineHeight + (isNarrow ? 14 : 20);
369
+ const bodyHeight = stageH - bodyTop - bottomGap;
370
+ const columnCount = stageW > 1000 ? 3 : stageW > 640 ? 2 : 1;
371
+ const totalGutter = gutter * 2 + colGap * (columnCount - 1);
372
+ const maxContentWidth = Math.min(stageW, 1500);
373
+ const columnWidth = Math.floor((maxContentWidth - totalGutter) / columnCount);
374
+ const contentLeft = Math.round((stageW - (columnCount * columnWidth + (columnCount - 1) * colGap)) / 2);
375
+ const dropCapRect = {
376
+ x: contentLeft - 2,
377
+ y: bodyTop - 2,
378
+ w: dropCapTotalW,
379
+ h: DROP_CAP_LINES * BODY_LINE_HEIGHT + 2,
380
+ };
381
+ // Pullquotes occupy fixed rects the body text must flow around.
382
+ const pullquoteRects = [];
383
+ if (!isNarrow) {
384
+ for (const spec of pullquoteSpecs) {
385
+ if (spec.colIdx >= columnCount)
386
+ continue;
387
+ const pqWidth = Math.round(columnWidth * spec.wFrac);
388
+ const pqLines = layoutWithLines(spec.prepared, pqWidth - 20, PQ_LINE_HEIGHT).lines;
389
+ const pqHeight = pqLines.length * PQ_LINE_HEIGHT + 16;
390
+ const columnX = contentLeft + spec.colIdx * (columnWidth + colGap);
391
+ const pqX = spec.side === "right" ? columnX + columnWidth - pqWidth : columnX;
392
+ const pqY = Math.round(bodyTop + bodyHeight * spec.yFrac);
393
+ pullquoteRects.push({
394
+ x: pqX,
395
+ y: pqY,
396
+ w: pqWidth,
397
+ h: pqHeight,
398
+ colIdx: spec.colIdx,
399
+ lines: pqLines.map((line, lineIndex) => ({
400
+ x: pqX + 20,
401
+ y: pqY + 8 + lineIndex * PQ_LINE_HEIGHT,
402
+ text: line.text,
403
+ width: line.width,
404
+ })),
405
+ });
406
+ }
407
+ }
408
+ // Body flow with cursor handoff between columns. Cursor starts past the
409
+ // first grapheme — the drop cap renders it.
410
+ const allBodyLines = [];
411
+ let cursor = { segmentIndex: 0, graphemeIndex: dropCapText === "" ? 0 : 1 };
412
+ for (let columnIndex = 0; columnIndex < columnCount; columnIndex++) {
413
+ const columnX = contentLeft + columnIndex * (columnWidth + colGap);
414
+ const rects = [];
415
+ if (columnIndex === 0 && dropCapTotalW > 0)
416
+ rects.push(dropCapRect);
417
+ for (const pq of pullquoteRects) {
418
+ if (pq.colIdx === columnIndex)
419
+ rects.push(pq);
420
+ }
421
+ const result = layoutColumn(preparedBody, cursor, columnX, bodyTop, columnWidth, bodyHeight, BODY_LINE_HEIGHT, circleObstacles, rects, isNarrow);
422
+ allBodyLines.push(...result.lines);
423
+ cursor = result.cursor;
424
+ }
425
+ const pullquoteLines = pullquoteRects.flatMap((pq) => pq.lines);
426
+ // --- DOM writes (only when the projection changed) ---
427
+ const changed = committedLines === null ||
428
+ committedLines.headlineFont !== headlineFont ||
429
+ !positionedLinesEqual(committedLines.headline, headlineLines) ||
430
+ !positionedLinesEqual(committedLines.body, allBodyLines) ||
431
+ !positionedLinesEqual(committedLines.pullquote, pullquoteLines);
432
+ if (changed) {
433
+ syncPool(headlinePool, headlineLines.length, () => makeLineEl("ee-headline-line"));
434
+ for (let i = 0; i < headlineLines.length; i++) {
435
+ const el = headlinePool[i];
436
+ const line = headlineLines[i];
437
+ el.textContent = line.text;
438
+ el.style.left = `${gutter + line.x}px`;
439
+ el.style.top = `${gutter + line.y}px`;
440
+ el.style.font = headlineFont;
441
+ el.style.lineHeight = `${headlineLineHeight}px`;
442
+ }
443
+ syncPool(bodyLinePool, allBodyLines.length, () => makeLineEl("ee-line"));
444
+ for (let i = 0; i < allBodyLines.length; i++) {
445
+ const el = bodyLinePool[i];
446
+ const line = allBodyLines[i];
447
+ el.textContent = line.text;
448
+ el.style.left = `${line.x}px`;
449
+ el.style.top = `${line.y}px`;
450
+ el.style.font = bodyFont;
451
+ el.style.lineHeight = `${BODY_LINE_HEIGHT}px`;
452
+ }
453
+ syncPool(pullquoteLinePool, pullquoteLines.length, () => makeLineEl("ee-pullquote-line"));
454
+ for (let i = 0; i < pullquoteLines.length; i++) {
455
+ const el = pullquoteLinePool[i];
456
+ const line = pullquoteLines[i];
457
+ el.textContent = line.text;
458
+ el.style.left = `${line.x}px`;
459
+ el.style.top = `${line.y}px`;
460
+ el.style.font = pullquoteFont;
461
+ el.style.lineHeight = `${PQ_LINE_HEIGHT}px`;
462
+ }
463
+ committedLines = {
464
+ headline: headlineLines,
465
+ body: allBodyLines,
466
+ pullquote: pullquoteLines,
467
+ headlineFont,
468
+ };
469
+ }
470
+ dropCapEl.style.left = `${contentLeft}px`;
471
+ dropCapEl.style.top = `${bodyTop}px`;
472
+ syncPool(pullquoteBoxPool, pullquoteRects.length, () => {
473
+ const element = document.createElement("div");
474
+ element.className = "ee-pullquote-box";
475
+ return element;
476
+ });
477
+ for (let i = 0; i < pullquoteRects.length; i++) {
478
+ const pq = pullquoteRects[i];
479
+ const el = pullquoteBoxPool[i];
480
+ el.style.left = `${pq.x}px`;
481
+ el.style.top = `${pq.y}px`;
482
+ el.style.width = `${pq.w}px`;
483
+ el.style.height = `${pq.h}px`;
484
+ }
485
+ for (let index = 0; index < orbs.length; index++) {
486
+ const orb = orbs[index];
487
+ const element = orbEls[index];
488
+ if (index >= activeOrbCount) {
489
+ element.style.display = "none";
490
+ continue;
491
+ }
492
+ const radius = orb.r * orbRadiusScale;
493
+ element.style.display = "";
494
+ element.style.left = `${orb.x - radius}px`;
495
+ element.style.top = `${orb.y - radius}px`;
496
+ element.style.width = `${radius * 2}px`;
497
+ element.style.height = `${radius * 2}px`;
498
+ element.style.opacity = orb.paused ? "0.45" : "1";
499
+ }
500
+ const hoveredOrbIndex = hitTestOrbs(orbs, pointer.x, pointer.y, activeOrbCount, orbRadiusScale);
501
+ stage.style.cursor = drag !== null ? "grabbing" : hoveredOrbIndex !== -1 ? "grab" : "";
502
+ lastFrameTime = stillAnimating ? now : null;
503
+ return stillAnimating;
504
+ }
505
+ // --- Events ---
506
+ function onPointerDown(event) {
507
+ const point = stagePoint(event);
508
+ const isNarrow = stageW < NARROW_BREAKPOINT;
509
+ const activeOrbCount = isNarrow ? NARROW_ACTIVE_ORBS : orbs.length;
510
+ const radiusScale = isNarrow ? NARROW_ORB_SCALE : 1;
511
+ const orbIndex = hitTestOrbs(orbs, point.x, point.y, activeOrbCount, radiusScale);
512
+ if (orbIndex !== -1) {
513
+ event.preventDefault();
514
+ const orb = orbs[orbIndex];
515
+ drag = {
516
+ orbIndex,
517
+ startPointerX: point.x,
518
+ startPointerY: point.y,
519
+ startOrbX: orb.x,
520
+ startOrbY: orb.y,
521
+ };
522
+ }
523
+ pointer = point;
524
+ scheduleRender();
525
+ }
526
+ function onPointerMove(event) {
527
+ const point = stagePoint(event);
528
+ pointer = point;
529
+ if (drag !== null) {
530
+ const orb = orbs[drag.orbIndex];
531
+ orb.x = drag.startOrbX + (point.x - drag.startPointerX);
532
+ orb.y = drag.startOrbY + (point.y - drag.startPointerY);
533
+ }
534
+ scheduleRender();
535
+ }
536
+ function onPointerUp(event) {
537
+ const point = stagePoint(event);
538
+ pointer = point;
539
+ if (drag !== null) {
540
+ const dx = point.x - drag.startPointerX;
541
+ const dy = point.y - drag.startPointerY;
542
+ // A motionless press toggles pause instead of dragging.
543
+ if (dx * dx + dy * dy < 16) {
544
+ orbs[drag.orbIndex].paused = !orbs[drag.orbIndex].paused;
545
+ }
546
+ drag = null;
547
+ }
548
+ scheduleRender();
549
+ }
550
+ function onSelectionChange() {
551
+ const selection = window.getSelection();
552
+ selectionActive = selection !== null && !selection.isCollapsed && selection.rangeCount > 0;
553
+ scheduleRender();
554
+ }
555
+ stage.addEventListener("pointerdown", onPointerDown);
556
+ window.addEventListener("pointermove", onPointerMove);
557
+ window.addEventListener("pointerup", onPointerUp);
558
+ window.addEventListener("pointercancel", onPointerUp);
559
+ document.addEventListener("selectionchange", onSelectionChange);
560
+ const resizeObserver = new ResizeObserver((entries) => {
561
+ for (const entry of entries) {
562
+ stageW = entry.contentRect.width;
563
+ stageH = entry.contentRect.height;
564
+ }
565
+ scheduleRender();
566
+ });
567
+ resizeObserver.observe(stage);
568
+ scheduleRender();
569
+ return () => {
570
+ destroyed = true;
571
+ if (scheduledRaf !== null)
572
+ cancelAnimationFrame(scheduledRaf);
573
+ resizeObserver.disconnect();
574
+ stage.removeEventListener("pointerdown", onPointerDown);
575
+ window.removeEventListener("pointermove", onPointerMove);
576
+ window.removeEventListener("pointerup", onPointerUp);
577
+ window.removeEventListener("pointercancel", onPointerUp);
578
+ document.removeEventListener("selectionchange", onSelectionChange);
579
+ stage.replaceChildren();
580
+ };
581
+ }
@@ -0,0 +1,2 @@
1
+ export { default as EditorialEngine } from "./EditorialEngine.svelte";
2
+ export type { EditorialEngineProps } from "./EditorialEngine.svelte";
@@ -0,0 +1 @@
1
+ export { default as EditorialEngine } from "./EditorialEngine.svelte";
@@ -11,6 +11,7 @@ export * from "./border-beam/index.js";
11
11
  export * from "./glow-border/index.js";
12
12
  export * from "./marquee/index.js";
13
13
  export * from "./compare/index.js";
14
+ export * from "./dock/index.js";
14
15
  export * from "./image-trail-cursor/index.js";
15
16
  export * from "./interactive-grid-pattern/index.js";
16
17
  export * from "./interactive-hover-button/index.js";
@@ -53,4 +54,7 @@ export * from "./tracing-beam/index.js";
53
54
  export * from "./displacement-text/index.js";
54
55
  export * from "./matrix-rain/index.js";
55
56
  export * from "./terminal-text/index.js";
57
+ export * from "./noise-reveal/index.js";
58
+ export * from "./line-reveal/index.js";
59
+ export * from "./editorial-engine/index.js";
56
60
  export { registry, categories, categoryLabels, categoryDescriptions, getAllComponents, getComponent, getComponentsByStatus, getComponentsByCategory, getComponentsGroupedByCategory, getComponentsGroupedByStatus, searchComponents, getStats, hasComponent, getComponentCategory, } from "./registry.js";
@@ -16,6 +16,7 @@ export * from "./border-beam/index.js";
16
16
  export * from "./glow-border/index.js";
17
17
  export * from "./marquee/index.js";
18
18
  export * from "./compare/index.js";
19
+ export * from "./dock/index.js";
19
20
  export * from "./image-trail-cursor/index.js";
20
21
  export * from "./interactive-grid-pattern/index.js";
21
22
  export * from "./interactive-hover-button/index.js";
@@ -58,6 +59,9 @@ export * from "./tracing-beam/index.js";
58
59
  export * from "./displacement-text/index.js";
59
60
  export * from "./matrix-rain/index.js";
60
61
  export * from "./terminal-text/index.js";
62
+ export * from "./noise-reveal/index.js";
63
+ export * from "./line-reveal/index.js";
64
+ export * from "./editorial-engine/index.js";
61
65
  // =============================================================================
62
66
  // Registry
63
67
  // =============================================================================