@agent-native/core 0.120.3 → 0.121.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.
- package/corpus/README.md +2 -2
- package/corpus/core/CHANGELOG.md +22 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/client/use-db-sync.ts +23 -21
- package/corpus/core/src/deploy/build.ts +1 -0
- package/corpus/core/src/server/core-routes-plugin.ts +3 -0
- package/corpus/core/src/server/gateway-access-check.ts +67 -0
- package/corpus/core/src/server/poll.ts +295 -9
- package/corpus/core/src/server/short-lived-token.ts +122 -0
- package/corpus/templates/content/actions/update-document.ts +145 -91
- package/corpus/templates/content/app/components/editor/CommentComposer.tsx +4 -1
- package/corpus/templates/content/app/components/editor/CommentsSidebar.tsx +366 -164
- package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +51 -28
- package/corpus/templates/content/app/components/editor/database/sidebar.tsx +19 -8
- package/corpus/templates/content/app/i18n-data.ts +15 -0
- package/corpus/templates/content/changelog/2026-07-23-shared-pages-now-keep-durable-content-visible-and-comment-th.md +6 -0
- package/corpus/templates/content/changelog/2026-07-24-comment-drafts-now-stay-open-when-saving-fails-and-long-live.md +6 -0
- package/corpus/templates/design/actions/update-file.ts +85 -18
- package/corpus/templates/design/app/lib/design-save-outbox.ts +39 -0
- package/corpus/templates/slides/app/components/editor/EditorToolbar.tsx +29 -4
- package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +365 -9
- package/corpus/templates/slides/app/components/editor/bullet-editing.ts +350 -0
- package/corpus/templates/slides/app/context/DeckContext.tsx +1 -3
- package/corpus/templates/slides/app/i18n/en-US.ts +2 -0
- package/corpus/templates/slides/app/pages/DeckEditor.tsx +5 -0
- package/corpus/templates/slides/changelog/2026-07-23-new-bullet-rows-created-with-enter-now-keep-the-list-item-s-.md +6 -0
- package/corpus/templates/slides/changelog/2026-07-23-pressing-enter-in-generated-checkbox-shape-marker-lists-now-.md +6 -0
- package/corpus/templates/slides/changelog/2026-07-24-typing-a-markdown-style-dash-space-at-the-start-of-a-text-bl.md +6 -0
- package/corpus/toolkit/CHANGELOG.md +6 -0
- package/corpus/toolkit/package.json +1 -1
- package/corpus/toolkit/src/editor/useCollabReconcile.ts +83 -15
- package/dist/client/use-db-sync.d.ts.map +1 -1
- package/dist/client/use-db-sync.js +27 -22
- package/dist/client/use-db-sync.js.map +1 -1
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/awareness.d.ts.map +1 -1
- package/dist/deploy/build.d.ts.map +1 -1
- package/dist/deploy/build.js +1 -0
- package/dist/deploy/build.js.map +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- package/dist/notifications/routes.d.ts +1 -1
- package/dist/observability/routes.d.ts +3 -3
- package/dist/progress/routes.d.ts +1 -1
- package/dist/provider-api/actions/custom-provider-registration.d.ts +4 -4
- package/dist/provider-api/actions/provider-api.d.ts +6 -6
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/core-routes-plugin.d.ts.map +1 -1
- package/dist/server/core-routes-plugin.js +3 -0
- package/dist/server/core-routes-plugin.js.map +1 -1
- package/dist/server/gateway-access-check.d.ts +12 -0
- package/dist/server/gateway-access-check.d.ts.map +1 -0
- package/dist/server/gateway-access-check.js +49 -0
- package/dist/server/gateway-access-check.js.map +1 -0
- package/dist/server/poll.d.ts +55 -1
- package/dist/server/poll.d.ts.map +1 -1
- package/dist/server/poll.js +254 -7
- package/dist/server/poll.js.map +1 -1
- package/dist/server/realtime-token.d.ts +1 -1
- package/dist/server/short-lived-token.d.ts +28 -0
- package/dist/server/short-lived-token.d.ts.map +1 -1
- package/dist/server/short-lived-token.js +78 -0
- package/dist/server/short-lived-token.js.map +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/package.json +2 -2
- package/src/client/use-db-sync.ts +23 -21
- package/src/deploy/build.ts +1 -0
- package/src/server/core-routes-plugin.ts +3 -0
- package/src/server/gateway-access-check.ts +67 -0
- package/src/server/poll.ts +295 -9
- 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))
|
|
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
|
-
|
|
204
|
-
|
|
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:
|
|
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 = () =>
|
|
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
|
-
}, [
|
|
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
|
-
//
|
|
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
|
|