@agent-native/core 0.176.1 → 0.177.0-nightly-20260828155142
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 +1 -1
- package/corpus/templates/analytics/actions/set-workspace-feature-flag.ts +2 -2
- package/corpus/templates/analytics/app/components/agents/FeatureFlagsFleetPanel.tsx +16 -1
- package/corpus/templates/analytics/changelog/2026-08-27-show-analytics-s-own-registered-flags-in-fleet-feature-flag-.md +6 -0
- package/corpus/templates/analytics/server/lib/workspace-feature-flags.ts +204 -32
- package/corpus/templates/assets/changelog/2026-08-26-templates-across-brand-kits.md +1 -0
- package/corpus/templates/calendar/app/components/calendar/CreateEventDialog.tsx +61 -68
- package/corpus/templates/calendar/app/components/calendar/DayView.tsx +9 -3
- package/corpus/templates/calendar/app/components/calendar/EventDetailPopover.tsx +3 -3
- package/corpus/templates/calendar/app/components/calendar/WeekView.tsx +8 -3
- package/corpus/templates/calendar/app/lib/calendar-timezone.ts +12 -3
- package/corpus/templates/calendar/changelog/2026-08-28-all-day-events-stay-in-the-compact-top-bar-in-day-and-week-v.md +6 -0
- package/corpus/templates/calendar/changelog/2026-08-28-event-creators-can-choose-whether-a-meeting-shows-as-free-or.md +6 -0
- package/corpus/templates/clips/app/global.css +0 -27
- package/corpus/templates/clips/app/i18n/en-US.ts +2 -0
- package/corpus/templates/clips/app/routes/download.tsx +131 -16
- package/corpus/templates/clips/app/routes/share.$shareId.tsx +35 -20
- package/corpus/templates/clips/server/routes/api/clips-latest.json.get.ts +77 -22
- package/corpus/templates/forms/actions/response-insights.ts +4 -4
- package/corpus/templates/forms/actions/retry-response-deliveries.ts +26 -0
- package/corpus/templates/forms/server/db/schema.ts +64 -17
- package/corpus/templates/forms/server/handlers/submissions.ts +1022 -94
- package/corpus/templates/forms/server/lib/integrations.ts +100 -33
- package/corpus/templates/forms/server/middleware/00-public-cors.ts +1 -1
- package/corpus/templates/forms/server/plugins/db.ts +40 -0
- package/corpus/templates/plan/agent-native.json +1 -4
- package/corpus/templates/slides/app/components/deck/SlideRenderer.tsx +6 -1
- package/corpus/templates/slides/app/components/editor/EditorActionCluster.tsx +2 -2
- package/corpus/templates/slides/app/components/editor/EditorToolbar.tsx +6 -6
- package/corpus/templates/slides/app/components/editor/ImageOverlay.tsx +78 -12
- package/corpus/templates/slides/app/components/editor/SlideContextToolbar.tsx +4 -4
- package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +111 -27
- package/corpus/templates/slides/app/components/editor/SlidesLayersPanel.tsx +20 -34
- package/corpus/templates/slides/app/components/editor/slide-style.ts +2 -0
- package/corpus/templates/slides/app/global.css +35 -0
- package/corpus/templates/slides/app/lib/sanitize-slide-html.ts +133 -5
- package/corpus/templates/slides/app/lib/slide-image-replacement.ts +675 -0
- package/corpus/templates/slides/app/lib/validate-native-slide-html.ts +1 -0
- package/corpus/templates/slides/app/pages/DeckEditor.tsx +44 -44
- package/corpus/templates/slides/app/root.tsx +1 -22
- package/corpus/templates/slides/changelog/2026-08-27-image-fit-controls-apply-cover-crops-and-position.md +6 -0
- package/corpus/templates/slides/changelog/2026-08-28-shared-slides-with-a-style-block-render-again.md +6 -0
- package/dist/action.d.ts +3 -1
- package/dist/client/FeedbackButton.d.ts +12 -0
- package/dist/client/FeedbackButton.js +62 -28
- package/dist/client/app-providers.js +13 -1
- package/dist/client/feedback-context.d.ts +1 -0
- package/dist/client/feedback-context.js +3 -2
- package/dist/client/host-bridge.d.ts +1 -0
- package/dist/client/observability/ThumbsFeedback.js +73 -10
- package/dist/client/webmcp.d.ts +4 -0
- package/dist/client/webmcp.js +51 -4
- package/dist/collab/routes.d.ts +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/index.browser.d.ts +1 -1
- package/dist/index.browser.js +1 -1
- package/dist/observability/routes.d.ts +5 -5
- package/dist/observability/routes.js +8 -2
- package/dist/observability/store.d.ts +1 -1
- package/dist/observability/store.js +18 -3
- package/dist/observability/types.d.ts +1 -0
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +2 -2
- package/dist/secrets/routes.d.ts +9 -9
- package/dist/server/action-routes.d.ts +1 -6
- package/dist/server/action-routes.js +58 -17
- package/dist/server/agent-chat-plugin.js +8 -1
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/docs/content/webmcp.mdx +31 -12
- package/package.json +1 -1
|
@@ -84,6 +84,9 @@ import {
|
|
|
84
84
|
import {
|
|
85
85
|
createPlaceholderImageTarget,
|
|
86
86
|
imageFileLooksSupported,
|
|
87
|
+
imageOccurrenceInRenderedSlide,
|
|
88
|
+
normalizeImageObjectPosition,
|
|
89
|
+
type ImageObjectPosition,
|
|
87
90
|
type SlideImageDropPosition,
|
|
88
91
|
} from "@/lib/slide-image-replacement";
|
|
89
92
|
import { TAB_ID } from "@/lib/tab-id";
|
|
@@ -555,6 +558,22 @@ function buildStyleSnapshot(
|
|
|
555
558
|
const paddingBottom = cssPx(computed.paddingBottom);
|
|
556
559
|
const parsedZIndex = Number(computed.zIndex);
|
|
557
560
|
const zIndex = Number.isFinite(parsedZIndex) ? parsedZIndex : 0;
|
|
561
|
+
const isImage =
|
|
562
|
+
element.tagName === "IMG" ||
|
|
563
|
+
element.classList.contains("fmd-pptx-image") ||
|
|
564
|
+
element.getAttribute("data-pptx-element-kind") === "image";
|
|
565
|
+
const objectFit =
|
|
566
|
+
element.tagName === "IMG"
|
|
567
|
+
? computed.objectFit === "contain"
|
|
568
|
+
? "contain"
|
|
569
|
+
: "cover"
|
|
570
|
+
: undefined;
|
|
571
|
+
const objectPosition =
|
|
572
|
+
element.tagName === "IMG"
|
|
573
|
+
? normalizeImageObjectPosition(
|
|
574
|
+
element.style.objectPosition || computed.objectPosition,
|
|
575
|
+
)
|
|
576
|
+
: undefined;
|
|
558
577
|
|
|
559
578
|
const selectedTextStyle =
|
|
560
579
|
inlineTextStyle?.scope === "selection" ? inlineTextStyle : null;
|
|
@@ -572,10 +591,9 @@ function buildStyleSnapshot(
|
|
|
572
591
|
isText:
|
|
573
592
|
element.tagName !== "IMG" &&
|
|
574
593
|
(!!textPreview || element.classList.contains("fmd-text-box")),
|
|
575
|
-
isImage
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
element.getAttribute("data-pptx-element-kind") === "image",
|
|
594
|
+
isImage,
|
|
595
|
+
objectFit,
|
|
596
|
+
objectPosition,
|
|
579
597
|
color:
|
|
580
598
|
selectedColor === null || selectedColor === undefined
|
|
581
599
|
? normalizedColor(computed.color)
|
|
@@ -631,6 +649,7 @@ function selectionItemForElement(
|
|
|
631
649
|
element: HTMLElement,
|
|
632
650
|
runtimeSelector: string,
|
|
633
651
|
snapshot?: SlideStyleSnapshot,
|
|
652
|
+
imageStyle?: Pick<SlideStyleSnapshot, "objectFit" | "objectPosition">,
|
|
634
653
|
): SlideSelectionItem {
|
|
635
654
|
const identity = getSlideSelectionIdentity(element, runtimeSelector);
|
|
636
655
|
return {
|
|
@@ -647,7 +666,9 @@ function selectionItemForElement(
|
|
|
647
666
|
element instanceof HTMLImageElement
|
|
648
667
|
? (element.getAttribute("src") ?? undefined)
|
|
649
668
|
: (element.querySelector("img")?.getAttribute("src") ?? undefined),
|
|
650
|
-
style: snapshot
|
|
669
|
+
style: snapshot
|
|
670
|
+
? { ...snapshot, ...imageStyle, selector: identity.selector }
|
|
671
|
+
: undefined,
|
|
651
672
|
};
|
|
652
673
|
}
|
|
653
674
|
|
|
@@ -719,7 +740,16 @@ interface SlideEditorProps {
|
|
|
719
740
|
url: string,
|
|
720
741
|
position?: SlideImageDropPosition,
|
|
721
742
|
) => void;
|
|
722
|
-
onToggleObjectFit: (
|
|
743
|
+
onToggleObjectFit: (
|
|
744
|
+
imgSrc: string,
|
|
745
|
+
newFit: "cover" | "contain",
|
|
746
|
+
imageOccurrence?: number,
|
|
747
|
+
) => void;
|
|
748
|
+
onChangeObjectPosition: (
|
|
749
|
+
imgSrc: string,
|
|
750
|
+
objectPosition: ImageObjectPosition,
|
|
751
|
+
imageOccurrence?: number,
|
|
752
|
+
) => void;
|
|
723
753
|
/** Current user display info for cursor caret */
|
|
724
754
|
collabUser?: { name: string; color: string };
|
|
725
755
|
/** True briefly when AI agent is making edits */
|
|
@@ -1197,6 +1227,7 @@ export default function SlideEditor({
|
|
|
1197
1227
|
onDropImage,
|
|
1198
1228
|
onDropImageUrl,
|
|
1199
1229
|
onToggleObjectFit,
|
|
1230
|
+
onChangeObjectPosition,
|
|
1200
1231
|
agentActive,
|
|
1201
1232
|
slideIndex = 0,
|
|
1202
1233
|
designSystem,
|
|
@@ -1235,6 +1266,8 @@ export default function SlideEditor({
|
|
|
1235
1266
|
rect: DOMRect;
|
|
1236
1267
|
src: string;
|
|
1237
1268
|
objectFit: "cover" | "contain";
|
|
1269
|
+
objectPosition: ImageObjectPosition;
|
|
1270
|
+
imageOccurrence: number;
|
|
1238
1271
|
} | null>(null);
|
|
1239
1272
|
const [selectedImg, setSelectedImg] = useState<HTMLImageElement | null>(null);
|
|
1240
1273
|
const [selectionRect, setSelectionRect] = useState<DOMRect | null>(null);
|
|
@@ -2990,10 +3023,11 @@ export default function SlideEditor({
|
|
|
2990
3023
|
|
|
2991
3024
|
const getPlaceholderTarget = useCallback(
|
|
2992
3025
|
(placeholder: HTMLElement): string => {
|
|
2993
|
-
const
|
|
2994
|
-
const placeholders = slideContent
|
|
3026
|
+
const placeholders = containerRef.current
|
|
2995
3027
|
? Array.from(
|
|
2996
|
-
|
|
3028
|
+
containerRef.current.querySelectorAll<HTMLElement>(
|
|
3029
|
+
".slide-content .fmd-img-placeholder",
|
|
3030
|
+
),
|
|
2997
3031
|
)
|
|
2998
3032
|
: [];
|
|
2999
3033
|
const index = Math.max(0, placeholders.indexOf(placeholder));
|
|
@@ -3002,7 +3036,7 @@ export default function SlideEditor({
|
|
|
3002
3036
|
placeholder.textContent?.trim() || "image",
|
|
3003
3037
|
);
|
|
3004
3038
|
},
|
|
3005
|
-
[
|
|
3039
|
+
[],
|
|
3006
3040
|
);
|
|
3007
3041
|
|
|
3008
3042
|
const getImageReplacementTarget = useCallback(
|
|
@@ -5793,22 +5827,31 @@ export default function SlideEditor({
|
|
|
5793
5827
|
);
|
|
5794
5828
|
}, [multiSelection.size, multiSelectionRects, slide.id, slideIndex]);
|
|
5795
5829
|
|
|
5830
|
+
const publishImageSelection = useCallback(
|
|
5831
|
+
(
|
|
5832
|
+
element: HTMLElement,
|
|
5833
|
+
imageStyle?: Pick<SlideStyleSnapshot, "objectFit" | "objectPosition">,
|
|
5834
|
+
) => {
|
|
5835
|
+
const selector =
|
|
5836
|
+
getBuilderSelector(element) ??
|
|
5837
|
+
`[data-builder-id="${ensureBuilderId(element)}"]`;
|
|
5838
|
+
const snapshot = buildStyleSnapshot(element, selector);
|
|
5839
|
+
syncSelectionToAppState(
|
|
5840
|
+
buildSelectionState("image", [
|
|
5841
|
+
selectionItemForElement(
|
|
5842
|
+
element,
|
|
5843
|
+
selector,
|
|
5844
|
+
{ ...snapshot, isImage: true },
|
|
5845
|
+
imageStyle,
|
|
5846
|
+
),
|
|
5847
|
+
]),
|
|
5848
|
+
);
|
|
5849
|
+
},
|
|
5850
|
+
[buildSelectionState],
|
|
5851
|
+
);
|
|
5852
|
+
|
|
5796
5853
|
const showImageOverlay = useCallback(
|
|
5797
5854
|
(target: HTMLElement) => {
|
|
5798
|
-
const publishImageSelection = (element: HTMLElement) => {
|
|
5799
|
-
const selector =
|
|
5800
|
-
getBuilderSelector(element) ??
|
|
5801
|
-
`[data-builder-id="${ensureBuilderId(element)}"]`;
|
|
5802
|
-
const snapshot = buildStyleSnapshot(element, selector);
|
|
5803
|
-
syncSelectionToAppState(
|
|
5804
|
-
buildSelectionState("image", [
|
|
5805
|
-
selectionItemForElement(element, selector, {
|
|
5806
|
-
...snapshot,
|
|
5807
|
-
isImage: true,
|
|
5808
|
-
}),
|
|
5809
|
-
]),
|
|
5810
|
-
);
|
|
5811
|
-
};
|
|
5812
5855
|
if (target.tagName === "IMG") {
|
|
5813
5856
|
const img = target as HTMLImageElement;
|
|
5814
5857
|
const rect = img.getBoundingClientRect();
|
|
@@ -5818,8 +5861,22 @@ export default function SlideEditor({
|
|
|
5818
5861
|
? "contain"
|
|
5819
5862
|
: "cover"
|
|
5820
5863
|
) as "cover" | "contain";
|
|
5864
|
+
const computedStyle = window.getComputedStyle(img);
|
|
5865
|
+
const position = normalizeImageObjectPosition(
|
|
5866
|
+
img.style.objectPosition || computedStyle.objectPosition,
|
|
5867
|
+
);
|
|
5868
|
+
const imageOccurrence = imageOccurrenceInRenderedSlide(
|
|
5869
|
+
containerRef.current,
|
|
5870
|
+
img,
|
|
5871
|
+
);
|
|
5821
5872
|
setSelectedImg(img);
|
|
5822
|
-
setImageOverlay({
|
|
5873
|
+
setImageOverlay({
|
|
5874
|
+
rect,
|
|
5875
|
+
src,
|
|
5876
|
+
objectFit: fit,
|
|
5877
|
+
objectPosition: position,
|
|
5878
|
+
imageOccurrence,
|
|
5879
|
+
});
|
|
5823
5880
|
publishImageSelection(img);
|
|
5824
5881
|
return;
|
|
5825
5882
|
}
|
|
@@ -5834,11 +5891,13 @@ export default function SlideEditor({
|
|
|
5834
5891
|
rect,
|
|
5835
5892
|
src: getPlaceholderTarget(placeholder),
|
|
5836
5893
|
objectFit: "cover",
|
|
5894
|
+
objectPosition: "center center",
|
|
5895
|
+
imageOccurrence: 0,
|
|
5837
5896
|
});
|
|
5838
5897
|
publishImageSelection(placeholder);
|
|
5839
5898
|
}
|
|
5840
5899
|
},
|
|
5841
|
-
[
|
|
5900
|
+
[getPlaceholderTarget, publishImageSelection],
|
|
5842
5901
|
);
|
|
5843
5902
|
|
|
5844
5903
|
// Browsers put the dragged element's outerHTML on the "text/html" data
|
|
@@ -6993,6 +7052,7 @@ export default function SlideEditor({
|
|
|
6993
7052
|
anchorRect={imageOverlay.rect}
|
|
6994
7053
|
src={imageOverlay.src}
|
|
6995
7054
|
objectFit={imageOverlay.objectFit}
|
|
7055
|
+
objectPosition={imageOverlay.objectPosition}
|
|
6996
7056
|
onGenerate={onGenerateImage}
|
|
6997
7057
|
onLibrary={() => onOpenAssetLibrary(imageOverlay.src)}
|
|
6998
7058
|
onUpload={() => onUploadImage(imageOverlay.src)}
|
|
@@ -7000,8 +7060,32 @@ export default function SlideEditor({
|
|
|
7000
7060
|
onToggleObjectFit={() => {
|
|
7001
7061
|
const newFit =
|
|
7002
7062
|
imageOverlay.objectFit === "cover" ? "contain" : "cover";
|
|
7003
|
-
onToggleObjectFit(
|
|
7063
|
+
onToggleObjectFit(
|
|
7064
|
+
imageOverlay.src,
|
|
7065
|
+
newFit,
|
|
7066
|
+
imageOverlay.imageOccurrence,
|
|
7067
|
+
);
|
|
7004
7068
|
setImageOverlay({ ...imageOverlay, objectFit: newFit });
|
|
7069
|
+
if (selectedImg) {
|
|
7070
|
+
publishImageSelection(selectedImg, {
|
|
7071
|
+
objectFit: newFit,
|
|
7072
|
+
objectPosition: imageOverlay.objectPosition,
|
|
7073
|
+
});
|
|
7074
|
+
}
|
|
7075
|
+
}}
|
|
7076
|
+
onChangeObjectPosition={(objectPosition) => {
|
|
7077
|
+
onChangeObjectPosition(
|
|
7078
|
+
imageOverlay.src,
|
|
7079
|
+
objectPosition,
|
|
7080
|
+
imageOverlay.imageOccurrence,
|
|
7081
|
+
);
|
|
7082
|
+
setImageOverlay({ ...imageOverlay, objectPosition });
|
|
7083
|
+
if (selectedImg) {
|
|
7084
|
+
publishImageSelection(selectedImg, {
|
|
7085
|
+
objectFit: imageOverlay.objectFit,
|
|
7086
|
+
objectPosition,
|
|
7087
|
+
});
|
|
7088
|
+
}
|
|
7005
7089
|
}}
|
|
7006
7090
|
onClose={() => setImageOverlay(null)}
|
|
7007
7091
|
/>
|
|
@@ -2,18 +2,10 @@ import {
|
|
|
2
2
|
IconChevronDown,
|
|
3
3
|
IconChevronRight,
|
|
4
4
|
IconGripVertical,
|
|
5
|
-
IconLayersSubtract,
|
|
6
5
|
IconX,
|
|
7
6
|
} from "@tabler/icons-react";
|
|
8
7
|
import { useState, type DragEvent } from "react";
|
|
9
8
|
|
|
10
|
-
import { Button } from "@/components/ui/button";
|
|
11
|
-
import {
|
|
12
|
-
Tooltip,
|
|
13
|
-
TooltipContent,
|
|
14
|
-
TooltipTrigger,
|
|
15
|
-
} from "@/components/ui/tooltip";
|
|
16
|
-
|
|
17
9
|
export interface SlidesLayerNode {
|
|
18
10
|
id: string;
|
|
19
11
|
label: string;
|
|
@@ -82,11 +74,12 @@ function LayerRow({
|
|
|
82
74
|
return (
|
|
83
75
|
<div
|
|
84
76
|
role="treeitem"
|
|
77
|
+
aria-level={depth + 1}
|
|
85
78
|
aria-expanded={children.length ? expanded : undefined}
|
|
86
79
|
aria-selected={selected}
|
|
87
80
|
>
|
|
88
81
|
<div
|
|
89
|
-
className={`group flex
|
|
82
|
+
className={`group flex h-8 items-center gap-1 rounded-[5px] px-2 text-xs text-foreground/90 transition-colors ${selected ? "bg-accent text-accent-foreground" : "hover:bg-accent hover:text-foreground"} ${dragging ? "opacity-50" : ""}`}
|
|
90
83
|
style={{ paddingLeft: `${8 + depth * 16}px` }}
|
|
91
84
|
onDragOver={(event) => event.preventDefault()}
|
|
92
85
|
onDragEnter={() => setDragging(true)}
|
|
@@ -96,14 +89,14 @@ function LayerRow({
|
|
|
96
89
|
{children.length ? (
|
|
97
90
|
<button
|
|
98
91
|
type="button"
|
|
99
|
-
className="rounded p-0.5 text-muted-foreground hover:text-foreground"
|
|
92
|
+
className="rounded-sm p-0.5 text-muted-foreground outline-none hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring"
|
|
100
93
|
aria-label={expanded ? labels.collapse : labels.expand}
|
|
101
94
|
onClick={() => setExpanded((value) => !value)}
|
|
102
95
|
>
|
|
103
96
|
{expanded ? (
|
|
104
|
-
<IconChevronDown size={
|
|
97
|
+
<IconChevronDown size={16} />
|
|
105
98
|
) : (
|
|
106
|
-
<IconChevronRight size={
|
|
99
|
+
<IconChevronRight size={16} />
|
|
107
100
|
)}
|
|
108
101
|
</button>
|
|
109
102
|
) : (
|
|
@@ -112,7 +105,7 @@ function LayerRow({
|
|
|
112
105
|
<button
|
|
113
106
|
type="button"
|
|
114
107
|
draggable
|
|
115
|
-
className="flex min-w-0 flex-1 items-center gap-2 py-
|
|
108
|
+
className="flex min-w-0 flex-1 items-center gap-2 py-0.5 text-left outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
116
109
|
onDragStart={(event) => {
|
|
117
110
|
event.dataTransfer.setData("text/plain", node.id);
|
|
118
111
|
event.dataTransfer.effectAllowed = "move";
|
|
@@ -126,7 +119,7 @@ function LayerRow({
|
|
|
126
119
|
}
|
|
127
120
|
>
|
|
128
121
|
<IconGripVertical
|
|
129
|
-
size={
|
|
122
|
+
size={14}
|
|
130
123
|
className="shrink-0 text-muted-foreground/60"
|
|
131
124
|
aria-hidden="true"
|
|
132
125
|
/>
|
|
@@ -162,31 +155,24 @@ export function SlidesLayersPanel({
|
|
|
162
155
|
}: SlidesLayersPanelProps) {
|
|
163
156
|
return (
|
|
164
157
|
<aside
|
|
165
|
-
className="flex h-full w-
|
|
158
|
+
className="flex h-full w-72 min-w-0 flex-col bg-background"
|
|
166
159
|
aria-label={labels.title}
|
|
167
160
|
>
|
|
168
|
-
<header className="flex h-
|
|
169
|
-
<
|
|
170
|
-
<IconLayersSubtract size={16} aria-hidden="true" />
|
|
161
|
+
<header className="flex h-14 shrink-0 items-center justify-between border-b border-border px-4">
|
|
162
|
+
<h2 className="text-sm font-semibold text-foreground">
|
|
171
163
|
{labels.title}
|
|
172
|
-
</
|
|
173
|
-
<
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
>
|
|
182
|
-
<IconX size={16} />
|
|
183
|
-
</Button>
|
|
184
|
-
</TooltipTrigger>
|
|
185
|
-
<TooltipContent>{labels.close}</TooltipContent>
|
|
186
|
-
</Tooltip>
|
|
164
|
+
</h2>
|
|
165
|
+
<button
|
|
166
|
+
type="button"
|
|
167
|
+
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
168
|
+
onClick={onClose}
|
|
169
|
+
aria-label={labels.close}
|
|
170
|
+
>
|
|
171
|
+
<IconX className="size-4" />
|
|
172
|
+
</button>
|
|
187
173
|
</header>
|
|
188
174
|
<div
|
|
189
|
-
className="min-h-0 flex-1 overflow-y-auto
|
|
175
|
+
className="min-h-0 flex-1 overflow-y-auto py-2"
|
|
190
176
|
role="tree"
|
|
191
177
|
aria-label={labels.title}
|
|
192
178
|
>
|
|
@@ -1153,6 +1153,8 @@ button[title="Open agent sidebar"] {
|
|
|
1153
1153
|
display: flex;
|
|
1154
1154
|
flex-direction: column;
|
|
1155
1155
|
gap: 2px;
|
|
1156
|
+
max-height: calc(100vh - 16px);
|
|
1157
|
+
overflow-y: auto;
|
|
1156
1158
|
animation: overlay-fade-in 0.12s ease-out;
|
|
1157
1159
|
}
|
|
1158
1160
|
|
|
@@ -1217,6 +1219,39 @@ button[title="Open agent sidebar"] {
|
|
|
1217
1219
|
color: hsl(var(--popover-foreground));
|
|
1218
1220
|
}
|
|
1219
1221
|
|
|
1222
|
+
.image-overlay-position {
|
|
1223
|
+
display: flex;
|
|
1224
|
+
align-items: center;
|
|
1225
|
+
gap: 10px;
|
|
1226
|
+
padding: 8px 14px;
|
|
1227
|
+
border-radius: 8px;
|
|
1228
|
+
font-size: 13px;
|
|
1229
|
+
font-weight: 500;
|
|
1230
|
+
color: hsl(var(--popover-foreground) / 0.8);
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
.image-overlay-position:hover,
|
|
1234
|
+
.image-overlay-position:focus-within {
|
|
1235
|
+
background: hsl(var(--accent));
|
|
1236
|
+
color: hsl(var(--popover-foreground));
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
.image-overlay-position select {
|
|
1240
|
+
min-width: 0;
|
|
1241
|
+
flex: 1;
|
|
1242
|
+
border: 0;
|
|
1243
|
+
outline: none;
|
|
1244
|
+
background: transparent;
|
|
1245
|
+
color: inherit;
|
|
1246
|
+
font: inherit;
|
|
1247
|
+
cursor: pointer;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
.image-overlay-position option {
|
|
1251
|
+
background: hsl(var(--popover));
|
|
1252
|
+
color: hsl(var(--popover-foreground));
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1220
1255
|
/* ─── Inline Slide Editor (TipTap) ─── */
|
|
1221
1256
|
|
|
1222
1257
|
.slide-tiptap-editor {
|
|
@@ -311,13 +311,144 @@ function cleanNode(
|
|
|
311
311
|
return out;
|
|
312
312
|
}
|
|
313
313
|
|
|
314
|
+
/**
|
|
315
|
+
* Elements whose unclosed start tag swallows the rest of the document in a real
|
|
316
|
+
* parser. `embed` is deliberately absent: it is void, so it never has a closing
|
|
317
|
+
* tag and requiring one would truncate every slide that contains a valid one.
|
|
318
|
+
*/
|
|
319
|
+
const SWALLOWING_ELEMENTS = /^(script|style|textarea|iframe|object|svg|math)$/i;
|
|
320
|
+
|
|
321
|
+
/** Elements whose children the HTML parser reads as text rather than markup. */
|
|
322
|
+
const RAW_TEXT_ELEMENTS = /^(script|style|textarea|title)$/i;
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Start-tag positions in `html`, skipping comments and anything inside a quoted
|
|
326
|
+
* attribute value.
|
|
327
|
+
*
|
|
328
|
+
* Scanning the serialized string with a bare regex cannot tell a tag from text:
|
|
329
|
+
* `<p title="Use <style> here">` reads as a `<style>` start tag, and truncating
|
|
330
|
+
* there drops the rest of a perfectly valid slide.
|
|
331
|
+
*/
|
|
332
|
+
function startTagPositions(
|
|
333
|
+
html: string,
|
|
334
|
+
): { name: string; index: number; end: number }[] {
|
|
335
|
+
const found: { name: string; index: number; end: number }[] = [];
|
|
336
|
+
for (let i = 0; i < html.length; i++) {
|
|
337
|
+
if (html[i] !== "<") continue;
|
|
338
|
+
if (html.startsWith("<!--", i)) {
|
|
339
|
+
const end = html.indexOf("-->", i + 4);
|
|
340
|
+
i = end === -1 ? html.length : end + 2;
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
const name = /^<([a-z][a-z0-9-]*)/i.exec(html.slice(i, i + 32))?.[1];
|
|
344
|
+
// Walk to this tag's `>`, stepping over quoted values so a `<` or `>`
|
|
345
|
+
// inside one is not read as markup.
|
|
346
|
+
let cursor = i + 1;
|
|
347
|
+
let quote = "";
|
|
348
|
+
while (cursor < html.length) {
|
|
349
|
+
const char = html[cursor];
|
|
350
|
+
if (quote) {
|
|
351
|
+
if (char === quote) quote = "";
|
|
352
|
+
} else if (char === '"' || char === "'") {
|
|
353
|
+
quote = char;
|
|
354
|
+
} else if (char === ">") {
|
|
355
|
+
break;
|
|
356
|
+
}
|
|
357
|
+
cursor++;
|
|
358
|
+
}
|
|
359
|
+
if (!name) {
|
|
360
|
+
i = cursor;
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
const lower = name.toLowerCase();
|
|
364
|
+
found.push({ name: lower, index: i, end: cursor });
|
|
365
|
+
if (RAW_TEXT_ELEMENTS.test(lower)) {
|
|
366
|
+
// A raw-text element's body is text, not markup — `content: "<script>"`
|
|
367
|
+
// inside a stylesheet is a CSS string, and reading it as a start tag
|
|
368
|
+
// truncated everything after it. Skip to the close tag; no close tag is
|
|
369
|
+
// the unclosed case the caller is looking for, and everything past it is
|
|
370
|
+
// swallowed anyway, so there is nothing further to find.
|
|
371
|
+
const closing = new RegExp(`</\\s*${lower}\\s*>`, "i").exec(
|
|
372
|
+
html.slice(cursor),
|
|
373
|
+
);
|
|
374
|
+
if (!closing) return found;
|
|
375
|
+
i = cursor + closing.index + closing[0].length - 1;
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
i = cursor;
|
|
379
|
+
}
|
|
380
|
+
return found;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Truncates at the first swallowing element that never closes.
|
|
385
|
+
*
|
|
386
|
+
* The regex path has to drop the remainder to agree with `cleanNode`. It must
|
|
387
|
+
* check for the closing tag to do that: the sweep used to match any of these
|
|
388
|
+
* tags and cut to the end of the string unconditionally, which ate the
|
|
389
|
+
* sanitized `<style>` block the pass above it had just emitted — and every
|
|
390
|
+
* heading and paragraph after it. A deck with one stylesheet rendered as an
|
|
391
|
+
* empty slide on the SSR'd share and present pages.
|
|
392
|
+
*/
|
|
393
|
+
function dropFromFirstUnclosedRawText(html: string): string {
|
|
394
|
+
for (const { name, index } of startTagPositions(html)) {
|
|
395
|
+
if (!SWALLOWING_ELEMENTS.test(name)) continue;
|
|
396
|
+
const closing = new RegExp(`</\\s*${name}\\s*>`, "i");
|
|
397
|
+
if (!closing.test(html.slice(index))) return html.slice(0, index);
|
|
398
|
+
}
|
|
399
|
+
return html;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Rewrites `/` attribute separators inside start tags as spaces.
|
|
404
|
+
*
|
|
405
|
+
* `/` is a legal separator between attributes, so `<img src="x"/onerror="…">`
|
|
406
|
+
* is an image with a live handler — and every attribute scrub below is anchored
|
|
407
|
+
* on whitespace, so none of them matched it. Normalizing here rather than
|
|
408
|
+
* widening each scrub to `[\s/]+` is what keeps a legitimate value intact: a
|
|
409
|
+
* URL like `https://cdn.example/onerror=logo.png` lives inside quotes, and this
|
|
410
|
+
* only touches separators outside them.
|
|
411
|
+
*/
|
|
412
|
+
function normalizeTagAttributeSeparators(html: string): string {
|
|
413
|
+
const tags = startTagPositions(html);
|
|
414
|
+
if (!tags.length) return html;
|
|
415
|
+
let out = "";
|
|
416
|
+
let copied = 0;
|
|
417
|
+
for (const { index, end } of tags) {
|
|
418
|
+
// Start after the tag name so `</p>` and the opening `<` are untouched.
|
|
419
|
+
const nameEnd = /^<[a-z][a-z0-9-]*/i.exec(html.slice(index, end))?.[0]
|
|
420
|
+
.length;
|
|
421
|
+
if (nameEnd === undefined) continue;
|
|
422
|
+
const from = index + nameEnd;
|
|
423
|
+
let region = "";
|
|
424
|
+
let quote = "";
|
|
425
|
+
for (let i = from; i < end; i++) {
|
|
426
|
+
const char = html[i];
|
|
427
|
+
if (quote) {
|
|
428
|
+
if (char === quote) quote = "";
|
|
429
|
+
region += char;
|
|
430
|
+
} else if (char === '"' || char === "'") {
|
|
431
|
+
quote = char;
|
|
432
|
+
region += char;
|
|
433
|
+
} else if (char === "/") {
|
|
434
|
+
region += " ";
|
|
435
|
+
} else {
|
|
436
|
+
region += char;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
out += html.slice(copied, from) + region;
|
|
440
|
+
copied = end;
|
|
441
|
+
}
|
|
442
|
+
return out + html.slice(copied);
|
|
443
|
+
}
|
|
444
|
+
|
|
314
445
|
function sanitizeHtmlString(
|
|
315
446
|
html: string,
|
|
316
447
|
scopeSelector?: string,
|
|
317
448
|
allowBlobImages = false,
|
|
318
449
|
): string {
|
|
319
450
|
return (
|
|
320
|
-
html
|
|
451
|
+
normalizeTagAttributeSeparators(html)
|
|
321
452
|
.replace(/<style\b[^>]*>([\s\S]*?)<\/\s*style\s*>/gi, (_match, css) => {
|
|
322
453
|
const safeCss = sanitizeStyleSheet(String(css), scopeSelector);
|
|
323
454
|
return safeCss
|
|
@@ -335,10 +466,7 @@ function sanitizeHtmlString(
|
|
|
335
466
|
// twin runs instead of cleanNode(). An unclosed raw-text or embedding
|
|
336
467
|
// element swallows the rest of the document in a real parser, so dropping
|
|
337
468
|
// the remainder is what keeps this path agreeing with the DOM path.
|
|
338
|
-
.replace(
|
|
339
|
-
/<(script|style|textarea|iframe|object|embed|svg|math)\b[\s\S]*$/i,
|
|
340
|
-
"",
|
|
341
|
-
)
|
|
469
|
+
.replace(/[\s\S]*/, dropFromFirstUnclosedRawText)
|
|
342
470
|
.replace(
|
|
343
471
|
/<(script|iframe|object|embed|form|input|button|select|textarea|meta|base|link|svg|math)\b[^>]*\/?>/gi,
|
|
344
472
|
"",
|