@agent-native/core 0.120.4 → 0.121.1

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 (66) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +20 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/agent/durable-background.ts +12 -4
  5. package/corpus/core/src/agent/run-manager.ts +3 -0
  6. package/corpus/core/src/client/use-db-sync.ts +23 -21
  7. package/corpus/core/src/deploy/build.ts +1 -0
  8. package/corpus/core/src/server/core-routes-plugin.ts +3 -0
  9. package/corpus/core/src/server/gateway-access-check.ts +67 -0
  10. package/corpus/core/src/server/poll.ts +295 -9
  11. package/corpus/core/src/server/short-lived-token.ts +122 -0
  12. package/corpus/templates/slides/app/components/editor/EditorToolbar.tsx +29 -4
  13. package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +365 -9
  14. package/corpus/templates/slides/app/components/editor/bullet-editing.ts +350 -0
  15. package/corpus/templates/slides/app/context/DeckContext.tsx +1 -3
  16. package/corpus/templates/slides/app/i18n/en-US.ts +2 -0
  17. package/corpus/templates/slides/app/pages/DeckEditor.tsx +5 -0
  18. package/corpus/templates/slides/changelog/2026-07-23-new-bullet-rows-created-with-enter-now-keep-the-list-item-s-.md +6 -0
  19. package/corpus/templates/slides/changelog/2026-07-23-pressing-enter-in-generated-checkbox-shape-marker-lists-now-.md +6 -0
  20. package/corpus/templates/slides/changelog/2026-07-24-typing-a-markdown-style-dash-space-at-the-start-of-a-text-bl.md +6 -0
  21. package/dist/agent/durable-background.d.ts.map +1 -1
  22. package/dist/agent/durable-background.js +16 -4
  23. package/dist/agent/durable-background.js.map +1 -1
  24. package/dist/agent/run-manager.d.ts.map +1 -1
  25. package/dist/agent/run-manager.js +6 -0
  26. package/dist/agent/run-manager.js.map +1 -1
  27. package/dist/client/use-db-sync.d.ts.map +1 -1
  28. package/dist/client/use-db-sync.js +27 -22
  29. package/dist/client/use-db-sync.js.map +1 -1
  30. package/dist/collab/awareness.d.ts +2 -2
  31. package/dist/collab/awareness.d.ts.map +1 -1
  32. package/dist/deploy/build.d.ts.map +1 -1
  33. package/dist/deploy/build.js +1 -0
  34. package/dist/deploy/build.js.map +1 -1
  35. package/dist/observability/routes.d.ts +3 -3
  36. package/dist/progress/routes.d.ts +1 -1
  37. package/dist/provider-api/actions/custom-provider-registration.d.ts +4 -4
  38. package/dist/provider-api/actions/provider-api.d.ts +6 -6
  39. package/dist/resources/handlers.d.ts +1 -1
  40. package/dist/secrets/routes.d.ts +9 -9
  41. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  42. package/dist/server/core-routes-plugin.js +3 -0
  43. package/dist/server/core-routes-plugin.js.map +1 -1
  44. package/dist/server/gateway-access-check.d.ts +12 -0
  45. package/dist/server/gateway-access-check.d.ts.map +1 -0
  46. package/dist/server/gateway-access-check.js +49 -0
  47. package/dist/server/gateway-access-check.js.map +1 -0
  48. package/dist/server/poll.d.ts +55 -1
  49. package/dist/server/poll.d.ts.map +1 -1
  50. package/dist/server/poll.js +254 -7
  51. package/dist/server/poll.js.map +1 -1
  52. package/dist/server/realtime-token.d.ts +1 -1
  53. package/dist/server/short-lived-token.d.ts +28 -0
  54. package/dist/server/short-lived-token.d.ts.map +1 -1
  55. package/dist/server/short-lived-token.js +78 -0
  56. package/dist/server/short-lived-token.js.map +1 -1
  57. package/dist/server/transcribe-voice.d.ts +1 -1
  58. package/package.json +1 -1
  59. package/src/agent/durable-background.ts +12 -4
  60. package/src/agent/run-manager.ts +3 -0
  61. package/src/client/use-db-sync.ts +23 -21
  62. package/src/deploy/build.ts +1 -0
  63. package/src/server/core-routes-plugin.ts +3 -0
  64. package/src/server/gateway-access-check.ts +67 -0
  65. package/src/server/poll.ts +295 -9
  66. package/src/server/short-lived-token.ts +122 -0
@@ -18,6 +18,7 @@ import {
18
18
  import { appStateKeyForBrowserTab } from "@shared/app-state-tabs";
19
19
  import {
20
20
  IconAlertTriangle,
21
+ IconArrowsMove,
21
22
  IconMaximize,
22
23
  IconZoomIn,
23
24
  IconZoomOut,
@@ -35,6 +36,13 @@ import { createPortal } from "react-dom";
35
36
  import { ExcalidrawSlide } from "@/components/deck/ExcalidrawSlide";
36
37
  import SlideRenderer from "@/components/deck/SlideRenderer";
37
38
  import type { SlideOverflowInfo } from "@/components/deck/SlideRenderer";
39
+ import {
40
+ convertMarkdownPrefixToBullet,
41
+ findEnclosingList,
42
+ insertBulletAfterCaret,
43
+ isBulletList,
44
+ ZERO_WIDTH_SPACE,
45
+ } from "@/components/editor/bullet-editing";
38
46
  import { Button } from "@/components/ui/button";
39
47
  import {
40
48
  Tooltip,
@@ -120,6 +128,9 @@ const RICH_BLOCK_TAGS = new Set(["P", "DIV", "BLOCKQUOTE", "LI", "UL", "OL"]);
120
128
  function isTextLeaf(el: HTMLElement): boolean {
121
129
  if (!el || el.tagName === "IMG") return false;
122
130
  if (el.classList.contains("fmd-img-placeholder")) return false;
131
+ // A user-placed text box stays editable even after its content is fully
132
+ // deleted, so an emptied box does not degrade into an unrecognized shape.
133
+ if (el.classList.contains("fmd-text-box")) return true;
123
134
  // Must contain some text
124
135
  if (!el.textContent?.trim()) return false;
125
136
  for (const child of Array.from(el.children)) {
@@ -172,7 +183,14 @@ function findSmartBlock(
172
183
  ): HTMLElement | null {
173
184
  let el: HTMLElement | null = target;
174
185
  while (el && root.contains(el)) {
175
- if (isTextLeaf(el)) return el;
186
+ if (isTextLeaf(el)) {
187
+ // A list item (native <li> or a styled bullet row) must be edited
188
+ // together with its siblings so Enter can add a new item — editing a
189
+ // single item in isolation traps Enter inside that one row.
190
+ const list = findEnclosingList(el, root);
191
+ if (list) return list;
192
+ return el;
193
+ }
176
194
  // The click landed on a container (e.g. a flex wrapper around stat
177
195
  // rows). If that container is a smart group, use IT as the block so
178
196
  // the user gets multi-chunk editing of everything inside.
@@ -200,13 +218,41 @@ function stripBuilderIds(html: string): string {
200
218
  }
201
219
  parent.removeChild(wrapper);
202
220
  }
203
- cleaned =
204
- doc.querySelector("[data-strip-root]")?.innerHTML ?? doc.body.innerHTML;
221
+ const stripRoot = doc.querySelector("[data-strip-root]");
222
+ if (stripRoot) stripPlaceholderZws(stripRoot);
223
+ cleaned = stripRoot?.innerHTML ?? doc.body.innerHTML;
205
224
  }
206
225
 
207
226
  return cleaned.replace(/\s*data-builder-id="[^"]*"/g, "");
208
227
  }
209
228
 
229
+ /**
230
+ * Remove zero-width-space characters used only as caret placeholders, while
231
+ * preserving a lone ZWS that is the sole content of an element — that ZWS keeps
232
+ * an empty bullet's text span from collapsing, so it retains its font. Stripping
233
+ * every ZWS (as a blanket regex did) drops that anchor and makes the next typed
234
+ * character fall back to the container's base font.
235
+ */
236
+ function stripPlaceholderZws(root: Element): void {
237
+ const walker = root.ownerDocument.createTreeWalker(
238
+ root,
239
+ NodeFilter.SHOW_TEXT,
240
+ );
241
+ const textNodes: Text[] = [];
242
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
243
+ textNodes.push(node as Text);
244
+ }
245
+ for (const textNode of textNodes) {
246
+ if (!textNode.data.includes(ZERO_WIDTH_SPACE)) continue;
247
+ const withoutZws = textNode.data.replaceAll(ZERO_WIDTH_SPACE, "");
248
+ if (withoutZws.length > 0) {
249
+ textNode.data = withoutZws;
250
+ } else if (textNode.parentNode?.childNodes.length !== 1) {
251
+ textNode.remove();
252
+ }
253
+ }
254
+ }
255
+
210
256
  function cssPx(value: string): number {
211
257
  const parsed = Number.parseFloat(value);
212
258
  return Number.isFinite(parsed) ? parsed : 0;
@@ -288,7 +334,9 @@ function buildStyleSnapshot(
288
334
  label: element.getAttribute("aria-label") || element.tagName.toLowerCase(),
289
335
  tagName: element.tagName.toLowerCase(),
290
336
  textPreview,
291
- isText: element.tagName !== "IMG" && !!textPreview,
337
+ isText:
338
+ element.tagName !== "IMG" &&
339
+ (!!textPreview || element.classList.contains("fmd-text-box")),
292
340
  isImage: element.tagName === "IMG",
293
341
  color: normalizedColor(computed.color),
294
342
  backgroundColor: normalizedColor(computed.backgroundColor),
@@ -394,6 +442,11 @@ interface SlideEditorProps {
394
442
  pinMode?: boolean;
395
443
  /** Called when pin mode should exit */
396
444
  onExitPinMode?: () => void;
445
+ /** Whether the "add text box" tool is active — the next click on the slide
446
+ * places a new text box there instead of selecting/marquee-selecting */
447
+ textBoxMode?: boolean;
448
+ /** Called after a text box is placed (or the tool should otherwise exit) */
449
+ onExitTextBoxMode?: () => void;
397
450
  /** Slide id for pin mode contextId — falls back to slide.id if omitted */
398
451
  slideId?: string;
399
452
  /** Slide title for pin mode contextLabel */
@@ -553,10 +606,13 @@ function ImageSelectionOutline({
553
606
  function ElementSelectionOutline({
554
607
  rect,
555
608
  viewportRect,
609
+ onDragStart,
556
610
  }: {
557
611
  rect: DOMRect;
558
612
  viewportRect: DOMRect | null;
613
+ onDragStart?: (e: React.PointerEvent) => void;
559
614
  }) {
615
+ const t = useT();
560
616
  const pad = 2;
561
617
  const handle = 7;
562
618
  const handleClass =
@@ -592,6 +648,23 @@ function ElementSelectionOutline({
592
648
  className={handleClass}
593
649
  style={{ right: -handle / 2, bottom: -handle / 2 }}
594
650
  />
651
+ {onDragStart && (
652
+ <span
653
+ onPointerDown={onDragStart}
654
+ title={t("raw.dragToMove")}
655
+ className="absolute flex items-center justify-center rounded-full border border-background bg-[#609FF8] shadow-sm cursor-move"
656
+ style={{
657
+ left: "50%",
658
+ top: -22,
659
+ width: 16,
660
+ height: 16,
661
+ transform: "translateX(-50%)",
662
+ pointerEvents: "auto",
663
+ }}
664
+ >
665
+ <IconArrowsMove className="size-2.5 text-background" />
666
+ </span>
667
+ )}
595
668
  </div>
596
669
  </SelectionOverlayPortal>
597
670
  );
@@ -737,6 +810,8 @@ export default function SlideEditor({
737
810
  onExitDrawMode,
738
811
  pinMode,
739
812
  onExitPinMode,
813
+ textBoxMode,
814
+ onExitTextBoxMode,
740
815
  slideId,
741
816
  slideTitle,
742
817
  deckId,
@@ -1013,6 +1088,10 @@ export default function SlideEditor({
1013
1088
  }, [overflowInfo, slide.id, dims.width, dims.height]);
1014
1089
  /** Marquee origin (viewport coords). Set on pointerdown. */
1015
1090
  const marqueeOriginRef = useRef<{ x: number; y: number } | null>(null);
1091
+ /** Set right before placing a text box so the click event that follows the
1092
+ * placing pointerdown doesn't fall through to click-to-select/deselect
1093
+ * logic and steal focus back off the freshly created box. */
1094
+ const suppressNextClickRef = useRef(false);
1016
1095
  /**
1017
1096
  * If the user pressed shift/cmd before starting a marquee, additive mode
1018
1097
  * preserves the existing selection on pointerup.
@@ -1203,7 +1282,14 @@ export default function SlideEditor({
1203
1282
  useEffect(() => {
1204
1283
  if (!editingEl) return;
1205
1284
  const editingSlideId = slide.id;
1206
- const handleInput = () => captureInlineEditDraft(editingSlideId);
1285
+ const handleInput = () => {
1286
+ // Markdown-style "- "/"* " at the start of a plain text block converts
1287
+ // it into a styled bullet row, so it's recognized as a list and Enter
1288
+ // can extend it — contentEditable has no native concept of these
1289
+ // styled (non-<ul>) bullet lists.
1290
+ convertMarkdownPrefixToBullet(editingEl);
1291
+ captureInlineEditDraft(editingSlideId);
1292
+ };
1207
1293
  editingEl.addEventListener("input", handleInput);
1208
1294
  return () => editingEl.removeEventListener("input", handleInput);
1209
1295
  }, [captureInlineEditDraft, editingEl, slide.id]);
@@ -1218,8 +1304,21 @@ export default function SlideEditor({
1218
1304
  // intent (rich-block edit vs single-line commit) doesn't change while
1219
1305
  // they're editing the same node, so latch it.
1220
1306
  const isMultiLineLeaf =
1221
- isTextLeaf(editingEl) && RICH_BLOCK_TAGS.has(editingEl.tagName);
1307
+ (isTextLeaf(editingEl) && RICH_BLOCK_TAGS.has(editingEl.tagName)) ||
1308
+ isBulletList(editingEl);
1222
1309
  const onKey = (e: KeyboardEvent) => {
1310
+ // Ignore keys from outside the slide (e.g. the style dock's font-size
1311
+ // input). Guard against the LIVE slide content, not `editingEl`, which a
1312
+ // re-render may have detached — a stale ref would wrongly bail here and
1313
+ // let native Enter run.
1314
+ const slideContent = getSlideContent();
1315
+ if (
1316
+ e.target instanceof Node &&
1317
+ slideContent &&
1318
+ !slideContent.contains(e.target)
1319
+ ) {
1320
+ return;
1321
+ }
1223
1322
  if (e.key === "Escape") {
1224
1323
  e.preventDefault();
1225
1324
  e.stopPropagation();
@@ -1229,12 +1328,34 @@ export default function SlideEditor({
1229
1328
  if (e.key === "Enter") {
1230
1329
  // Smart Enter:
1231
1330
  // - Shift+Enter always inserts a <br>.
1331
+ // - Inside a styled bullet list, Enter clones the current row so a
1332
+ // new bullet (marker + empty text) appears — contentEditable's
1333
+ // native split can't recreate the marker glyph.
1232
1334
  // - A single <p> or <div> leaf is multi-line capable — Enter
1233
1335
  // creates a new line via contentEditable's default behavior.
1234
1336
  // - Headings, inline leaves, and smart groups commit on Enter
1235
1337
  // so the slide layout can never be broken by a stray new node.
1236
1338
  if (e.shiftKey) return;
1237
1339
 
1340
+ // Re-derive the list from the LIVE caret so a re-render that swapped
1341
+ // the edited node can't drop us into native Enter.
1342
+ const sel = window.getSelection();
1343
+ const anchor = sel?.anchorNode ?? null;
1344
+ const anchorEl = anchor
1345
+ ? anchor.nodeType === Node.TEXT_NODE
1346
+ ? anchor.parentElement
1347
+ : (anchor as HTMLElement)
1348
+ : null;
1349
+ const liveList =
1350
+ anchorEl && slideContent && slideContent.contains(anchorEl)
1351
+ ? findEnclosingList(anchorEl, slideContent)
1352
+ : null;
1353
+ if (liveList && insertBulletAfterCaret(liveList)) {
1354
+ e.preventDefault();
1355
+ captureInlineEditDraft(slide.id);
1356
+ return;
1357
+ }
1358
+
1238
1359
  if (!isMultiLineLeaf) {
1239
1360
  e.preventDefault();
1240
1361
  exitInlineEdit();
@@ -1243,7 +1364,13 @@ export default function SlideEditor({
1243
1364
  };
1244
1365
  window.addEventListener("keydown", onKey, true);
1245
1366
  return () => window.removeEventListener("keydown", onKey, true);
1246
- }, [exitInlineEdit, editingEl]);
1367
+ }, [
1368
+ exitInlineEdit,
1369
+ editingEl,
1370
+ captureInlineEditDraft,
1371
+ slide.id,
1372
+ getSlideContent,
1373
+ ]);
1247
1374
 
1248
1375
  // Click-outside: exit inline edit mode
1249
1376
  useEffect(() => {
@@ -1493,6 +1620,52 @@ export default function SlideEditor({
1493
1620
  return () => window.removeEventListener("keydown", onKey);
1494
1621
  }, [multiSelection.size, editingEl, clearMultiSelection]);
1495
1622
 
1623
+ // Delete/Backspace removes the selected shape/text box (single or
1624
+ // multi-select) from the slide. Only active when something is selected for
1625
+ // styling (not while inline-editing text, where Backspace should delete a
1626
+ // character) and not while the browser focus is in an unrelated input.
1627
+ useEffect(() => {
1628
+ if (editingEl) return;
1629
+ if (multiSelection.size === 0 && !selectedElementSelector) return;
1630
+ const onKey = (e: KeyboardEvent) => {
1631
+ if (e.key !== "Delete" && e.key !== "Backspace") return;
1632
+ const active = document.activeElement;
1633
+ const tag = active?.tagName;
1634
+ if (tag === "INPUT" || tag === "TEXTAREA") return;
1635
+ if (active instanceof HTMLElement && active.isContentEditable) return;
1636
+
1637
+ const slideContent = getSlideContent();
1638
+ if (!slideContent) return;
1639
+ e.preventDefault();
1640
+
1641
+ if (multiSelection.size > 0) {
1642
+ for (const id of multiSelection) {
1643
+ slideContent.querySelector(`[data-builder-id="${id}"]`)?.remove();
1644
+ }
1645
+ clearMultiSelection();
1646
+ } else {
1647
+ resolveSelectedElement()?.remove();
1648
+ clearSelectedElement();
1649
+ }
1650
+
1651
+ const html = readCurrentSlideContentHtml();
1652
+ if (html !== null) onUpdateSlideRef.current({ content: html });
1653
+ syncSelectionToAppState(null);
1654
+ };
1655
+ window.addEventListener("keydown", onKey);
1656
+ return () => window.removeEventListener("keydown", onKey);
1657
+ }, [
1658
+ editingEl,
1659
+ multiSelection,
1660
+ selectedElementSelector,
1661
+ getSlideContent,
1662
+ clearMultiSelection,
1663
+ resolveSelectedElement,
1664
+ clearSelectedElement,
1665
+ readCurrentSlideContentHtml,
1666
+ syncSelectionToAppState,
1667
+ ]);
1668
+
1496
1669
  /**
1497
1670
  * Find the nearest meaningful "element" for multi-select from a click target.
1498
1671
  * Walks up to the closest [data-builder-id] inside the slide content. Skips
@@ -1537,16 +1710,95 @@ export default function SlideEditor({
1537
1710
  [],
1538
1711
  );
1539
1712
 
1713
+ const placeTextBoxAt = useCallback(
1714
+ (clientX: number, clientY: number) => {
1715
+ const fmdSlide = containerRef.current?.querySelector(
1716
+ ".fmd-slide",
1717
+ ) as HTMLElement | null;
1718
+ if (!fmdSlide) return;
1719
+ // .fmd-slide is often visually scaled (canvas zoom, and the autofit
1720
+ // system that shrinks overflowing slides to fit) via a CSS transform.
1721
+ // A transform doesn't change the element's own layout coordinate
1722
+ // space, so a pixel offset computed from its on-screen rect would be
1723
+ // scaled a second time once applied to a child. Percentages resolve
1724
+ // against that untransformed layout box, so they land at the actual
1725
+ // click point regardless of the current scale.
1726
+ const rect = fmdSlide.getBoundingClientRect();
1727
+ const xPct =
1728
+ rect.width > 0
1729
+ ? Math.min(
1730
+ 100,
1731
+ Math.max(0, ((clientX - rect.left) / rect.width) * 100),
1732
+ )
1733
+ : 0;
1734
+ const yPct =
1735
+ rect.height > 0
1736
+ ? Math.min(
1737
+ 100,
1738
+ Math.max(0, ((clientY - rect.top) / rect.height) * 100),
1739
+ )
1740
+ : 0;
1741
+
1742
+ if (getComputedStyle(fmdSlide).position === "static") {
1743
+ fmdSlide.style.position = "relative";
1744
+ }
1745
+
1746
+ const box = document.createElement("div");
1747
+ box.className = "fmd-text-box";
1748
+ box.style.position = "absolute";
1749
+ box.style.left = `${xPct}%`;
1750
+ box.style.top = `${yPct}%`;
1751
+ box.style.width = "320px";
1752
+ box.style.fontSize = "24px";
1753
+ box.style.color = "#fff";
1754
+ box.style.fontFamily = "'Poppins', sans-serif";
1755
+ box.style.lineHeight = "1.3";
1756
+ box.textContent = ZERO_WIDTH_SPACE;
1757
+ fmdSlide.appendChild(box);
1758
+
1759
+ enterInlineEdit(box);
1760
+
1761
+ // el.focus() alone doesn't reliably place the caret inside a freshly
1762
+ // created contentEditable node across browsers — set it explicitly on
1763
+ // the placeholder text so typing lands immediately.
1764
+ const textNode = box.firstChild;
1765
+ if (textNode) {
1766
+ const range = document.createRange();
1767
+ range.setStart(textNode, textNode.textContent?.length ?? 0);
1768
+ range.collapse(true);
1769
+ const selection = window.getSelection();
1770
+ selection?.removeAllRanges();
1771
+ selection?.addRange(range);
1772
+ }
1773
+ },
1774
+ [enterInlineEdit],
1775
+ );
1776
+
1540
1777
  // --- Marquee drag handlers (attached to slide-content via React props) ---
1541
1778
 
1542
1779
  const handleSlidePointerDown = useCallback(
1543
1780
  (e: React.PointerEvent) => {
1544
- if (editingEl) return; // don't interfere with inline edit
1545
1781
  if (e.button !== 0) return; // left click only
1546
1782
  const slideContent = getSlideContent();
1547
1783
  if (!slideContent) return;
1548
1784
  const target = e.target as HTMLElement;
1549
1785
 
1786
+ if (textBoxMode && isHtmlSlide) {
1787
+ // Unlike the marquee/select flow below, the text-box tool places a
1788
+ // box on the very next click no matter what it lands on (mirroring
1789
+ // Google Slides / PowerPoint). Gating this on "whitespace" left the
1790
+ // tool silently stuck on when a click landed on existing text —
1791
+ // editing that text worked, but the tool state never cleared, so an
1792
+ // unrelated later click would unexpectedly drop a new box.
1793
+ e.preventDefault();
1794
+ if (editingEl) exitInlineEdit();
1795
+ suppressNextClickRef.current = true;
1796
+ placeTextBoxAt(e.clientX, e.clientY);
1797
+ onExitTextBoxMode?.();
1798
+ return;
1799
+ }
1800
+ if (editingEl) return; // avoid interfering with an active inline edit
1801
+
1550
1802
  // Only start a marquee from "whitespace" inside the slide. Clicks on
1551
1803
  // an actual element fall through to handleSlideClick (which handles
1552
1804
  // shift/cmd-click toggle below).
@@ -1575,6 +1827,11 @@ export default function SlideEditor({
1575
1827
  multiSelection,
1576
1828
  applyMultiSelection,
1577
1829
  clearSelectedElement,
1830
+ textBoxMode,
1831
+ isHtmlSlide,
1832
+ exitInlineEdit,
1833
+ placeTextBoxAt,
1834
+ onExitTextBoxMode,
1578
1835
  ],
1579
1836
  );
1580
1837
 
@@ -1713,6 +1970,11 @@ export default function SlideEditor({
1713
1970
 
1714
1971
  const handleSlideClick = useCallback(
1715
1972
  (e: React.MouseEvent) => {
1973
+ if (suppressNextClickRef.current) {
1974
+ suppressNextClickRef.current = false;
1975
+ return;
1976
+ }
1977
+
1716
1978
  // If currently editing a block, clicks inside it are for the caret —
1717
1979
  // don't select/style-edit.
1718
1980
  if (editingEl?.contains(e.target as Node)) return;
@@ -1750,7 +2012,27 @@ export default function SlideEditor({
1750
2012
 
1751
2013
  showImageOverlay(target);
1752
2014
 
1753
- // Send style-editing postMessage with a unique selector for the clicked element
2015
+ // For editable text, a single click edits the whole smart block (a text
2016
+ // leaf, or an entire bullet list) — not the individual line — so typing,
2017
+ // highlighting, shortcuts, and Enter-to-add-bullet all work, and the
2018
+ // style dock targets the same block being edited.
2019
+ if (!readOnly && isHtmlSlide && slideContent) {
2020
+ const block = findSmartBlock(target, slideContent);
2021
+ if (block) {
2022
+ const blockSelector = getBuilderSelector(block);
2023
+ if (blockSelector) {
2024
+ selectElementForStyling(block, blockSelector);
2025
+ enterSelectionMode("agentNative.enterStyleEditing", {
2026
+ selector: blockSelector,
2027
+ });
2028
+ }
2029
+ enterInlineEdit(block);
2030
+ return;
2031
+ }
2032
+ }
2033
+
2034
+ // Non-text elements (images, shapes, …): select the clicked element for
2035
+ // styling.
1754
2036
  const selectableEl = slideContent
1755
2037
  ? findSelectableElement(target, slideContent)
1756
2038
  : null;
@@ -1772,6 +2054,9 @@ export default function SlideEditor({
1772
2054
  clearMultiSelection,
1773
2055
  clearSelectedElement,
1774
2056
  selectElementForStyling,
2057
+ readOnly,
2058
+ isHtmlSlide,
2059
+ enterInlineEdit,
1775
2060
  ],
1776
2061
  );
1777
2062
 
@@ -1837,6 +2122,63 @@ export default function SlideEditor({
1837
2122
  ],
1838
2123
  );
1839
2124
 
2125
+ /**
2126
+ * Drag-to-reposition for the selected element. Only elements that are (or
2127
+ * can safely become) absolutely positioned support this — repositioning an
2128
+ * in-flow element would shift the rest of the slide's layout. Percentages
2129
+ * (not px) are used for left/top so the drag stays accurate under canvas
2130
+ * zoom and the autofit scale transform, matching placeTextBoxAt.
2131
+ */
2132
+ const startElementDrag = useCallback(
2133
+ (e: React.PointerEvent) => {
2134
+ const element = resolveSelectedElement();
2135
+ if (!element) return;
2136
+ const fmdSlide = element.closest(".fmd-slide") as HTMLElement | null;
2137
+ if (!fmdSlide) return;
2138
+ // left/top only place an element at an absolute coordinate for
2139
+ // position: absolute (or fixed). For position: relative they're an
2140
+ // *offset* from the element's normal flow position instead, so
2141
+ // treating a relative element as draggable here would make it jump by
2142
+ // the wrong amount. Restrict dragging to elements already out of flow.
2143
+ if (getComputedStyle(element).position !== "absolute") return;
2144
+
2145
+ e.preventDefault();
2146
+ e.stopPropagation();
2147
+
2148
+ const slideRect = fmdSlide.getBoundingClientRect();
2149
+ const elRect = element.getBoundingClientRect();
2150
+ const startXPct =
2151
+ ((elRect.left - slideRect.left) / slideRect.width) * 100;
2152
+ const startYPct = ((elRect.top - slideRect.top) / slideRect.height) * 100;
2153
+ const startClientX = e.clientX;
2154
+ const startClientY = e.clientY;
2155
+ let moved = false;
2156
+
2157
+ const onMove = (moveEvent: PointerEvent) => {
2158
+ moved = true;
2159
+ const dxPct =
2160
+ ((moveEvent.clientX - startClientX) / slideRect.width) * 100;
2161
+ const dyPct =
2162
+ ((moveEvent.clientY - startClientY) / slideRect.height) * 100;
2163
+ element.style.left = `${startXPct + dxPct}%`;
2164
+ element.style.top = `${startYPct + dyPct}%`;
2165
+ setSelectedElementRect(element.getBoundingClientRect());
2166
+ };
2167
+
2168
+ const onUp = () => {
2169
+ window.removeEventListener("pointermove", onMove);
2170
+ window.removeEventListener("pointerup", onUp);
2171
+ if (!moved) return;
2172
+ const html = readCurrentSlideContentHtml();
2173
+ if (html !== null) onUpdateSlideRef.current({ content: html });
2174
+ };
2175
+
2176
+ window.addEventListener("pointermove", onMove);
2177
+ window.addEventListener("pointerup", onUp);
2178
+ },
2179
+ [resolveSelectedElement, readCurrentSlideContentHtml],
2180
+ );
2181
+
1840
2182
  // --- Pending visual updates ---
1841
2183
  const [pendingUpdateCount, setPendingUpdateCount] = useState(0);
1842
2184
 
@@ -1891,6 +2233,17 @@ export default function SlideEditor({
1891
2233
  const slideElementSelected =
1892
2234
  !!selectedImg || !!editingEl || !!selectedStyleSnapshot;
1893
2235
 
2236
+ // Dragging is only offered for elements with position: absolute — our own
2237
+ // placed text boxes, and any similarly-positioned shape. left/top on a
2238
+ // position: relative element is an offset from its normal flow position
2239
+ // rather than an absolute coordinate, so treating "not static" as
2240
+ // draggable would move relative-positioned elements by the wrong amount.
2241
+ const selectedForDrag =
2242
+ selectedElementRect && !editingEl ? resolveSelectedElement() : null;
2243
+ const isSelectedElementDraggable = selectedForDrag
2244
+ ? getComputedStyle(selectedForDrag).position === "absolute"
2245
+ : false;
2246
+
1894
2247
  return (
1895
2248
  <div
1896
2249
  className="relative flex h-full min-h-0 flex-1 flex-col overflow-hidden"
@@ -2098,6 +2451,9 @@ export default function SlideEditor({
2098
2451
  <ElementSelectionOutline
2099
2452
  rect={selectedElementRect}
2100
2453
  viewportRect={selectionViewportRect}
2454
+ onDragStart={
2455
+ isSelectedElementDraggable ? startElementDrag : undefined
2456
+ }
2101
2457
  />
2102
2458
  )}
2103
2459