@plannotator/ui 0.53.1 → 0.55.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/HANDOFF.md +274 -14
- package/README.md +114 -11
- package/components/AgentToolAnnouncementDialog.tsx +1 -1
- package/components/AgentsTab.tsx +1 -1
- package/components/AnnotationPanel.tsx +142 -24
- package/components/AnnotationSidebar.tsx +1 -1
- package/components/AnnotationToolbar.tsx +64 -9
- package/components/ApproveDropdown.tsx +7 -9
- package/components/AskSessionAnnouncementDialog.tsx +1 -1
- package/components/AttachmentsButton.tsx +1 -1
- package/components/BlockRenderer.tsx +22 -5
- package/components/CodeFilePopout.tsx +139 -4
- package/components/CodeSnippetPreview.tsx +174 -0
- package/components/CommentPopover.tsx +44 -8
- package/components/DecisionControl.tsx +31 -5
- package/components/DeferredMarkdownContentsRail.tsx +38 -0
- package/components/DiagramBlock.tsx +17 -12
- package/components/DiffFileTree.tsx +7 -6
- package/components/DocumentContents.tsx +74 -0
- package/components/EditorAnnotationCard.tsx +1 -1
- package/components/ExportModal.tsx +77 -7
- package/components/HostTitle.tsx +0 -4
- package/components/ImageAnnotator/Toolbar.tsx +25 -1
- package/components/ImageAnnotator/index.tsx +24 -4
- package/components/ImageLightbox.tsx +722 -126
- package/components/ImagePeek.tsx +38 -4
- package/components/ImageRegions.tsx +369 -0
- package/components/ImageThumbnail.tsx +1 -1
- package/components/ImageZoomStage.tsx +796 -0
- package/components/InboxAnnouncementDialog.tsx +202 -0
- package/components/InlineMarkdown.tsx +167 -174
- package/components/KeyboardShortcuts.tsx +23 -0
- package/components/LazyBlockBoundary.tsx +100 -0
- package/components/ListMarker.tsx +3 -3
- package/components/LookAndFeelAnnouncementDialog.tsx +12 -2
- package/components/MarkdownContentsRail.tsx +777 -0
- package/components/MenuVersionSection.tsx +31 -9
- package/components/PlanDecisionMenuExtras.tsx +129 -0
- package/components/PlanHeaderMenu.tsx +15 -1
- package/components/ProviderIcons.tsx +8 -0
- package/components/RefHoverCard.tsx +913 -0
- package/components/SendFailureToast.tsx +126 -0
- package/components/Settings.tsx +617 -126
- package/components/TableOfContents.tsx +20 -6
- package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
- package/components/ThemeProvider.tsx +14 -1
- package/components/ThemeTab.tsx +16 -24
- package/components/Tooltip.tsx +49 -18
- package/components/Viewer.tsx +288 -43
- package/components/ai/DocumentAIChatPanel.tsx +29 -3
- package/components/ai/NewChatButton.tsx +74 -0
- package/components/blocks/CodeBlock.tsx +1 -1
- package/components/blocks/HtmlBlock.tsx +7 -1
- package/components/blocks/QuestionBlock.tsx +532 -62
- package/components/diagram/DiagramComposer.tsx +5 -1
- package/components/diagram/DiagramPending.tsx +12 -2
- package/components/diagram/DiagramViewer.tsx +19 -3
- package/components/html-embed/HtmlEmbedBlock.tsx +374 -0
- package/components/html-embed/SvgFenceBlock.tsx +46 -0
- package/components/html-embed/index.ts +3 -0
- package/components/html-embed/languages.ts +15 -0
- package/components/html-embed/loadGate.ts +107 -0
- package/components/html-viewer/HtmlViewer.tsx +82 -6
- package/components/html-viewer/bridge-script.asset.js +173 -1
- package/components/html-viewer/bridge-script.ts +173 -1
- package/components/html-viewer/useHtmlAnnotation.ts +125 -0
- package/components/imageZoom.ts +276 -0
- package/components/plan-diff/PlanCleanDiffView.tsx +1 -1
- package/components/plan-diff/PlanDiffViewer.tsx +13 -2
- package/components/plan-diff/PlanRawDiffView.tsx +1 -1
- package/components/sidebar/FileBrowser.tsx +16 -2
- package/components/sidebar/SidebarTabs.tsx +3 -0
- package/config/index.ts +3 -0
- package/config/reviewView.ts +41 -1
- package/config/settings.ts +155 -4
- package/configure.ts +28 -0
- package/contents-rail.css +337 -0
- package/hooks/pfm/useCodeFilePopout.ts +7 -1
- package/hooks/useAIChat.ts +72 -25
- package/hooks/useAnnotationDraft.ts +71 -6
- package/hooks/useAnnotationHighlighter.ts +57 -3
- package/hooks/useCodeAnnotationDraft.ts +113 -17
- package/hooks/useCookieHostSetting.ts +96 -0
- package/hooks/useDocumentFont.ts +17 -0
- package/hooks/useDocumentTextSize.ts +20 -0
- package/hooks/useFileBrowser.ts +10 -1
- package/hooks/useHeadingScrollSpy.ts +77 -0
- package/hooks/useHoverPreview.ts +94 -0
- package/hooks/useLinkedDoc.ts +226 -13
- package/hooks/usePackageChangesSetting.ts +90 -0
- package/hooks/usePlanLookDialog.ts +67 -0
- package/hooks/usePrefersReducedMotion.ts +23 -0
- package/hooks/useSharing.ts +11 -7
- package/hooks/useSwipeStep.ts +597 -0
- package/hooks/useThemePaletteHalves.ts +37 -0
- package/hooks/useUpdateCheck.ts +22 -4
- package/hooks/useUpdateToast.ts +1 -1
- package/package.json +26 -23
- package/shortcuts/code-review/annotationToolbar.shortcuts.ts +1 -0
- package/shortcuts/code-review/explorer.shortcuts.ts +82 -0
- package/shortcuts/core.ts +63 -4
- package/shortcuts/index.ts +1 -0
- package/shortcuts/plan-review/annotationPanel.shortcuts.ts +1 -1
- package/shortcuts/plan-review/commentPopover.shortcuts.ts +1 -0
- package/shortcuts/runtime.ts +4 -3
- package/styles.css +1 -1
- package/theme.css +179 -1
- package/themes/plannotator-graphite.css +66 -0
- package/types.ts +26 -9
- package/utils/agentSwitch.ts +41 -20
- package/utils/agentToolSetting.ts +1 -1
- package/utils/aiChatFormat.ts +23 -7
- package/utils/annotationHelpers.ts +32 -4
- package/utils/annotationOwnsHighlight.ts +4 -2
- package/utils/asciidocParser.ts +570 -0
- package/utils/autoUpdateNotice.ts +24 -0
- package/utils/belowTitleSlot.ts +174 -0
- package/utils/blockParse.ts +45 -0
- package/utils/codeLineScroll.ts +100 -0
- package/utils/commentSubmitKey.ts +48 -0
- package/utils/cssFontFamily.ts +65 -0
- package/utils/decisionSpec.ts +190 -2
- package/utils/defaultNotesApp.ts +1 -1
- package/utils/diffFonts.ts +4 -1
- package/utils/documentFont.ts +68 -0
- package/utils/documentTextSize.ts +54 -0
- package/utils/fontDetect.ts +63 -0
- package/utils/headingScrollSpy.ts +92 -0
- package/utils/inboxAnnouncement.ts +78 -0
- package/utils/lookAndFeelAnnouncement.ts +82 -5
- package/utils/markdownExtensions.ts +2 -2
- package/utils/mermaid.ts +20 -3
- package/utils/mermaidTheme.ts +3 -51
- package/utils/notion.ts +58 -0
- package/utils/parser.ts +28 -1
- package/utils/platform.ts +46 -0
- package/utils/refCard.ts +82 -0
- package/utils/richClipboard.ts +380 -0
- package/utils/richClipboardLists.ts +57 -0
- package/utils/richCopyPolicy.ts +22 -0
- package/utils/scrollAnchor.ts +82 -0
- package/utils/sectionRefs.ts +123 -0
- package/utils/selectionBoundary.ts +40 -0
- package/utils/sendFailure.ts +85 -0
- package/utils/sharing.ts +5 -2
- package/utils/themeRegistry.ts +10 -0
- package/utils/themeTokens.ts +74 -0
- package/utils/updateNotice.ts +30 -8
- package/utils/vscodePanel.ts +9 -0
|
@@ -4,6 +4,7 @@ import { cn } from '../../lib/utils';
|
|
|
4
4
|
import type { ScreenRect } from '../../utils/diagram-projection';
|
|
5
5
|
import { SparklesIcon } from '../SparklesIcon';
|
|
6
6
|
import { Button } from '../ui/button';
|
|
7
|
+
import { isCommentSubmitKey } from '../../utils/commentSubmitKey';
|
|
7
8
|
import type { DiagramComposerDraft } from './useDiagramComments';
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -101,7 +102,10 @@ export function DiagramComposer({
|
|
|
101
102
|
onCancel();
|
|
102
103
|
return;
|
|
103
104
|
}
|
|
104
|
-
|
|
105
|
+
// Enter saves here whatever the "Enter saves a comment" setting says (it
|
|
106
|
+
// always has: a diagram comment is one short line); the shared check adds
|
|
107
|
+
// the IME guard Safari needs (keyCode 229) and keeps Shift+Enter a newline.
|
|
108
|
+
if (isCommentSubmitKey(event, true)) {
|
|
105
109
|
event.preventDefault();
|
|
106
110
|
if (canWrite && !asking && text.trim() !== '') onSubmit(text);
|
|
107
111
|
}
|
|
@@ -48,12 +48,22 @@ export const DiagramPending: React.FC<{ block: Block; kind: DiagramKind }> = ({
|
|
|
48
48
|
* The same pending state inside the boxes the block itself renders it in
|
|
49
49
|
* (the block wrapper, the inline host, the viewer's fallback slot), so the
|
|
50
50
|
* Suspense fallback occupies the same space the block will.
|
|
51
|
+
*
|
|
52
|
+
* With `notice` (the block's chunk failed to load, `LazyBlockBoundary`) the
|
|
53
|
+
* notice takes the status line's place over the same source.
|
|
51
54
|
*/
|
|
52
|
-
export const DiagramBlockPending: React.FC<{ block: Block; kind: DiagramKind }> = ({ block, kind }) => (
|
|
55
|
+
export const DiagramBlockPending: React.FC<{ block: Block; kind: DiagramKind; notice?: React.ReactNode }> = ({ block, kind, notice }) => (
|
|
53
56
|
<div className="annotation-exclude my-5 group relative" data-block-id={block.id} data-pinpoint-ignore="" data-diagram-block={kind}>
|
|
54
57
|
<div data-diagram-inline="">
|
|
55
58
|
<div data-diagram-fallback="" className="h-full min-h-0 w-full">
|
|
56
|
-
|
|
59
|
+
{notice === undefined ? (
|
|
60
|
+
<DiagramPending block={block} kind={kind} />
|
|
61
|
+
) : (
|
|
62
|
+
<>
|
|
63
|
+
{notice}
|
|
64
|
+
<DiagramInlineSource block={block} kind={kind} />
|
|
65
|
+
</>
|
|
66
|
+
)}
|
|
57
67
|
</div>
|
|
58
68
|
</div>
|
|
59
69
|
</div>
|
|
@@ -9,6 +9,7 @@ import { DiagramOverlay } from './DiagramOverlay';
|
|
|
9
9
|
import { useDiagramComments, type DiagramComment, type DiagramCreateComment } from './useDiagramComments';
|
|
10
10
|
import { useDiagramRender, type DiagramRenderState } from './useDiagramRender';
|
|
11
11
|
import { useDiagramSourceDraft, type SaveResult } from './useDiagramSourceDraft';
|
|
12
|
+
import { LazyBlockBoundary } from '../LazyBlockBoundary';
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* The one diagram viewer: the canvas with its overlay, the Source pane on
|
|
@@ -204,6 +205,12 @@ export function DiagramViewer({
|
|
|
204
205
|
if (!sourceOpen) cancelComposer();
|
|
205
206
|
}, [cancelComposer, sourceOpen]);
|
|
206
207
|
|
|
208
|
+
// A host that takes commenting away (no handler, no reason) closes an open
|
|
209
|
+
// composer: left open it became a Comment form nothing could save.
|
|
210
|
+
useEffect(() => {
|
|
211
|
+
if (!canCreate) cancelComposer();
|
|
212
|
+
}, [cancelComposer, canCreate]);
|
|
213
|
+
|
|
207
214
|
// The composer handed to the host: the draft's part goes out, and the
|
|
208
215
|
// host's cancel closes it only while it is still the open one.
|
|
209
216
|
const onHostDraftRef = useRef(onHostDraft);
|
|
@@ -284,9 +291,18 @@ export function DiagramViewer({
|
|
|
284
291
|
column and the pane stays stacked UNDER the canvas, which
|
|
285
292
|
`order-last` keeps while `md:order-first` puts it left from `md`. */}
|
|
286
293
|
{hasPane && sourceOpen && (
|
|
287
|
-
<
|
|
288
|
-
|
|
289
|
-
|
|
294
|
+
<LazyBlockBoundary
|
|
295
|
+
fallback={(notice) => (
|
|
296
|
+
<div data-diagram-source-pane-failed="" className={cn(PANE_CLASS, 'overflow-auto p-3')}>
|
|
297
|
+
{notice}
|
|
298
|
+
<pre className="text-xs text-muted-foreground"><code className="font-mono">{draft.draft}</code></pre>
|
|
299
|
+
</div>
|
|
300
|
+
)}
|
|
301
|
+
>
|
|
302
|
+
<Suspense fallback={<div data-diagram-source-pane-pending="" aria-hidden="true" className={PANE_CLASS} />}>
|
|
303
|
+
<DiagramSourcePane draft={draft} editable={editable} markedLines={markedLines} className={PANE_CLASS} />
|
|
304
|
+
</Suspense>
|
|
305
|
+
</LazyBlockBoundary>
|
|
290
306
|
)}
|
|
291
307
|
<div className="relative min-h-0 min-w-0 flex-1">
|
|
292
308
|
{showFallback ? (
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A ```html fence drawn as a live page in the document (approval
|
|
3
|
+
* "inbox-inline-html", 2026-10-28; the policy and the page's protocol are
|
|
4
|
+
* @plannotator/core/html-embed). Only for a host that serves embed documents
|
|
5
|
+
* (`HtmlEmbedHost`, the Viewer's `htmlEmbeds`); without one a fence stays
|
|
6
|
+
* code, as before.
|
|
7
|
+
*
|
|
8
|
+
* A sandboxed frame, as T3 Code draws an agent's HTML: an opaque origin, its
|
|
9
|
+
* height and theme by message (checked against the frame's own window), and
|
|
10
|
+
* a web link opened only while the person is using the frame (it has focus
|
|
11
|
+
* and the window has the person's activation).
|
|
12
|
+
*
|
|
13
|
+
* The look is the owner's: the page sits in the text with no card, border or
|
|
14
|
+
* background of its own (its document is transparent and takes the host's
|
|
15
|
+
* theme and color-scheme), as tall as its page up to `EMBED_CLIP_PX`, then a
|
|
16
|
+
* fade and "Show more". Under it one quiet line: "HTML · sandboxed", Code
|
|
17
|
+
* (the fence's source in place of the page) and Full screen (the same page in
|
|
18
|
+
* ui's popout, scrolling).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { Code } from 'lucide-react';
|
|
22
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
23
|
+
import {
|
|
24
|
+
EMBED_SANDBOX,
|
|
25
|
+
EMBED_THEME_TOKENS,
|
|
26
|
+
embedFragment,
|
|
27
|
+
embedThemeMessage,
|
|
28
|
+
readEmbedMessage,
|
|
29
|
+
type EmbedMode,
|
|
30
|
+
type EmbedTheme,
|
|
31
|
+
} from '@plannotator/core/html-embed';
|
|
32
|
+
import type { Block } from '../../types';
|
|
33
|
+
import { CodeBlock } from '../blocks/CodeBlock';
|
|
34
|
+
import { HostTitle } from '../HostTitle';
|
|
35
|
+
import { PopoutDialog } from '../PopoutDialog';
|
|
36
|
+
import { useTheme } from '../ThemeProvider';
|
|
37
|
+
import { acquireEmbedLoad, claimEmbedLive, onEmbedLiveFreed, releaseEmbedLive, useNearViewport } from './loadGate';
|
|
38
|
+
|
|
39
|
+
/** A host that can serve embed documents (the Inbox: POST /api/inbox/embeds). */
|
|
40
|
+
export interface HtmlEmbedHost {
|
|
41
|
+
/**
|
|
42
|
+
* Keep `html` and give the URL of its embed document, served under
|
|
43
|
+
* `EMBED_RESPONSE_CSP`. Null (or a rejection) shows the fence as code.
|
|
44
|
+
*/
|
|
45
|
+
frameUrl(html: string): Promise<string | null>;
|
|
46
|
+
/** Who wrote the page ("Claude Code"), for the caption's hover text. */
|
|
47
|
+
author?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** How tall a page shows in the text before "Show more" (30rem). */
|
|
51
|
+
export const EMBED_CLIP_PX = 480;
|
|
52
|
+
/** The frame's height until its page reports one. */
|
|
53
|
+
const EMBED_FIRST_HEIGHT = 120;
|
|
54
|
+
|
|
55
|
+
/** The host's theme, read from the page's tokens once they have applied. */
|
|
56
|
+
function useEmbedTheme(): EmbedTheme {
|
|
57
|
+
const { colorTheme, resolvedMode } = useTheme();
|
|
58
|
+
const read = useCallback((): EmbedTheme => {
|
|
59
|
+
const style = getComputedStyle(document.documentElement);
|
|
60
|
+
const vars: Record<string, string> = {};
|
|
61
|
+
for (const name of EMBED_THEME_TOKENS) {
|
|
62
|
+
const value = style.getPropertyValue(name).trim();
|
|
63
|
+
if (value) vars[name] = value;
|
|
64
|
+
}
|
|
65
|
+
return { vars, scheme: resolvedMode === 'light' ? 'light' : 'dark' };
|
|
66
|
+
}, [resolvedMode]);
|
|
67
|
+
const [theme, setTheme] = useState<EmbedTheme>(read);
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
// The provider writes the tokens in its own effect, after this one: read on the next frame.
|
|
70
|
+
const frame = requestAnimationFrame(() => setTheme(read()));
|
|
71
|
+
return () => cancelAnimationFrame(frame);
|
|
72
|
+
}, [colorTheme, read]);
|
|
73
|
+
return theme;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** How long a page has to speak (its bootstrap reports a height) before the block shows its code. */
|
|
77
|
+
let readyTimeoutMs = 10_000;
|
|
78
|
+
/** Tests only. */
|
|
79
|
+
export function setEmbedReadyTimeoutForTests(ms: number): void {
|
|
80
|
+
readyTimeoutMs = ms;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* One embed frame and its messages. `onLoaded` runs on its load. A link the
|
|
85
|
+
* page asks for opens only while the person is using this frame: it has
|
|
86
|
+
* focus, and the window has the person's activation (as T3 Code).
|
|
87
|
+
*/
|
|
88
|
+
function EmbedFrame({
|
|
89
|
+
url,
|
|
90
|
+
theme,
|
|
91
|
+
mode,
|
|
92
|
+
title,
|
|
93
|
+
height,
|
|
94
|
+
onHeight,
|
|
95
|
+
onEscape,
|
|
96
|
+
onLoaded,
|
|
97
|
+
onReady,
|
|
98
|
+
}: {
|
|
99
|
+
url: string;
|
|
100
|
+
theme: EmbedTheme;
|
|
101
|
+
mode: EmbedMode;
|
|
102
|
+
title: string;
|
|
103
|
+
height?: number;
|
|
104
|
+
onHeight?: (height: number) => void;
|
|
105
|
+
onEscape: (frame: HTMLIFrameElement | null) => void;
|
|
106
|
+
onLoaded?: () => void;
|
|
107
|
+
onReady?: () => void;
|
|
108
|
+
}) {
|
|
109
|
+
const ref = useRef<HTMLIFrameElement>(null);
|
|
110
|
+
const loaded = useRef(false);
|
|
111
|
+
// The theme the page starts with rides in the URL (no flash); later ones go by message.
|
|
112
|
+
const [src] = useState(() => url + embedFragment(theme, mode));
|
|
113
|
+
const handlers = useRef({ onHeight, onEscape, onLoaded, onReady });
|
|
114
|
+
handlers.current = { onHeight, onEscape, onLoaded, onReady };
|
|
115
|
+
const spoke = useRef(false);
|
|
116
|
+
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
const onMessage = (event: MessageEvent) => {
|
|
119
|
+
// Only this frame's own page: every other window (another embed, a page it framed) is ignored.
|
|
120
|
+
const frame = ref.current;
|
|
121
|
+
if (!frame || event.source !== frame.contentWindow) return;
|
|
122
|
+
const message = readEmbedMessage(event.data);
|
|
123
|
+
if (!message) return;
|
|
124
|
+
if (!spoke.current) {
|
|
125
|
+
spoke.current = true;
|
|
126
|
+
handlers.current.onReady?.();
|
|
127
|
+
}
|
|
128
|
+
if (message.kind === 'height') handlers.current.onHeight?.(message.height);
|
|
129
|
+
else if (message.kind === 'link') {
|
|
130
|
+
const activation = (navigator as Navigator & { userActivation?: { isActive: boolean } }).userActivation;
|
|
131
|
+
if (document.activeElement !== frame || activation?.isActive === false) return;
|
|
132
|
+
window.open(message.url, '_blank', 'noopener,noreferrer');
|
|
133
|
+
} else handlers.current.onEscape(frame);
|
|
134
|
+
};
|
|
135
|
+
window.addEventListener('message', onMessage);
|
|
136
|
+
return () => window.removeEventListener('message', onMessage);
|
|
137
|
+
}, []);
|
|
138
|
+
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
if (loaded.current) ref.current?.contentWindow?.postMessage(embedThemeMessage(theme), '*');
|
|
141
|
+
}, [theme]);
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<iframe
|
|
145
|
+
ref={ref}
|
|
146
|
+
src={src}
|
|
147
|
+
sandbox={EMBED_SANDBOX}
|
|
148
|
+
referrerPolicy="no-referrer"
|
|
149
|
+
loading="lazy"
|
|
150
|
+
title={title}
|
|
151
|
+
data-html-embed-frame={mode}
|
|
152
|
+
onLoad={() => {
|
|
153
|
+
if (loaded.current) return;
|
|
154
|
+
loaded.current = true;
|
|
155
|
+
handlers.current.onLoaded?.();
|
|
156
|
+
}}
|
|
157
|
+
style={{
|
|
158
|
+
display: 'block',
|
|
159
|
+
width: '100%',
|
|
160
|
+
height: mode === 'full' ? '100%' : `${height ?? EMBED_FIRST_HEIGHT}px`,
|
|
161
|
+
border: 0,
|
|
162
|
+
background: 'transparent',
|
|
163
|
+
// Must match the page's own color-scheme, or the browser paints an opaque backdrop behind it.
|
|
164
|
+
colorScheme: theme.scheme,
|
|
165
|
+
}}
|
|
166
|
+
/>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
type Phase = 'far' | 'waiting' | 'loading' | 'ready' | 'failed';
|
|
171
|
+
|
|
172
|
+
export function HtmlEmbedBlock({ block, host }: { block: Block; host: HtmlEmbedHost }) {
|
|
173
|
+
const figureRef = useRef<HTMLElement>(null);
|
|
174
|
+
const near = useNearViewport(figureRef);
|
|
175
|
+
const theme = useEmbedTheme();
|
|
176
|
+
const [showCode, setShowCode] = useState(false);
|
|
177
|
+
const [phase, setPhase] = useState<Phase>('far');
|
|
178
|
+
const [url, setUrl] = useState<string | null>(null);
|
|
179
|
+
const [height, setHeight] = useState<number | undefined>(undefined);
|
|
180
|
+
const [expanded, setExpanded] = useState(false);
|
|
181
|
+
const [full, setFull] = useState(false);
|
|
182
|
+
const [forced, setForced] = useState(false);
|
|
183
|
+
const releaseLoad = useRef<(() => void) | null>(null);
|
|
184
|
+
// The host is read when a page is asked for: a new host object (a re-render) never reloads the page.
|
|
185
|
+
const hostRef = useRef(host);
|
|
186
|
+
hostRef.current = host;
|
|
187
|
+
|
|
188
|
+
// Near the screen: take a live slot (or wait for one), a load permit, then the page's URL.
|
|
189
|
+
useEffect(() => {
|
|
190
|
+
if (!near || showCode) return;
|
|
191
|
+
let cancelled = false;
|
|
192
|
+
let holdsLive = false;
|
|
193
|
+
let stopWaiting: (() => void) | null = null;
|
|
194
|
+
const start = () => {
|
|
195
|
+
if (cancelled) return;
|
|
196
|
+
if (!claimEmbedLive(forced)) {
|
|
197
|
+
setPhase('waiting');
|
|
198
|
+
stopWaiting = onEmbedLiveFreed(() => {
|
|
199
|
+
stopWaiting?.();
|
|
200
|
+
stopWaiting = null;
|
|
201
|
+
start();
|
|
202
|
+
});
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
holdsLive = true;
|
|
206
|
+
setPhase('loading');
|
|
207
|
+
void acquireEmbedLoad().then(async (release) => {
|
|
208
|
+
if (cancelled) return release();
|
|
209
|
+
releaseLoad.current = release;
|
|
210
|
+
let next: string | null = null;
|
|
211
|
+
try {
|
|
212
|
+
next = await hostRef.current.frameUrl(block.content);
|
|
213
|
+
} catch {
|
|
214
|
+
next = null;
|
|
215
|
+
}
|
|
216
|
+
if (cancelled) return release();
|
|
217
|
+
if (next === null) {
|
|
218
|
+
release();
|
|
219
|
+
setPhase('failed');
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
setUrl(next);
|
|
223
|
+
});
|
|
224
|
+
};
|
|
225
|
+
start();
|
|
226
|
+
return () => {
|
|
227
|
+
cancelled = true;
|
|
228
|
+
stopWaiting?.();
|
|
229
|
+
releaseLoad.current?.();
|
|
230
|
+
releaseLoad.current = null;
|
|
231
|
+
if (holdsLive) releaseEmbedLive();
|
|
232
|
+
setUrl(null);
|
|
233
|
+
};
|
|
234
|
+
}, [near, showCode, forced, block.content]);
|
|
235
|
+
|
|
236
|
+
// Loaded: the load permit goes back. Ready only once the page speaks (a frame the browser
|
|
237
|
+
// refused loads too, blank, and never speaks).
|
|
238
|
+
const loaded = useCallback(() => {
|
|
239
|
+
releaseLoad.current?.();
|
|
240
|
+
releaseLoad.current = null;
|
|
241
|
+
}, []);
|
|
242
|
+
const ready = useCallback(() => {
|
|
243
|
+
releaseLoad.current?.();
|
|
244
|
+
releaseLoad.current = null;
|
|
245
|
+
setPhase((now) => (now === 'loading' ? 'ready' : now));
|
|
246
|
+
}, []);
|
|
247
|
+
|
|
248
|
+
// A page that never speaks (the frame was refused, or the Inbox lost it): its code instead.
|
|
249
|
+
useEffect(() => {
|
|
250
|
+
if (phase !== 'loading' || url === null) return;
|
|
251
|
+
const timer = setTimeout(() => setPhase((now) => (now === 'loading' ? 'failed' : now)), readyTimeoutMs);
|
|
252
|
+
return () => clearTimeout(timer);
|
|
253
|
+
}, [phase, url]);
|
|
254
|
+
|
|
255
|
+
const escapeInline = useCallback((frame: HTMLIFrameElement | null) => {
|
|
256
|
+
// Escape in the page hands the keyboard back to the document (the host's own Escape comes next).
|
|
257
|
+
if (frame && document.activeElement === frame) frame.blur();
|
|
258
|
+
}, []);
|
|
259
|
+
|
|
260
|
+
const clipped = !expanded && height !== undefined && height > EMBED_CLIP_PX;
|
|
261
|
+
const title = host.author ? `HTML from ${host.author}` : 'HTML page';
|
|
262
|
+
const caption = host.author ? `From ${host.author}. It runs in a sandbox and can load from the web.` : 'It runs in a sandbox and can load from the web.';
|
|
263
|
+
const live = !showCode && url !== null;
|
|
264
|
+
|
|
265
|
+
const body = useMemo(() => {
|
|
266
|
+
if (showCode || phase === 'failed') return <CodeBlock block={block} isHovered={false} />;
|
|
267
|
+
if (phase === 'waiting') {
|
|
268
|
+
return (
|
|
269
|
+
<button
|
|
270
|
+
type="button"
|
|
271
|
+
data-html-embed-waiting=""
|
|
272
|
+
className="flex w-full items-center justify-center rounded-lg border border-dashed border-border py-6 text-xs text-muted-foreground hover:text-foreground"
|
|
273
|
+
style={{ minHeight: height ? Math.min(height, EMBED_CLIP_PX) : EMBED_FIRST_HEIGHT }}
|
|
274
|
+
onClick={() => setForced(true)}
|
|
275
|
+
>
|
|
276
|
+
Show page
|
|
277
|
+
</button>
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
return null;
|
|
281
|
+
}, [showCode, phase, block, height]);
|
|
282
|
+
|
|
283
|
+
return (
|
|
284
|
+
<figure ref={figureRef} data-block-id={block.id} data-html-embed="" data-html-embed-phase={showCode ? 'code' : phase} className="my-4">
|
|
285
|
+
{body ?? (
|
|
286
|
+
<div className="relative">
|
|
287
|
+
<div
|
|
288
|
+
data-html-embed-clip=""
|
|
289
|
+
style={{
|
|
290
|
+
maxHeight: clipped ? EMBED_CLIP_PX : undefined,
|
|
291
|
+
overflow: 'hidden',
|
|
292
|
+
// A fade that needs no background color: the message shows through it.
|
|
293
|
+
maskImage: clipped ? 'linear-gradient(to bottom, black calc(100% - 72px), transparent)' : undefined,
|
|
294
|
+
WebkitMaskImage: clipped ? 'linear-gradient(to bottom, black calc(100% - 72px), transparent)' : undefined,
|
|
295
|
+
}}
|
|
296
|
+
>
|
|
297
|
+
{live ? (
|
|
298
|
+
<EmbedFrame
|
|
299
|
+
key={url}
|
|
300
|
+
url={url}
|
|
301
|
+
theme={theme}
|
|
302
|
+
mode="inline"
|
|
303
|
+
title={title}
|
|
304
|
+
height={height}
|
|
305
|
+
onHeight={setHeight}
|
|
306
|
+
onEscape={escapeInline}
|
|
307
|
+
onLoaded={loaded}
|
|
308
|
+
onReady={ready}
|
|
309
|
+
/>
|
|
310
|
+
) : (
|
|
311
|
+
<div aria-hidden="true" style={{ height: height ?? EMBED_FIRST_HEIGHT }} />
|
|
312
|
+
)}
|
|
313
|
+
</div>
|
|
314
|
+
{clipped && (
|
|
315
|
+
<div className="absolute inset-x-0 bottom-1 flex justify-center">
|
|
316
|
+
<button
|
|
317
|
+
type="button"
|
|
318
|
+
data-html-embed-more=""
|
|
319
|
+
className="rounded-full border border-border bg-background px-3.5 py-1 text-xs font-medium text-foreground shadow-sm hover:bg-muted"
|
|
320
|
+
onClick={() => setExpanded(true)}
|
|
321
|
+
>
|
|
322
|
+
Show more
|
|
323
|
+
</button>
|
|
324
|
+
</div>
|
|
325
|
+
)}
|
|
326
|
+
</div>
|
|
327
|
+
)}
|
|
328
|
+
<figcaption data-html-embed-caption="" className="mt-1 flex items-center gap-1 text-xs text-muted-foreground">
|
|
329
|
+
<Code aria-hidden="true" className="h-3.5 w-3.5 shrink-0" />
|
|
330
|
+
<HostTitle title={caption}>
|
|
331
|
+
<span className="truncate">HTML · sandboxed</span>
|
|
332
|
+
</HostTitle>
|
|
333
|
+
<span className="flex-1" />
|
|
334
|
+
{expanded && !showCode && (
|
|
335
|
+
<button type="button" className="rounded px-1.5 py-0.5 hover:bg-muted hover:text-foreground" onClick={() => setExpanded(false)}>
|
|
336
|
+
Show less
|
|
337
|
+
</button>
|
|
338
|
+
)}
|
|
339
|
+
<button
|
|
340
|
+
type="button"
|
|
341
|
+
data-html-embed-code=""
|
|
342
|
+
aria-pressed={showCode}
|
|
343
|
+
className={`rounded px-1.5 py-0.5 hover:bg-muted hover:text-foreground ${showCode ? 'bg-muted text-foreground' : ''}`}
|
|
344
|
+
onClick={() => setShowCode((value) => !value)}
|
|
345
|
+
>
|
|
346
|
+
Code
|
|
347
|
+
</button>
|
|
348
|
+
<button
|
|
349
|
+
type="button"
|
|
350
|
+
data-html-embed-full=""
|
|
351
|
+
disabled={url === null || showCode}
|
|
352
|
+
className="rounded px-1.5 py-0.5 hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
|
|
353
|
+
onClick={() => setFull(true)}
|
|
354
|
+
>
|
|
355
|
+
Full screen
|
|
356
|
+
</button>
|
|
357
|
+
</figcaption>
|
|
358
|
+
{full && url !== null && (
|
|
359
|
+
<PopoutDialog
|
|
360
|
+
open
|
|
361
|
+
onClose={() => setFull(false)}
|
|
362
|
+
title={title}
|
|
363
|
+
className="h-[calc(100vh-2rem)] w-[calc(100vw-2rem)] max-w-none"
|
|
364
|
+
dataAttributes={{ 'data-html-embed-popout': '' }}
|
|
365
|
+
>
|
|
366
|
+
<div className="flex h-9 shrink-0 items-center border-b border-border pl-4 pr-12 text-xs font-medium text-foreground">{title}</div>
|
|
367
|
+
<div className="min-h-0 flex-1">
|
|
368
|
+
<EmbedFrame url={url} theme={theme} mode="full" title={title} onEscape={() => setFull(false)} />
|
|
369
|
+
</div>
|
|
370
|
+
</PopoutDialog>
|
|
371
|
+
)}
|
|
372
|
+
</figure>
|
|
373
|
+
);
|
|
374
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A ```svg fence drawn as an image (approval "inbox-inline-html"), for a host
|
|
3
|
+
* with `htmlEmbeds`. Through `<img>` an SVG runs no script and loads nothing,
|
|
4
|
+
* so it needs no frame. Code shows the source in its place.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Code } from 'lucide-react';
|
|
8
|
+
import { useMemo, useState } from 'react';
|
|
9
|
+
import type { Block } from '../../types';
|
|
10
|
+
import { CodeBlock } from '../blocks/CodeBlock';
|
|
11
|
+
|
|
12
|
+
/** Larger than this, the drawing stays code. */
|
|
13
|
+
const SVG_MAX_CHARS = 512 * 1024;
|
|
14
|
+
|
|
15
|
+
export function SvgFenceBlock({ block }: { block: Block }) {
|
|
16
|
+
const [showCode, setShowCode] = useState(false);
|
|
17
|
+
const [broken, setBroken] = useState(false);
|
|
18
|
+
const src = useMemo(() => `data:image/svg+xml;charset=utf-8,${encodeURIComponent(block.content)}`, [block.content]);
|
|
19
|
+
const drawable = block.content.length <= SVG_MAX_CHARS && /<svg[\s>]/i.test(block.content);
|
|
20
|
+
if (!drawable) return <CodeBlock block={block} isHovered={false} />;
|
|
21
|
+
return (
|
|
22
|
+
<figure data-block-id={block.id} data-svg-fence="" className="my-4">
|
|
23
|
+
{showCode || broken ? (
|
|
24
|
+
<CodeBlock block={block} isHovered={false} />
|
|
25
|
+
) : (
|
|
26
|
+
<img src={src} alt="" className="block h-auto max-w-full" onError={() => setBroken(true)} />
|
|
27
|
+
)}
|
|
28
|
+
<figcaption className="mt-1 flex items-center gap-1 text-xs text-muted-foreground">
|
|
29
|
+
<Code aria-hidden="true" className="h-3.5 w-3.5 shrink-0" />
|
|
30
|
+
<span>SVG</span>
|
|
31
|
+
<span className="flex-1" />
|
|
32
|
+
{!broken && (
|
|
33
|
+
<button
|
|
34
|
+
type="button"
|
|
35
|
+
data-svg-fence-code=""
|
|
36
|
+
aria-pressed={showCode}
|
|
37
|
+
className={`rounded px-1.5 py-0.5 hover:bg-muted hover:text-foreground ${showCode ? 'bg-muted text-foreground' : ''}`}
|
|
38
|
+
onClick={() => setShowCode((value) => !value)}
|
|
39
|
+
>
|
|
40
|
+
Code
|
|
41
|
+
</button>
|
|
42
|
+
)}
|
|
43
|
+
</figcaption>
|
|
44
|
+
</figure>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Fence languages a host with `htmlEmbeds` draws instead of showing code (approval "inbox-inline-html"). */
|
|
2
|
+
|
|
3
|
+
function fenceLanguage(language?: string): string | undefined {
|
|
4
|
+
return language?.trim().split(/\s+/, 1)[0]?.toLowerCase();
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** ```html: a live page in a sandboxed frame. */
|
|
8
|
+
export function isHtmlEmbedLanguage(language?: string): boolean {
|
|
9
|
+
return fenceLanguage(language) === 'html';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** ```svg: a drawing, shown as an image (it never runs). */
|
|
13
|
+
export function isSvgFenceLanguage(language?: string): boolean {
|
|
14
|
+
return fenceLanguage(language) === 'svg';
|
|
15
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How many live HTML embeds a page runs, and when they load (approval
|
|
3
|
+
* "inbox-inline-html"; the gate Workspaces settled on for its previews):
|
|
4
|
+
* - a frame loads only once it comes within `EMBED_NEAR_PX` of the screen;
|
|
5
|
+
* - at most `EMBED_CONCURRENT_LOADS` load at once, each holding its permit
|
|
6
|
+
* until its document has loaded (or `EMBED_LOAD_TIMEOUT_MS` passed);
|
|
7
|
+
* - at most `EMBED_LIVE_MAX` are live at once; past that an embed waits as
|
|
8
|
+
* a "Show page" button until the person asks or a live one goes away.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useEffect, useState, type RefObject } from 'react';
|
|
12
|
+
|
|
13
|
+
export const EMBED_NEAR_PX = 400;
|
|
14
|
+
export const EMBED_CONCURRENT_LOADS = 2;
|
|
15
|
+
export const EMBED_LIVE_MAX = 12;
|
|
16
|
+
export const EMBED_LOAD_TIMEOUT_MS = 8000;
|
|
17
|
+
|
|
18
|
+
let loading = 0;
|
|
19
|
+
const waiting: Array<() => void> = [];
|
|
20
|
+
|
|
21
|
+
/** Wait for a load permit; call the release it resolves to once the frame has loaded. */
|
|
22
|
+
export function acquireEmbedLoad(): Promise<() => void> {
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
const grant = () => {
|
|
25
|
+
loading += 1;
|
|
26
|
+
let released = false;
|
|
27
|
+
const release = () => {
|
|
28
|
+
if (released) return;
|
|
29
|
+
released = true;
|
|
30
|
+
loading -= 1;
|
|
31
|
+
waiting.shift()?.();
|
|
32
|
+
};
|
|
33
|
+
setTimeout(release, EMBED_LOAD_TIMEOUT_MS);
|
|
34
|
+
resolve(release);
|
|
35
|
+
};
|
|
36
|
+
if (loading < EMBED_CONCURRENT_LOADS) grant();
|
|
37
|
+
else waiting.push(grant);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let live = 0;
|
|
42
|
+
const liveListeners = new Set<() => void>();
|
|
43
|
+
|
|
44
|
+
/** Take a live slot; false when `EMBED_LIVE_MAX` are already live (unless `force`, the person's own ask). */
|
|
45
|
+
export function claimEmbedLive(force = false): boolean {
|
|
46
|
+
if (!force && live >= EMBED_LIVE_MAX) return false;
|
|
47
|
+
live += 1;
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function releaseEmbedLive(): void {
|
|
52
|
+
live = Math.max(0, live - 1);
|
|
53
|
+
for (const listener of [...liveListeners]) listener();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Called when a live slot frees up, so a waiting embed can try again. */
|
|
57
|
+
export function onEmbedLiveFreed(listener: () => void): () => void {
|
|
58
|
+
liveListeners.add(listener);
|
|
59
|
+
return () => liveListeners.delete(listener);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** For tests: forget every permit and slot. */
|
|
63
|
+
export function resetEmbedGateForTests(): void {
|
|
64
|
+
loading = 0;
|
|
65
|
+
waiting.length = 0;
|
|
66
|
+
live = 0;
|
|
67
|
+
liveListeners.clear();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The nearest ancestor that scrolls vertically, or null for the page itself. */
|
|
71
|
+
function scrollParentOf(el: Element): Element | null {
|
|
72
|
+
for (let node = el.parentElement; node; node = node.parentElement) {
|
|
73
|
+
const overflow = getComputedStyle(node).overflowY;
|
|
74
|
+
if (overflow === 'auto' || overflow === 'scroll' || overflow === 'overlay') return node;
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* True once the element has come within `EMBED_NEAR_PX` of the screen (and
|
|
81
|
+
* stays true). Measured against the pane that scrolls it: a margin on the
|
|
82
|
+
* viewport would stop at the pane's clip.
|
|
83
|
+
*/
|
|
84
|
+
export function useNearViewport(ref: RefObject<Element | null>): boolean {
|
|
85
|
+
const [near, setNear] = useState(false);
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
if (near) return;
|
|
88
|
+
const el = ref.current;
|
|
89
|
+
if (!el) return;
|
|
90
|
+
if (typeof IntersectionObserver === 'undefined') {
|
|
91
|
+
setNear(true);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const observer = new IntersectionObserver(
|
|
95
|
+
(entries) => {
|
|
96
|
+
if (entries.some((entry) => entry.isIntersecting)) {
|
|
97
|
+
setNear(true);
|
|
98
|
+
observer.disconnect();
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
{ root: scrollParentOf(el), rootMargin: `${EMBED_NEAR_PX}px 0px` },
|
|
102
|
+
);
|
|
103
|
+
observer.observe(el);
|
|
104
|
+
return () => observer.disconnect();
|
|
105
|
+
}, [near, ref]);
|
|
106
|
+
return near;
|
|
107
|
+
}
|