@open-slide/core 1.3.0 → 1.4.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.
Files changed (38) hide show
  1. package/dist/{build-_276DMmJ.js → build-1Rqivz0d.js} +1 -1
  2. package/dist/cli/bin.js +3 -3
  3. package/dist/{config-BAwKWNtW.js → config-XZJnC_fu.js} +533 -59
  4. package/dist/{config-D9cZ1A0X.d.ts → config-s0YUbmUe.d.ts} +2 -1
  5. package/dist/{dev-BoqeVXVq.js → dev-0W8gYiSa.js} +1 -1
  6. package/dist/{en-CDKzoZvf.js → en-7GU-DHbJ.js} +13 -3
  7. package/dist/index.d.ts +2 -2
  8. package/dist/index.js +1 -1
  9. package/dist/locale/index.d.ts +1 -1
  10. package/dist/locale/index.js +40 -10
  11. package/dist/{preview-BLPxspc9.js → preview-DT9hJvzM.js} +1 -1
  12. package/dist/{types-JYG1cmwC.d.ts → types-QCpkHkiS.d.ts} +11 -1
  13. package/dist/vite/index.d.ts +2 -2
  14. package/dist/vite/index.js +1 -1
  15. package/package.json +1 -1
  16. package/skills/slide-authoring/SKILL.md +10 -2
  17. package/src/app/app.tsx +2 -0
  18. package/src/app/components/asset-view.tsx +36 -9
  19. package/src/app/components/inspector/inspect-overlay.tsx +49 -3
  20. package/src/app/components/inspector/inspector-panel.tsx +251 -24
  21. package/src/app/components/inspector/inspector-provider.tsx +390 -49
  22. package/src/app/components/player.tsx +25 -5
  23. package/src/app/components/present/control-bar.tsx +12 -0
  24. package/src/app/components/sidebar/folder-item.tsx +14 -3
  25. package/src/app/components/sidebar/sidebar.tsx +10 -0
  26. package/src/app/lib/export-pdf.ts +6 -0
  27. package/src/app/lib/inspector/use-editor.ts +9 -1
  28. package/src/app/lib/slides.ts +7 -0
  29. package/src/app/lib/use-slide-module.ts +48 -0
  30. package/src/app/routes/assets.tsx +9 -0
  31. package/src/app/routes/home-shell.tsx +23 -2
  32. package/src/app/routes/presenter.tsx +2 -20
  33. package/src/app/routes/slide.tsx +73 -40
  34. package/src/locale/en.ts +14 -4
  35. package/src/locale/ja.ts +14 -4
  36. package/src/locale/types.ts +11 -1
  37. package/src/locale/zh-cn.ts +14 -5
  38. package/src/locale/zh-tw.ts +14 -5
@@ -187,11 +187,12 @@ function offsetToLine(source, offset) {
187
187
  }
188
188
  function parseSource$2(source) {
189
189
  try {
190
- return parse(source, {
190
+ const ast = parse(source, {
191
191
  sourceType: "module",
192
192
  plugins: ["typescript", "jsx"],
193
193
  errorRecovery: true
194
194
  });
195
+ return ast.errors && ast.errors.length > 0 ? null : ast;
195
196
  } catch {
196
197
  return null;
197
198
  }
@@ -202,6 +203,51 @@ function findInnermostJsxElement(ast, line, column) {
202
203
  for (const n of findJsxAncestors(ast, line, column)) if (t$2.isJSXElement(n)) return n;
203
204
  return null;
204
205
  }
206
+ function findUniqueElementByText(ast, prevText) {
207
+ const hits = [];
208
+ walkJsx(ast, (n) => {
209
+ if (!t$2.isJSXElement(n)) return;
210
+ const parts = [];
211
+ collectTextRangeParts(n, parts);
212
+ if (textRangeContent(parts) !== prevText) return;
213
+ hits.push({
214
+ node: n,
215
+ size: (n.end ?? 0) - (n.start ?? 0)
216
+ });
217
+ });
218
+ if (hits.length === 0) return null;
219
+ hits.sort((a, b) => a.size - b.size);
220
+ const best = hits[0];
221
+ const bestStart = best.node.start ?? 0;
222
+ const bestEnd = best.node.end ?? 0;
223
+ const hasSiblingMatch = hits.slice(1).some(({ node }) => (node.start ?? 0) > bestStart || (node.end ?? 0) < bestEnd);
224
+ return hasSiblingMatch ? null : best.node;
225
+ }
226
+ function fallbackTextForOps(ops) {
227
+ for (const op of ops) if ((op.kind === "set-style" || op.kind === "set-text" || op.kind === "set-text-range-style") && op.prevText !== void 0) return op.prevText;
228
+ return null;
229
+ }
230
+ function hasOnlyTextOps(ops) {
231
+ return ops.length > 0 && ops.every((op) => op.kind === "set-text");
232
+ }
233
+ function elementTextMatches(element, prevText) {
234
+ const parts = [];
235
+ collectTextRangeParts(element, parts);
236
+ return textRangeContent(parts) === prevText;
237
+ }
238
+ function elementHasTextCandidate(ast, element, prevText) {
239
+ const norm = prevText.trim();
240
+ return collectElementTextCandidates(ast, element).some((candidate) => candidate.current === norm);
241
+ }
242
+ function findElementForEdit(ast, line, column, ops) {
243
+ const element = findInnermostJsxElement(ast, line, column);
244
+ const prevText = fallbackTextForOps(ops);
245
+ if (prevText === null) return element;
246
+ if (hasOnlyTextOps(ops) && element && (elementTextMatches(element, prevText) || elementHasTextCandidate(ast, element, prevText))) return element;
247
+ const textMatch = findUniqueElementByText(ast, prevText);
248
+ if (element && elementTextMatches(element, prevText)) return textMatch ?? element;
249
+ return textMatch ?? element;
250
+ }
205
251
  function findJsxByStart(ast, line, column) {
206
252
  let hit = null;
207
253
  walkJsx(ast, (n) => {
@@ -227,27 +273,60 @@ function findJsxAttr(opening, name) {
227
273
  function buildStyleSplice(source, element, ops) {
228
274
  const opening = element.openingElement;
229
275
  const existing = findJsxAttr(opening, "style");
230
- const style = new Map();
276
+ const entries = [];
277
+ let hasRawEntry = false;
231
278
  if (existing) {
232
279
  const value = existing.value;
233
280
  if (!value || !t$2.isJSXExpressionContainer(value)) return { error: "style attribute has unsupported form" };
234
281
  const expr = value.expression;
235
- if (!t$2.isObjectExpression(expr)) return { error: "style is not a literal object" };
236
- for (const prop of expr.properties) {
237
- if (!t$2.isObjectProperty(prop)) return { error: "style contains spread or method" };
238
- if (prop.computed) return { error: "style has computed key" };
282
+ if (!t$2.isObjectExpression(expr)) {
283
+ if (typeof expr.start !== "number" || typeof expr.end !== "number") return { error: "style value missing source range" };
284
+ entries.push({
285
+ kind: "raw",
286
+ text: `...(${source.slice(expr.start, expr.end)})`
287
+ });
288
+ hasRawEntry = true;
289
+ } else for (const prop of expr.properties) if (t$2.isObjectProperty(prop) && !prop.computed) {
239
290
  let keyName = null;
240
291
  if (t$2.isIdentifier(prop.key)) keyName = prop.key.name;
241
292
  else if (t$2.isStringLiteral(prop.key)) keyName = prop.key.value;
242
293
  if (!keyName) return { error: "style has unsupported key" };
243
294
  const v = prop.value;
244
- if (typeof v.start !== "number" || typeof v.end !== "number") return { error: "style value missing source range" };
245
- style.set(keyName, source.slice(v.start, v.end));
295
+ if (typeof prop.key.start !== "number" || typeof prop.key.end !== "number" || typeof v.start !== "number" || typeof v.end !== "number") return { error: "style value missing source range" };
296
+ entries.push({
297
+ kind: "prop",
298
+ key: keyName,
299
+ keyText: source.slice(prop.key.start, prop.key.end),
300
+ valueText: source.slice(v.start, v.end)
301
+ });
302
+ } else {
303
+ if (typeof prop.start !== "number" || typeof prop.end !== "number") return { error: "style value missing source range" };
304
+ entries.push({
305
+ kind: "raw",
306
+ text: source.slice(prop.start, prop.end)
307
+ });
308
+ hasRawEntry = true;
246
309
  }
247
310
  }
248
- for (const op of ops) if (op.value === null) style.delete(op.key);
249
- else style.set(op.key, jsString$1(op.value));
250
- if (style.size === 0) {
311
+ for (const op of ops) {
312
+ const matching = entries.filter((entry) => entry.kind === "prop" && entry.key === op.key);
313
+ if (op.value === null) {
314
+ for (const entry of matching) entries.splice(entries.indexOf(entry), 1);
315
+ if (hasRawEntry) entries.push({
316
+ kind: "prop",
317
+ key: op.key,
318
+ keyText: op.key,
319
+ valueText: "undefined"
320
+ });
321
+ } else if (matching.length > 0) matching[matching.length - 1].valueText = jsString$1(op.value);
322
+ else entries.push({
323
+ kind: "prop",
324
+ key: op.key,
325
+ keyText: op.key,
326
+ valueText: jsString$1(op.value)
327
+ });
328
+ }
329
+ if (entries.length === 0) {
251
330
  if (!existing) return null;
252
331
  let from = existing.start ?? 0;
253
332
  if (from > 0 && source[from - 1] === " ") from -= 1;
@@ -257,16 +336,29 @@ function buildStyleSplice(source, element, ops) {
257
336
  text: ""
258
337
  };
259
338
  }
260
- const propsText = Array.from(style.entries()).map(([k, v]) => `${k}: ${v}`).join(", ");
339
+ const propsText = entries.map((entry) => entry.kind === "prop" ? `${entry.keyText}: ${entry.valueText}` : entry.text).join(", ");
261
340
  const newAttr = `style={{ ${propsText} }}`;
262
- if (existing) return {
263
- from: existing.start ?? 0,
264
- to: existing.end ?? 0,
265
- text: newAttr
266
- };
341
+ if (existing) {
342
+ const lastAttr$1 = opening.attributes[opening.attributes.length - 1];
343
+ if (lastAttr$1 && lastAttr$1 !== existing && typeof lastAttr$1.end === "number") {
344
+ const attrsAfterStyle = source.slice(existing.end ?? 0, lastAttr$1.end).replace(/^[ \t]+/, "");
345
+ return {
346
+ from: existing.start ?? 0,
347
+ to: lastAttr$1.end,
348
+ text: `${attrsAfterStyle} ${newAttr}`
349
+ };
350
+ }
351
+ return {
352
+ from: existing.start ?? 0,
353
+ to: existing.end ?? 0,
354
+ text: newAttr
355
+ };
356
+ }
357
+ const lastAttr = opening.attributes[opening.attributes.length - 1];
358
+ const at = lastAttr?.end ?? opening.name.end ?? 0;
267
359
  return {
268
- from: opening.name.end ?? 0,
269
- to: opening.name.end ?? 0,
360
+ from: at,
361
+ to: at,
270
362
  text: ` ${newAttr}`
271
363
  };
272
364
  }
@@ -280,6 +372,10 @@ function meaningfulChildren(parent) {
280
372
  return true;
281
373
  });
282
374
  }
375
+ function isOnlyMeaningfulChild(parent, child) {
376
+ const meaningful = meaningfulChildren(parent);
377
+ return meaningful.length === 1 && meaningful[0] === child;
378
+ }
283
379
  function wrapSplice(parent, text) {
284
380
  const first = parent.children[0];
285
381
  const last = parent.children[parent.children.length - 1];
@@ -289,6 +385,60 @@ function wrapSplice(parent, text) {
289
385
  text
290
386
  };
291
387
  }
388
+ function splitLinesWithOffsets(value) {
389
+ const lines = [];
390
+ let start = 0;
391
+ for (let i = 0; i < value.length; i++) {
392
+ const ch = value[i];
393
+ if (ch !== "\n" && ch !== "\r") continue;
394
+ lines.push({
395
+ text: value.slice(start, i),
396
+ start
397
+ });
398
+ if (ch === "\r" && value[i + 1] === "\n") i += 1;
399
+ start = i + 1;
400
+ }
401
+ lines.push({
402
+ text: value.slice(start),
403
+ start
404
+ });
405
+ return lines;
406
+ }
407
+ function cleanJsxTextWithOffsets(value) {
408
+ const lines = splitLinesWithOffsets(value);
409
+ let lastNonEmptyLine = 0;
410
+ for (let i = 0; i < lines.length; i++) if (lines[i].text.trim()) lastNonEmptyLine = i;
411
+ let text = "";
412
+ const offsets = [];
413
+ for (let i = 0; i < lines.length; i++) {
414
+ const chars = Array.from(lines[i].text, (ch, j) => ({
415
+ ch: ch === " " ? " " : ch,
416
+ offset: lines[i].start + j
417
+ }));
418
+ let from = 0;
419
+ let to = chars.length;
420
+ if (i !== 0) while (from < to && chars[from].ch === " ") from += 1;
421
+ if (i !== lines.length - 1) while (to > from && chars[to - 1].ch === " ") to -= 1;
422
+ if (from >= to) continue;
423
+ for (const item of chars.slice(from, to)) {
424
+ text += item.ch;
425
+ offsets.push(item.offset);
426
+ }
427
+ if (i !== lastNonEmptyLine) {
428
+ text += " ";
429
+ offsets.push(null);
430
+ }
431
+ }
432
+ return {
433
+ text,
434
+ offsets
435
+ };
436
+ }
437
+ function isJsxBrElement(node) {
438
+ if (!t$2.isJSXElement(node)) return false;
439
+ const name = node.openingElement.name;
440
+ return t$2.isJSXIdentifier(name) && name.name.toLowerCase() === "br";
441
+ }
292
442
  function collectTextCandidates(element, out) {
293
443
  const meaningful = meaningfulChildren(element);
294
444
  const isSole = meaningful.length === 1;
@@ -318,6 +468,226 @@ function collectTextCandidates(element, out) {
318
468
  }
319
469
  } else if (t$2.isJSXElement(child) || t$2.isJSXFragment(child)) collectTextCandidates(child, out);
320
470
  }
471
+ function collectTextRangeParts(element, out) {
472
+ const parts = [];
473
+ collectTextRangePartsRaw(element, parts);
474
+ out.push(...normalizeTextRangeParts(parts));
475
+ }
476
+ function collectTextRangePartsRaw(element, out) {
477
+ for (const child of element.children) if (t$2.isJSXText(child)) {
478
+ const { text: current, offsets } = cleanJsxTextWithOffsets(child.value);
479
+ if (current) out.push({
480
+ node: child,
481
+ parent: element,
482
+ current,
483
+ raw: child.value,
484
+ text: formatJsxText,
485
+ offsets
486
+ });
487
+ } else if (t$2.isJSXExpressionContainer(child)) {
488
+ const expression = child.expression;
489
+ if (t$2.isStringLiteral(expression) || t$2.isNumericLiteral(expression)) {
490
+ const raw = String(expression.value);
491
+ const current = raw;
492
+ if (current) out.push({
493
+ node: child,
494
+ parent: element,
495
+ current,
496
+ raw,
497
+ text: (value) => `{${jsString$1(value)}}`,
498
+ offsets: Array.from({ length: current.length }, (_, i) => i)
499
+ });
500
+ }
501
+ } else if (isJsxBrElement(child)) out.push({
502
+ node: child,
503
+ current: "\n"
504
+ });
505
+ else if (t$2.isJSXElement(child) || t$2.isJSXFragment(child)) collectTextRangePartsRaw(child, out);
506
+ }
507
+ function normalizeTextRangeParts(parts) {
508
+ return parts.flatMap((part, index) => {
509
+ if (!("raw" in part)) return [part];
510
+ let start = 0;
511
+ let end = part.current.length;
512
+ if (parts[index - 1]?.current === "\n") while (start < end && /\s/.test(part.current[start] ?? "")) start++;
513
+ if (parts[index + 1]?.current === "\n") while (end > start && /\s/.test(part.current[end - 1] ?? "")) end--;
514
+ if (start === 0 && end === part.current.length) return [part];
515
+ if (start >= end) return [];
516
+ return [{
517
+ ...part,
518
+ current: part.current.slice(start, end),
519
+ offsets: part.offsets.slice(start, end)
520
+ }];
521
+ });
522
+ }
523
+ function resetValueForRangeStyle(key) {
524
+ if (key === "fontWeight") return "400";
525
+ if (key === "fontStyle") return "normal";
526
+ return null;
527
+ }
528
+ function styleSpanForText(text, key, value) {
529
+ const styleValue = value ?? resetValueForRangeStyle(key);
530
+ if (styleValue === null) return formatJsxText(text);
531
+ return `<span style={{ ${key}: ${jsString$1(styleValue)} }}>${formatJsxText(text)}</span>`;
532
+ }
533
+ function textRangeContent(parts) {
534
+ return parts.map((part) => part.current).join("");
535
+ }
536
+ function compactText(value) {
537
+ return value.replace(/\s+/g, "");
538
+ }
539
+ function textMatchesExpected(current, expected) {
540
+ return current === expected || compactText(current) === compactText(expected);
541
+ }
542
+ function formatRichText(value, formatText = formatJsxText) {
543
+ return value.split("\n").map((part) => formatText(part)).join("<br />");
544
+ }
545
+ function formatOptionalText(value, formatText = formatJsxText) {
546
+ return value ? formatText(value) : "";
547
+ }
548
+ function textDiff(prevText, nextText) {
549
+ let start = 0;
550
+ while (start < prevText.length && start < nextText.length && prevText[start] === nextText[start]) start += 1;
551
+ let prevEnd = prevText.length;
552
+ let nextEnd = nextText.length;
553
+ while (prevEnd > start && nextEnd > start && prevText[prevEnd - 1] === nextText[nextEnd - 1]) {
554
+ prevEnd -= 1;
555
+ nextEnd -= 1;
556
+ }
557
+ return {
558
+ start,
559
+ end: prevEnd,
560
+ value: nextText.slice(start, nextEnd)
561
+ };
562
+ }
563
+ function textLeafSplice(part, value) {
564
+ const rawRange = textLeafRawRange(part, 0, part.current.length);
565
+ if (!rawRange) return spliceRange(part.node, part.text(value));
566
+ const { rawStart, rawEnd } = rawRange;
567
+ return {
568
+ from: part.node.start ?? 0,
569
+ to: part.node.end ?? 0,
570
+ text: `${part.raw.slice(0, rawStart)}${formatRichText(value, part.text)}${part.raw.slice(rawEnd)}`
571
+ };
572
+ }
573
+ function textLeafRawRange(part, start, end) {
574
+ if (start >= end) return null;
575
+ let first = null;
576
+ let last = null;
577
+ for (let i = start; i < end; i++) {
578
+ const offset = part.offsets[i];
579
+ if (offset === void 0) return null;
580
+ if (offset === null) continue;
581
+ first ??= offset;
582
+ last = offset;
583
+ }
584
+ if (first === null || last === null) return null;
585
+ return {
586
+ rawStart: first,
587
+ rawEnd: last + 1
588
+ };
589
+ }
590
+ function buildTextRangeReplaceSplices(parts, start, end, value) {
591
+ const splices = [];
592
+ let offset = 0;
593
+ let inserted = false;
594
+ for (const part of parts) {
595
+ const partStart = offset;
596
+ const partEnd = partStart + part.current.length;
597
+ offset = partEnd;
598
+ const overlaps = start < partEnd && end > partStart;
599
+ const insertsHere = start === end && !inserted && start >= partStart && start <= partEnd;
600
+ if (!overlaps && !insertsHere) continue;
601
+ if ("raw" in part) {
602
+ const localStart = Math.max(start, partStart) - partStart;
603
+ const localEnd = overlaps ? Math.min(end, partEnd) - partStart : localStart;
604
+ const nextText = `${part.current.slice(0, localStart)}${inserted ? "" : value}${part.current.slice(localEnd)}`;
605
+ splices.push(textLeafSplice(part, nextText));
606
+ } else if (overlaps) splices.push(spliceRange(part.node, inserted ? "" : formatRichText(value)));
607
+ else if (insertsHere) {
608
+ const at = start === partStart ? part.node.start ?? 0 : part.node.end ?? 0;
609
+ splices.push({
610
+ from: at,
611
+ to: at,
612
+ text: formatRichText(value)
613
+ });
614
+ }
615
+ inserted = true;
616
+ }
617
+ if (!inserted && start === end && start === offset) {
618
+ const last = parts[parts.length - 1];
619
+ if (!last) return { error: "element has no editable text" };
620
+ if ("raw" in last) splices.push(textLeafSplice(last, `${last.current}${value}`));
621
+ else splices.push({
622
+ from: last.node.end ?? 0,
623
+ to: last.node.end ?? 0,
624
+ text: formatRichText(value)
625
+ });
626
+ }
627
+ return splices;
628
+ }
629
+ function buildTextContentSplices(element, value, prevText) {
630
+ const parts = [];
631
+ collectTextRangeParts(element, parts);
632
+ const current = textRangeContent(parts);
633
+ if (!textMatchesExpected(current, prevText)) return { error: "no text candidate matches the current value" };
634
+ const diff = textDiff(current, value);
635
+ if (diff.start === diff.end && diff.value === "") return [];
636
+ return buildTextRangeReplaceSplices(parts, diff.start, diff.end, diff.value);
637
+ }
638
+ function buildTextRangeStyleSplices(ast, source, element, start, end, op, prevText) {
639
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end <= start) return { error: "invalid text range" };
640
+ const parts = [];
641
+ collectTextRangeParts(element, parts);
642
+ const current = prevText ?? textRangeContent(parts);
643
+ if (!current) return { error: "element has no editable text" };
644
+ if (end > current.length) return { error: "text range is out of bounds" };
645
+ const renderedText = textRangeContent(parts);
646
+ if (prevText !== void 0 && renderedText !== prevText) {
647
+ if (elementTextCandidateMatches(ast, element, prevText)) {
648
+ const result = buildStyleSplice(source, element, [op]);
649
+ if (result && "error" in result) return result;
650
+ return result ? [result] : [];
651
+ }
652
+ return { error: "no text candidate matches the current value" };
653
+ }
654
+ const splices = [];
655
+ let leafStart = 0;
656
+ for (const leaf of parts) {
657
+ const leafEnd = leafStart + leaf.current.length;
658
+ if (!("raw" in leaf)) {
659
+ leafStart = leafEnd;
660
+ continue;
661
+ }
662
+ const selectedStart = Math.max(start, leafStart);
663
+ const selectedEnd = Math.min(end, leafEnd);
664
+ if (selectedStart >= selectedEnd) {
665
+ leafStart = leafEnd;
666
+ continue;
667
+ }
668
+ if (selectedStart === leafStart && selectedEnd === leafEnd && t$2.isJSXElement(leaf.parent) && leaf.parent !== element && isOnlyMeaningfulChild(leaf.parent, leaf.node)) {
669
+ const result = buildStyleSplice(source, leaf.parent, [op]);
670
+ if (result && "error" in result) return result;
671
+ if (result) splices.push(result);
672
+ leafStart = leafEnd;
673
+ continue;
674
+ }
675
+ const localStart = selectedStart - leafStart;
676
+ const localEnd = selectedEnd - leafStart;
677
+ const rawRange = textLeafRawRange(leaf, localStart, localEnd);
678
+ if (!rawRange) return { error: "text range source mismatch" };
679
+ const raw = leaf.raw;
680
+ const { rawStart, rawEnd } = rawRange;
681
+ const before = raw.slice(0, rawStart);
682
+ const selected = leaf.current.slice(localStart, localEnd);
683
+ const after = raw.slice(rawEnd);
684
+ const beforeText = t$2.isJSXText(leaf.node) ? before : formatOptionalText(before, leaf.text);
685
+ const afterText = t$2.isJSXText(leaf.node) ? after : formatOptionalText(after, leaf.text);
686
+ splices.push(spliceRange(leaf.node, `${beforeText}${styleSpanForText(selected, op.key, op.value)}${afterText}`));
687
+ leafStart = leafEnd;
688
+ }
689
+ return splices.length > 0 ? splices : null;
690
+ }
321
691
  function propPassthroughName(element) {
322
692
  const meaningful = meaningfulChildren(element);
323
693
  if (meaningful.length !== 1) return null;
@@ -517,7 +887,7 @@ function collectArrayMapCandidates(ast, element) {
517
887
  }
518
888
  return out;
519
889
  }
520
- function buildTextSplice(ast, element, value, prevText) {
890
+ function collectElementTextCandidates(ast, element) {
521
891
  const candidates = [];
522
892
  collectTextCandidates(element, candidates);
523
893
  if (candidates.length === 0) {
@@ -527,6 +897,14 @@ function buildTextSplice(ast, element, value, prevText) {
527
897
  else if (passthrough && enclosing && componentDestructuresProp(enclosing.fn, passthrough)) candidates.push(...collectPropCallSiteCandidates(ast, enclosing.name, passthrough));
528
898
  }
529
899
  if (candidates.length === 0) candidates.push(...collectArrayMapCandidates(ast, element));
900
+ return candidates;
901
+ }
902
+ function elementTextCandidateMatches(ast, element, prevText) {
903
+ const norm = prevText.trim();
904
+ return collectElementTextCandidates(ast, element).some((candidate) => candidate.current === norm);
905
+ }
906
+ function buildTextSplice(ast, element, value, prevText) {
907
+ const candidates = collectElementTextCandidates(ast, element);
530
908
  if (candidates.length === 0) return { error: "element has no editable text" };
531
909
  if (candidates.length === 1) return candidates[0].splice(value);
532
910
  if (prevText === void 0) return { error: "element has multiple text candidates; missing prevText" };
@@ -603,7 +981,7 @@ function planAssetImport(ast, assetPath) {
603
981
  }
604
982
  function planAssetAttr(ast, element, attr, assetPath) {
605
983
  if (!attr || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(attr)) return { error: "invalid attribute name" };
606
- if (!assetPath.startsWith("./assets/")) return { error: "asset path must start with ./assets/" };
984
+ if (!assetPath.startsWith("./assets/") && !assetPath.startsWith("@assets/")) return { error: "asset path must start with ./assets/ or @assets/" };
607
985
  const { identifier, importSplice } = planAssetImport(ast, assetPath);
608
986
  const opening = element.openingElement;
609
987
  const newAttr = `${attr}={${identifier}}`;
@@ -641,7 +1019,7 @@ function readJsxNumberAttr(opening, name) {
641
1019
  function planReplacePlaceholder(ast, element, assetPath) {
642
1020
  const opening = element.openingElement;
643
1021
  if (!t$2.isJSXIdentifier(opening.name) || opening.name.name !== "ImagePlaceholder") return { error: "not a placeholder" };
644
- if (!assetPath.startsWith("./assets/")) return { error: "asset path must start with ./assets/" };
1022
+ if (!assetPath.startsWith("./assets/") && !assetPath.startsWith("@assets/")) return { error: "asset path must start with ./assets/ or @assets/" };
645
1023
  const hint = readJsxStringAttr(opening, "hint") ?? "";
646
1024
  const width = readJsxNumberAttr(opening, "width");
647
1025
  const height = readJsxNumberAttr(opening, "height");
@@ -670,7 +1048,7 @@ function applyEdit(source, line, column, ops) {
670
1048
  status: 422,
671
1049
  error: "could not parse source"
672
1050
  };
673
- const element = findInnermostJsxElement(ast, line, column);
1051
+ const element = findElementForEdit(ast, line, column, ops);
674
1052
  if (!element) return {
675
1053
  ok: false,
676
1054
  status: 422,
@@ -691,14 +1069,42 @@ function applyEdit(source, line, column, ops) {
691
1069
  if (result) splices.push(result);
692
1070
  }
693
1071
  for (const op of ops) {
694
- if (op.kind !== "set-text") continue;
695
- const result = buildTextSplice(ast, element, op.value, op.prevText);
696
- if ("error" in result) return {
1072
+ if (op.kind !== "set-text-range-style") continue;
1073
+ const result = buildTextRangeStyleSplices(ast, source, element, op.start, op.end, {
1074
+ key: op.key,
1075
+ value: op.value
1076
+ }, op.prevText);
1077
+ if (result && "error" in result) return {
697
1078
  ok: false,
698
1079
  status: 422,
699
1080
  error: result.error
700
1081
  };
701
- splices.push(result);
1082
+ if (result) splices.push(...result);
1083
+ }
1084
+ for (const op of ops) {
1085
+ if (op.kind !== "set-text") continue;
1086
+ if (op.prevText !== void 0 && (op.value.includes("\n") || op.prevText.includes("\n"))) {
1087
+ const richResult = buildTextContentSplices(element, op.value, op.prevText);
1088
+ if (!("error" in richResult)) {
1089
+ splices.push(...richResult);
1090
+ continue;
1091
+ }
1092
+ }
1093
+ const result = buildTextSplice(ast, element, op.value, op.prevText);
1094
+ if ("error" in result) {
1095
+ if (op.prevText === void 0) return {
1096
+ ok: false,
1097
+ status: 422,
1098
+ error: result.error
1099
+ };
1100
+ const richResult = buildTextContentSplices(element, op.value, op.prevText);
1101
+ if ("error" in richResult) return {
1102
+ ok: false,
1103
+ status: 422,
1104
+ error: result.error
1105
+ };
1106
+ splices.push(...richResult);
1107
+ } else splices.push(result);
702
1108
  }
703
1109
  const assetOps = ops.flatMap((op) => op.kind === "set-attr-asset" ? [op] : []);
704
1110
  const placeholderOps = ops.flatMap((op) => op.kind === "replace-placeholder-with-image" ? [op] : []);
@@ -742,6 +1148,11 @@ function applyEdit(source, line, column, ops) {
742
1148
  splices.sort((a, b) => b.from - a.from);
743
1149
  let next = source;
744
1150
  for (const sp of splices) next = next.slice(0, sp.from) + sp.text + next.slice(sp.to);
1151
+ if (!parseSource$2(next)) return {
1152
+ ok: false,
1153
+ status: 422,
1154
+ error: "edit would produce invalid source"
1155
+ };
745
1156
  return {
746
1157
  ok: true,
747
1158
  source: next
@@ -1406,6 +1817,7 @@ function designPlugin(opts) {
1406
1817
  //#region src/vite/files-plugin.ts
1407
1818
  const FOLDER_ID_RE = /^f-[a-f0-9]{8}$/;
1408
1819
  const SLIDE_ID_RE$1 = /^[a-z0-9_-]+$/i;
1820
+ const GLOBAL_SCOPE = "@global";
1409
1821
  const COLOR_RE = /^#[0-9a-fA-F]{6}$/;
1410
1822
  const ASSET_FORBIDDEN_RE = /[\x00-\x1F\x7F/\\:*?"<>|]/;
1411
1823
  const ASSET_MAX_BYTES = 25 * 1024 * 1024;
@@ -1535,6 +1947,19 @@ function resolveAssetFile(slidesRoot, slideId, filename) {
1535
1947
  if (!file.startsWith(assetsDir + path.sep)) return null;
1536
1948
  return file;
1537
1949
  }
1950
+ function resolveScopedAssetsDir(slidesRoot, globalAssetsRoot, scope) {
1951
+ if (scope === GLOBAL_SCOPE) return globalAssetsRoot;
1952
+ return resolveAssetsDir(slidesRoot, scope);
1953
+ }
1954
+ function resolveScopedAssetFile(slidesRoot, globalAssetsRoot, scope, filename) {
1955
+ if (scope === GLOBAL_SCOPE) {
1956
+ if (!validateAssetName(filename)) return null;
1957
+ const file = path.resolve(globalAssetsRoot, filename);
1958
+ if (!file.startsWith(globalAssetsRoot + path.sep)) return null;
1959
+ return file;
1960
+ }
1961
+ return resolveAssetFile(slidesRoot, scope, filename);
1962
+ }
1538
1963
  function resolveSlideEntry(slidesRoot, slideId) {
1539
1964
  if (!SLIDE_ID_RE$1.test(slideId)) return null;
1540
1965
  const dir = path.resolve(slidesRoot, slideId);
@@ -1852,7 +2277,9 @@ function validateIcon(v) {
1852
2277
  function filesPlugin(opts) {
1853
2278
  const userCwd = opts.userCwd;
1854
2279
  const slidesDir = opts.slidesDir ?? "slides";
2280
+ const assetsDir = opts.assetsDir ?? "assets";
1855
2281
  const slidesRoot = path.resolve(userCwd, slidesDir);
2282
+ const globalAssetsRoot = path.resolve(userCwd, assetsDir);
1856
2283
  const manifestPath = path.join(slidesRoot, ".folders.json");
1857
2284
  return {
1858
2285
  name: "open-slide:files",
@@ -1865,7 +2292,16 @@ function filesPlugin(opts) {
1865
2292
  event: "open-slide:files-changed"
1866
2293
  });
1867
2294
  });
2295
+ server.watcher.add(globalAssetsRoot);
1868
2296
  const onAssetChange = (p) => {
2297
+ if (p.startsWith(globalAssetsRoot + path.sep) || p === globalAssetsRoot) {
2298
+ server.ws.send({
2299
+ type: "custom",
2300
+ event: "open-slide:assets-changed",
2301
+ data: { slideId: GLOBAL_SCOPE }
2302
+ });
2303
+ return;
2304
+ }
1869
2305
  if (!p.startsWith(slidesRoot + path.sep)) return;
1870
2306
  const rel = p.slice(slidesRoot.length + 1);
1871
2307
  const parts = rel.split(path.sep);
@@ -1989,11 +2425,11 @@ function filesPlugin(opts) {
1989
2425
  const fileMatch = url.pathname.match(/^\/([^/]+)\/([^/]+)$/);
1990
2426
  if (listMatch && method === "GET") {
1991
2427
  const slideId = listMatch[1];
1992
- const assetsDir = resolveAssetsDir(slidesRoot, slideId);
1993
- if (!assetsDir) return json$1(res, 400, { error: "invalid slideId" });
2428
+ const assetsDir$1 = resolveScopedAssetsDir(slidesRoot, globalAssetsRoot, slideId);
2429
+ if (!assetsDir$1) return json$1(res, 400, { error: "invalid slideId" });
1994
2430
  let entries;
1995
2431
  try {
1996
- entries = await fs.readdir(assetsDir);
2432
+ entries = await fs.readdir(assetsDir$1);
1997
2433
  } catch (err) {
1998
2434
  if (err.code === "ENOENT") return json$1(res, 200, { assets: [] });
1999
2435
  throw err;
@@ -2001,7 +2437,7 @@ function filesPlugin(opts) {
2001
2437
  const assets = [];
2002
2438
  for (const name of entries) {
2003
2439
  if (!validateAssetName(name)) continue;
2004
- const stat = await fs.stat(path.join(assetsDir, name));
2440
+ const stat = await fs.stat(path.join(assetsDir$1, name));
2005
2441
  if (!stat.isFile()) continue;
2006
2442
  assets.push({
2007
2443
  name,
@@ -2017,7 +2453,7 @@ function filesPlugin(opts) {
2017
2453
  if (fileMatch) {
2018
2454
  const slideId = fileMatch[1];
2019
2455
  const filename = decodeURIComponent(fileMatch[2]);
2020
- const file = resolveAssetFile(slidesRoot, slideId, filename);
2456
+ const file = resolveScopedAssetFile(slidesRoot, globalAssetsRoot, slideId, filename);
2021
2457
  if (!file) return json$1(res, 400, { error: "invalid path" });
2022
2458
  if (method === "GET") try {
2023
2459
  const buf = await fs.readFile(file);
@@ -2039,9 +2475,9 @@ function filesPlugin(opts) {
2039
2475
  await fs.access(file);
2040
2476
  return json$1(res, 409, { error: "asset exists" });
2041
2477
  } catch {}
2042
- const assetsDir = resolveAssetsDir(slidesRoot, slideId);
2043
- if (!assetsDir) return json$1(res, 400, { error: "invalid slideId" });
2044
- await fs.mkdir(assetsDir, { recursive: true });
2478
+ const assetsDir$1 = resolveScopedAssetsDir(slidesRoot, globalAssetsRoot, slideId);
2479
+ if (!assetsDir$1) return json$1(res, 400, { error: "invalid slideId" });
2480
+ await fs.mkdir(assetsDir$1, { recursive: true });
2045
2481
  const chunks = [];
2046
2482
  let total = 0;
2047
2483
  let oversized = false;
@@ -2076,7 +2512,7 @@ function filesPlugin(opts) {
2076
2512
  ok: true,
2077
2513
  name: filename
2078
2514
  });
2079
- const dest = resolveAssetFile(slidesRoot, slideId, target);
2515
+ const dest = resolveScopedAssetFile(slidesRoot, globalAssetsRoot, slideId, target);
2080
2516
  if (!dest) return json$1(res, 400, { error: "invalid name" });
2081
2517
  try {
2082
2518
  await fs.access(dest);
@@ -2571,10 +3007,27 @@ async function generateSlidesModule(files, slidesRoot, isDev) {
2571
3007
  const themesMap = {};
2572
3008
  for (const e of entries) if (e.theme) themesMap[e.id] = e.theme;
2573
3009
  const themesJson = JSON.stringify(themesMap);
2574
- const cases = entries.map((e) => ` case ${JSON.stringify(e.id)}: return import(${JSON.stringify(e.importPath)});`).join("\n");
3010
+ const importTokens = JSON.stringify(Object.fromEntries(entries.map((e) => [e.id, 0])));
3011
+ const devRuntime = isDev ? `
3012
+ const slideImportTokens = ${importTokens};
3013
+ if (import.meta.hot) {
3014
+ import.meta.hot.on('open-slide:slide-changed', (data) => {
3015
+ const ids = Array.isArray(data?.slideIds) ? data.slideIds : data?.slideId ? [data.slideId] : [];
3016
+ const token = Date.now();
3017
+ for (const id of ids) {
3018
+ if (Object.prototype.hasOwnProperty.call(slideImportTokens, id)) slideImportTokens[id] = token;
3019
+ }
3020
+ });
3021
+ }
3022
+ ` : "";
3023
+ const cases = entries.map((e) => {
3024
+ const importExpr = isDev ? `import(/* @vite-ignore */ ${JSON.stringify(`${e.importPath}?t=`)} + slideImportTokens[${JSON.stringify(e.id)}])` : `import(${JSON.stringify(e.importPath)})`;
3025
+ return ` case ${JSON.stringify(e.id)}: return ${importExpr};`;
3026
+ }).join("\n");
2575
3027
  return `// virtual:open-slide/slides — generated
2576
3028
  export const slideIds = ${ids};
2577
3029
  export const slideThemes = ${themesJson};
3030
+ ${devRuntime}
2578
3031
 
2579
3032
  export async function loadSlide(id) {
2580
3033
  switch (id) {
@@ -2590,6 +3043,32 @@ function openSlidePlugin(opts) {
2590
3043
  const slidesRoot = path.resolve(userCwd, slidesDir);
2591
3044
  const foldersManifestPath = path.join(slidesRoot, ".folders.json");
2592
3045
  let isDev = false;
3046
+ const slideIdForEntry = (p) => {
3047
+ const rel = path.relative(slidesRoot, p);
3048
+ if (rel.startsWith("..") || path.isAbsolute(rel)) return null;
3049
+ const parts = rel.split(path.sep);
3050
+ if (parts.length !== 2) return null;
3051
+ if (!/^index\.(tsx|jsx|ts|js)$/.test(parts[1])) return null;
3052
+ return parts[0];
3053
+ };
3054
+ let slideChangeTimer = null;
3055
+ const pendingSlideChanges = new Set();
3056
+ const queueSlideChanged = (server, id) => {
3057
+ pendingSlideChanges.add(id);
3058
+ if (slideChangeTimer) clearTimeout(slideChangeTimer);
3059
+ slideChangeTimer = setTimeout(() => {
3060
+ slideChangeTimer = null;
3061
+ const mod = server.moduleGraph.getModuleById(resolved$1(SLIDES_VMOD));
3062
+ if (mod) server.moduleGraph.invalidateModule(mod);
3063
+ const slideIds = Array.from(pendingSlideChanges);
3064
+ pendingSlideChanges.clear();
3065
+ server.ws.send({
3066
+ type: "custom",
3067
+ event: "open-slide:slide-changed",
3068
+ data: { slideIds }
3069
+ });
3070
+ }, 100);
3071
+ };
2593
3072
  return {
2594
3073
  name: "open-slide",
2595
3074
  config(_c, env) {
@@ -2630,14 +3109,14 @@ function openSlidePlugin(opts) {
2630
3109
  }
2631
3110
  return null;
2632
3111
  },
3112
+ handleHotUpdate(ctx) {
3113
+ const slideId = slideIdForEntry(ctx.file);
3114
+ if (!slideId) return;
3115
+ queueSlideChanged(ctx.server, slideId);
3116
+ return [];
3117
+ },
2633
3118
  configureServer(server) {
2634
- const isSlideEntry = (p) => {
2635
- const rel = path.relative(slidesRoot, p);
2636
- if (rel.startsWith("..") || path.isAbsolute(rel)) return false;
2637
- const parts = rel.split(path.sep);
2638
- if (parts.length !== 2) return false;
2639
- return /^index\.(tsx|jsx|ts|js)$/.test(parts[1]);
2640
- };
3119
+ const isSlideEntry = (p) => slideIdForEntry(p) !== null;
2641
3120
  let reloadTimer = null;
2642
3121
  const reload = () => {
2643
3122
  if (reloadTimer) clearTimeout(reloadTimer);
@@ -2655,18 +3134,6 @@ function openSlidePlugin(opts) {
2655
3134
  server.watcher.on("unlink", (p) => {
2656
3135
  if (isSlideEntry(p)) reload();
2657
3136
  });
2658
- let slideThemeTimer = null;
2659
- const invalidateSlidesVmod = () => {
2660
- if (slideThemeTimer) clearTimeout(slideThemeTimer);
2661
- slideThemeTimer = setTimeout(() => {
2662
- slideThemeTimer = null;
2663
- const mod = server.moduleGraph.getModuleById(resolved$1(SLIDES_VMOD));
2664
- if (mod) server.moduleGraph.invalidateModule(mod);
2665
- }, 100);
2666
- };
2667
- server.watcher.on("change", (p) => {
2668
- if (isSlideEntry(p)) invalidateSlidesVmod();
2669
- });
2670
3137
  let foldersTimer = null;
2671
3138
  const invalidateFolders = () => {
2672
3139
  if (foldersTimer) clearTimeout(foldersTimer);
@@ -2854,8 +3321,10 @@ async function createViteConfig(opts) {
2854
3321
  const config = opts.config ?? await loadUserConfig(userCwd);
2855
3322
  const slidesDir = config.slidesDir ?? "slides";
2856
3323
  const themesDir = config.themesDir ?? "themes";
3324
+ const assetsDir = config.assetsDir ?? "assets";
2857
3325
  const slidesAbs = path.resolve(userCwd, slidesDir);
2858
3326
  const themesAbs = path.resolve(userCwd, themesDir);
3327
+ const assetsAbs = path.resolve(userCwd, assetsDir);
2859
3328
  return {
2860
3329
  root: APP_ROOT,
2861
3330
  configFile: false,
@@ -2886,14 +3355,18 @@ async function createViteConfig(opts) {
2886
3355
  }),
2887
3356
  filesPlugin({
2888
3357
  userCwd,
2889
- slidesDir
3358
+ slidesDir,
3359
+ assetsDir
2890
3360
  }),
2891
3361
  currentPlugin({
2892
3362
  userCwd,
2893
3363
  slidesDir
2894
3364
  })
2895
3365
  ],
2896
- resolve: { alias: { "@": APP_ROOT } },
3366
+ resolve: { alias: {
3367
+ "@": APP_ROOT,
3368
+ "@assets": assetsAbs
3369
+ } },
2897
3370
  optimizeDeps: {
2898
3371
  entries: [path.join(APP_ROOT, "main.tsx")],
2899
3372
  include: [
@@ -2925,7 +3398,8 @@ async function createViteConfig(opts) {
2925
3398
  APP_ROOT,
2926
3399
  userCwd,
2927
3400
  slidesAbs,
2928
- themesAbs
3401
+ themesAbs,
3402
+ assetsAbs
2929
3403
  ] }
2930
3404
  },
2931
3405
  build: {