@plannotator/ui 0.24.0 → 0.26.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/README.md +15 -0
- package/components/ResizeHandle.tsx +86 -47
- package/components/html-viewer/HtmlViewer.tsx +26 -52
- package/components/html-viewer/bridge-script.ts +26 -13
- package/components/html-viewer/srcdoc.ts +130 -0
- package/components/sidebar/FileBrowser.tsx +132 -6
- package/hooks/useResizablePanel.ts +36 -5
- package/package.json +1 -1
- package/styles.css +1 -1
- package/theme.css +16 -0
package/README.md
CHANGED
|
@@ -30,6 +30,21 @@ configurePlannotatorUI({
|
|
|
30
30
|
|
|
31
31
|
Anything you don't pass keeps Plannotator's default. A few component-specific overrides (e.g. an "open in editor" diff action) are passed as props where you render that component.
|
|
32
32
|
|
|
33
|
+
### Resize-handle seams (`ResizeHandle` / `useResizablePanel`)
|
|
34
|
+
|
|
35
|
+
The sidebar/panel resize handle exposes seams for hosts that want different edge interactions (e.g. no hover reveal, click-to-collapse). All default to today's behavior — pass nothing and it's unchanged.
|
|
36
|
+
|
|
37
|
+
- **Suppress / restyle the hover reveal.** The inner visible track carries a `[data-resize-track]` attribute (same host-CSS pattern as the collapse button's `[data-collapse]`), and `ResizeHandle` takes a `trackClassName` prop. To kill the pop-in from host CSS:
|
|
38
|
+
```css
|
|
39
|
+
[data-resize-track] { background: none !important; }
|
|
40
|
+
```
|
|
41
|
+
- **Click-to-collapse anywhere on the handle.** The handle can't tell a click from a drag-start on its own — the hook owns the pointer state machine. Pass `onClick` to `useResizablePanel`; it fires on pointer-up only when the pointer never traveled past `clickThreshold` (default 4px), so a genuine click on the full-width handle can collapse the panel while drags still resize:
|
|
42
|
+
```ts
|
|
43
|
+
const resize = useResizablePanel({ storageKey, side: "left", onSnapClose: collapse, onClick: collapse });
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Building your own tooltip and removing the built-in double-click reset are host-side concerns (override `onDoubleClick` where you render the handle).
|
|
47
|
+
|
|
33
48
|
## Consuming it (e.g. from Workspaces)
|
|
34
49
|
|
|
35
50
|
```bash
|
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
2
3
|
import type { ResizeHandleProps as BaseProps } from '../hooks/useResizablePanel';
|
|
3
4
|
|
|
4
5
|
interface Props extends BaseProps {
|
|
5
6
|
className?: string;
|
|
7
|
+
/**
|
|
8
|
+
* Extra classes for the inner visible track (the 4px bar that reveals on
|
|
9
|
+
* hover). `className` only reaches the outer wrapper, so this is the seam for
|
|
10
|
+
* restyling or suppressing the hover affordance. The track also carries a
|
|
11
|
+
* `[data-resize-track]` attribute (same pattern as `[data-collapse]`) so a
|
|
12
|
+
* host can target it from plain CSS — e.g. to kill the hover reveal:
|
|
13
|
+
* `[data-resize-track] { background: none !important; }`.
|
|
14
|
+
*/
|
|
15
|
+
trackClassName?: string;
|
|
16
|
+
/** Suppress the hover color-change on the track entirely (no pop-in). */
|
|
17
|
+
hideHoverTrack?: boolean;
|
|
18
|
+
/** Cursor-following hint shown while hovering the handle (hidden mid-drag). */
|
|
19
|
+
tooltip?: React.ReactNode;
|
|
6
20
|
/** When provided, renders a hover-reveal collapse button centered on the
|
|
7
21
|
* handle (prototype's gutter affordance). Chevron direction follows `side`:
|
|
8
22
|
* a left sidebar collapses leftward, a right sidebar rightward. */
|
|
@@ -34,53 +48,78 @@ export const ResizeHandle: React.FC<Props> = ({
|
|
|
34
48
|
onDoubleClick,
|
|
35
49
|
style,
|
|
36
50
|
className,
|
|
51
|
+
trackClassName,
|
|
52
|
+
hideHoverTrack,
|
|
53
|
+
tooltip,
|
|
37
54
|
side,
|
|
38
55
|
onCollapse,
|
|
39
|
-
}) =>
|
|
40
|
-
<
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
invisible until hover/drag. */}
|
|
45
|
-
<div className={`absolute inset-y-0 -left-0.5 -right-0.5 transition-colors ${
|
|
46
|
-
isDragging ? 'bg-transparent' : 'group-hover:bg-border'
|
|
47
|
-
}`} />
|
|
48
|
-
{/* Wider grab/touch zone — must never have zero width (see `side` docs).
|
|
49
|
-
Pointer events + setPointerCapture live here; touch-action:none (from
|
|
50
|
-
style) stops touch drags from scroll-hijacking. */}
|
|
56
|
+
}) => {
|
|
57
|
+
const [tip, setTip] = React.useState<{ x: number; y: number } | null>(null);
|
|
58
|
+
const showTip = tooltip != null && tip != null && !isDragging;
|
|
59
|
+
|
|
60
|
+
return (
|
|
51
61
|
<div
|
|
52
|
-
className={`
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
62
|
+
className={`relative w-0 cursor-col-resize flex-shrink-0 group${className ? ` ${className}` : ''}`}
|
|
63
|
+
>
|
|
64
|
+
{/* Visible track — 4px wide, centered on the zero-width layout box,
|
|
65
|
+
invisible until hover/drag. `data-resize-track` + `trackClassName` are
|
|
66
|
+
host seams for restyling/suppressing the hover reveal. */}
|
|
67
|
+
<div
|
|
68
|
+
data-resize-track={side ?? ''}
|
|
69
|
+
className={`absolute inset-y-0 -left-0.5 -right-0.5 transition-colors ${
|
|
70
|
+
isDragging || hideHoverTrack ? 'bg-transparent' : 'group-hover:bg-border'
|
|
71
|
+
}${trackClassName ? ` ${trackClassName}` : ''}`}
|
|
72
|
+
/>
|
|
73
|
+
{/* Wider grab/touch zone — must never have zero width (see `side` docs).
|
|
74
|
+
Pointer events + setPointerCapture live here; touch-action:none (from
|
|
75
|
+
style) stops touch drags from scroll-hijacking. */}
|
|
76
|
+
<div
|
|
77
|
+
className={`absolute inset-y-0 ${
|
|
78
|
+
side === 'left' ? '-right-2 -left-1' :
|
|
79
|
+
side === 'right' ? '-right-3 left-0' :
|
|
80
|
+
'-inset-x-2'
|
|
81
|
+
}`}
|
|
82
|
+
style={style}
|
|
83
|
+
onPointerDown={onPointerDown}
|
|
84
|
+
onDoubleClick={onDoubleClick}
|
|
85
|
+
onPointerMove={tooltip != null && !isDragging ? (e) => setTip({ x: e.clientX, y: e.clientY }) : undefined}
|
|
86
|
+
onPointerLeave={tooltip != null ? () => setTip(null) : undefined}
|
|
87
|
+
/>
|
|
88
|
+
{/* Cursor-following hint. pointer-events-none so it never blocks the drag;
|
|
89
|
+
portaled to body so the zero-width parent doesn't clip it. */}
|
|
90
|
+
{showTip && typeof document !== 'undefined' && createPortal(
|
|
91
|
+
<div
|
|
92
|
+
style={{ position: 'fixed', left: tip.x + 14, top: tip.y + 16, zIndex: 100 }}
|
|
93
|
+
className="pointer-events-none whitespace-nowrap rounded-md bg-surface-1 px-2 py-1 text-xs text-foreground shadow-md ring-1 ring-border/60"
|
|
94
|
+
>
|
|
95
|
+
{tooltip}
|
|
96
|
+
</div>,
|
|
97
|
+
document.body,
|
|
98
|
+
)}
|
|
99
|
+
{/* Hover-reveal collapse button, centered on the handle. stopPropagation on
|
|
100
|
+
pointerdown so clicking it never starts a drag. Hidden mid-drag. */}
|
|
101
|
+
{onCollapse && !isDragging && (
|
|
102
|
+
<button
|
|
103
|
+
type="button"
|
|
104
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
105
|
+
onClick={(e) => {
|
|
106
|
+
e.stopPropagation();
|
|
107
|
+
onCollapse();
|
|
108
|
+
}}
|
|
109
|
+
title="Collapse sidebar"
|
|
110
|
+
aria-label="Collapse sidebar"
|
|
111
|
+
data-collapse={side}
|
|
112
|
+
className="absolute top-1/2 left-1/2 z-20 flex h-6 w-4 -translate-x-1/2 -translate-y-1/2 items-center justify-center before:absolute before:-inset-2 before:content-[''] rounded-sm bg-surface-1 text-muted-foreground/60 opacity-0 ring-1 ring-border/40 transition-opacity hover:text-foreground group-hover:opacity-100 group-hover/sidebar:opacity-100"
|
|
113
|
+
>
|
|
114
|
+
<svg className="size-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
115
|
+
<path
|
|
116
|
+
strokeLinecap="round"
|
|
117
|
+
strokeLinejoin="round"
|
|
118
|
+
d={side === 'right' ? 'M9 5l7 7-7 7' : 'M15 19l-7-7 7-7'}
|
|
119
|
+
/>
|
|
120
|
+
</svg>
|
|
121
|
+
</button>
|
|
122
|
+
)}
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
};
|
|
@@ -17,39 +17,16 @@ import { CommentPopover, type CommentAskAIHandler } from "../CommentPopover";
|
|
|
17
17
|
import { FloatingQuickLabelPicker } from "../FloatingQuickLabelPicker";
|
|
18
18
|
import type { ViewerHandle } from "../Viewer";
|
|
19
19
|
import { useHtmlAnnotation } from "./useHtmlAnnotation";
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
THEME_TOKENS,
|
|
22
|
+
buildSrcdocInjection,
|
|
23
|
+
buildThemeTokenPayload,
|
|
24
|
+
hasHostThemeOptIn,
|
|
25
|
+
injectIntoHead,
|
|
26
|
+
} from "./srcdoc";
|
|
21
27
|
|
|
22
28
|
const PREFIX = "plannotator-bridge-";
|
|
23
29
|
|
|
24
|
-
const THEME_TOKENS = [
|
|
25
|
-
"--background",
|
|
26
|
-
"--foreground",
|
|
27
|
-
"--card",
|
|
28
|
-
"--card-foreground",
|
|
29
|
-
"--primary",
|
|
30
|
-
"--primary-foreground",
|
|
31
|
-
"--secondary",
|
|
32
|
-
"--secondary-foreground",
|
|
33
|
-
"--muted",
|
|
34
|
-
"--muted-foreground",
|
|
35
|
-
"--accent",
|
|
36
|
-
"--accent-foreground",
|
|
37
|
-
"--destructive",
|
|
38
|
-
"--destructive-foreground",
|
|
39
|
-
"--success",
|
|
40
|
-
"--success-foreground",
|
|
41
|
-
"--warning",
|
|
42
|
-
"--warning-foreground",
|
|
43
|
-
"--border",
|
|
44
|
-
"--input",
|
|
45
|
-
"--ring",
|
|
46
|
-
"--code-bg",
|
|
47
|
-
"--focus-highlight",
|
|
48
|
-
"--font-sans",
|
|
49
|
-
"--font-mono",
|
|
50
|
-
"--radius",
|
|
51
|
-
] as const;
|
|
52
|
-
|
|
53
30
|
function readThemeTokens(): Record<string, string> {
|
|
54
31
|
const style = getComputedStyle(document.documentElement);
|
|
55
32
|
const tokens: Record<string, string> = {};
|
|
@@ -124,26 +101,19 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
124
101
|
contextText: string;
|
|
125
102
|
} | null>(null);
|
|
126
103
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
for (const [key, val] of Object.entries(tokens)) {
|
|
131
|
-
themeCSS += ` ${key}: ${val};\n`;
|
|
132
|
-
}
|
|
133
|
-
themeCSS += "}\n";
|
|
134
|
-
if (isLightTheme()) themeCSS += ":root { color-scheme: light; }\n:root.light, :root { }\n";
|
|
104
|
+
// Host theming is opt-in per document (Plannotator-generated artifacts tag
|
|
105
|
+
// themselves); arbitrary HTML renders untouched, like a standalone tab.
|
|
106
|
+
const hostTheme = useMemo(() => hasHostThemeOptIn(rawHtml), [rawHtml]);
|
|
135
107
|
|
|
136
|
-
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
return injection + rawHtml;
|
|
146
|
-
}, [rawHtml]);
|
|
108
|
+
const srcdoc = useMemo(() => {
|
|
109
|
+
const injection = buildSrcdocInjection({
|
|
110
|
+
tokens: readThemeTokens(),
|
|
111
|
+
isLight: isLightTheme(),
|
|
112
|
+
hostTheme,
|
|
113
|
+
diffActive: !!diffActive,
|
|
114
|
+
});
|
|
115
|
+
return injectIntoHead(rawHtml, injection);
|
|
116
|
+
}, [rawHtml, hostTheme, diffActive]);
|
|
147
117
|
|
|
148
118
|
const handleResize = useCallback((height: number) => {
|
|
149
119
|
setIframeHeight(height);
|
|
@@ -189,9 +159,13 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
189
159
|
useEffect(() => {
|
|
190
160
|
if (!iframeReady) return;
|
|
191
161
|
function sendTheme() {
|
|
192
|
-
const tokens = readThemeTokens();
|
|
193
162
|
iframeRef.current?.contentWindow?.postMessage(
|
|
194
|
-
{
|
|
163
|
+
{
|
|
164
|
+
type: `${PREFIX}theme`,
|
|
165
|
+
tokens: buildThemeTokenPayload(readThemeTokens(), hostTheme),
|
|
166
|
+
isLight: isLightTheme(),
|
|
167
|
+
hostTheme,
|
|
168
|
+
},
|
|
195
169
|
"*",
|
|
196
170
|
);
|
|
197
171
|
}
|
|
@@ -202,7 +176,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
202
176
|
attributeFilter: ["class", "style"],
|
|
203
177
|
});
|
|
204
178
|
return () => observer.disconnect();
|
|
205
|
-
}, [iframeReady]);
|
|
179
|
+
}, [iframeReady, hostTheme]);
|
|
206
180
|
|
|
207
181
|
useImperativeHandle(ref, () => ({
|
|
208
182
|
removeHighlight: hook.removeHighlight,
|
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
* No external dependencies.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Reads only viewer-namespaced \`--pn-*\` variables (with fallbacks): arbitrary
|
|
14
|
+
* documents may define bare token names like \`--accent\` for themselves, and the
|
|
15
|
+
* viewer must never depend on — or collide with — the author's namespace.
|
|
16
|
+
*/
|
|
12
17
|
export const ANNOTATION_HIGHLIGHT_CSS = `
|
|
13
18
|
.annotation-highlight {
|
|
14
19
|
border-radius: 2px;
|
|
@@ -17,33 +22,33 @@ export const ANNOTATION_HIGHLIGHT_CSS = `
|
|
|
17
22
|
cursor: pointer;
|
|
18
23
|
}
|
|
19
24
|
.annotation-highlight.deletion {
|
|
20
|
-
background: oklch(from var(--destructive, #c0392b) l c h / 0.35);
|
|
25
|
+
background: oklch(from var(--pn-destructive, #c0392b) l c h / 0.35);
|
|
21
26
|
text-decoration: line-through;
|
|
22
|
-
text-decoration-color: var(--destructive, #c0392b);
|
|
27
|
+
text-decoration-color: var(--pn-destructive, #c0392b);
|
|
23
28
|
text-decoration-thickness: 2px;
|
|
24
29
|
}
|
|
25
30
|
.annotation-highlight.comment {
|
|
26
31
|
background: oklch(0.70 0.18 60 / 0.3);
|
|
27
|
-
border-bottom: 2px solid var(--accent, #d97757);
|
|
32
|
+
border-bottom: 2px solid var(--pn-accent, #d97757);
|
|
28
33
|
}
|
|
29
34
|
.annotation-highlight.focused {
|
|
30
|
-
background: oklch(from var(--focus-highlight, #4493f8) l c h / 0.45) !important;
|
|
31
|
-
box-shadow: 0 0 8px oklch(from var(--focus-highlight, #4493f8) l c h / 0.4);
|
|
32
|
-
border-bottom: 2px solid var(--focus-highlight, #4493f8);
|
|
35
|
+
background: oklch(from var(--pn-focus-highlight, #4493f8) l c h / 0.45) !important;
|
|
36
|
+
box-shadow: 0 0 8px oklch(from var(--pn-focus-highlight, #4493f8) l c h / 0.4);
|
|
37
|
+
border-bottom: 2px solid var(--pn-focus-highlight, #4493f8);
|
|
33
38
|
filter: none;
|
|
34
39
|
}
|
|
35
40
|
.annotation-highlight:hover {
|
|
36
41
|
filter: brightness(1.2);
|
|
37
42
|
}
|
|
38
43
|
.plannotator-pinpoint-hover {
|
|
39
|
-
outline: 2px solid var(--focus-highlight, #4493f8) !important;
|
|
44
|
+
outline: 2px solid var(--pn-focus-highlight, #4493f8) !important;
|
|
40
45
|
outline-offset: 1px;
|
|
41
46
|
cursor: crosshair !important;
|
|
42
47
|
}
|
|
43
48
|
/* SVG nodes can't take a CSS outline — stroke their shapes instead. */
|
|
44
49
|
.plannotator-pinpoint-hover rect, .plannotator-pinpoint-hover path,
|
|
45
50
|
.plannotator-pinpoint-hover circle, .plannotator-pinpoint-hover ellipse, .plannotator-pinpoint-hover polygon {
|
|
46
|
-
stroke: var(--focus-highlight, #4493f8) !important; stroke-width: 2.5px !important;
|
|
51
|
+
stroke: var(--pn-focus-highlight, #4493f8) !important; stroke-width: 2.5px !important;
|
|
47
52
|
}
|
|
48
53
|
`;
|
|
49
54
|
|
|
@@ -51,15 +56,23 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
51
56
|
var PREFIX = 'plannotator-bridge-';
|
|
52
57
|
|
|
53
58
|
// --- Theme ---
|
|
59
|
+
// The author owns this document. Unless it opted in to host theming
|
|
60
|
+
// (hostTheme), only viewer-namespaced --pn-* properties may be written to its
|
|
61
|
+
// root, and its class list is never touched.
|
|
54
62
|
window.addEventListener('message', function(e) {
|
|
55
63
|
if (!e.data || e.data.type !== PREFIX + 'theme') return;
|
|
56
64
|
var root = document.documentElement;
|
|
57
|
-
var tokens = e.data.tokens;
|
|
65
|
+
var tokens = e.data.tokens || {};
|
|
66
|
+
var hostTheme = !!e.data.hostTheme;
|
|
58
67
|
for (var key in tokens) {
|
|
59
|
-
if (tokens.hasOwnProperty(key))
|
|
68
|
+
if (!tokens.hasOwnProperty(key)) continue;
|
|
69
|
+
if (!hostTheme && key.indexOf('--pn-') !== 0) continue;
|
|
70
|
+
root.style.setProperty(key, tokens[key]);
|
|
71
|
+
}
|
|
72
|
+
if (hostTheme) {
|
|
73
|
+
root.classList.remove('light');
|
|
74
|
+
if (e.data.isLight) root.classList.add('light');
|
|
60
75
|
}
|
|
61
|
-
root.classList.remove('light');
|
|
62
|
-
if (e.data.isLight) root.classList.add('light');
|
|
63
76
|
});
|
|
64
77
|
|
|
65
78
|
// --- Resize ---
|
|
@@ -244,7 +257,7 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
244
257
|
if (!pinpointLabelEl) {
|
|
245
258
|
pinpointLabelEl = document.createElement('div');
|
|
246
259
|
pinpointLabelEl.setAttribute('data-plannotator-pinpoint-label', '');
|
|
247
|
-
pinpointLabelEl.style.cssText = 'position:fixed;z-index:2147483647;pointer-events:none;display:none;font:600 11px/1.3 system-ui,-apple-system,sans-serif;padding:2px 7px;border-radius:5px;background:var(--focus-highlight,#4493f8);color:#fff;white-space:nowrap;box-shadow:0 1px 5px rgba(0,0,0,.35);';
|
|
260
|
+
pinpointLabelEl.style.cssText = 'position:fixed;z-index:2147483647;pointer-events:none;display:none;font:600 11px/1.3 system-ui,-apple-system,sans-serif;padding:2px 7px;border-radius:5px;background:var(--pn-focus-highlight,#4493f8);color:#fff;white-space:nowrap;box-shadow:0 1px 5px rgba(0,0,0,.35);';
|
|
248
261
|
document.body.appendChild(pinpointLabelEl);
|
|
249
262
|
}
|
|
250
263
|
return pinpointLabelEl;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Srcdoc injection builder for the HTML viewer.
|
|
3
|
+
*
|
|
4
|
+
* Product rule: arbitrary HTML must render exactly as it would in a plain
|
|
5
|
+
* browser tab. The viewer never writes into the document's namespace — no bare
|
|
6
|
+
* CSS custom properties, no classes on the author's root, no `color-scheme`,
|
|
7
|
+
* no styling of author elements. Host theme tokens are pushed under the
|
|
8
|
+
* viewer-owned `--pn-*` prefix, which the annotation CSS reads.
|
|
9
|
+
*
|
|
10
|
+
* Documents that WANT to follow the host theme (e.g. Plannotator-generated
|
|
11
|
+
* artifacts) opt in with `<meta name="plannotator-theme" content="host">`,
|
|
12
|
+
* which re-enables the bare-token push, the `light` class on their root, and
|
|
13
|
+
* `color-scheme` sync — for that document only.
|
|
14
|
+
*
|
|
15
|
+
* Pure string logic (no DOM) so the rendering-neutrality contract is unit-testable.
|
|
16
|
+
*/
|
|
17
|
+
import { ANNOTATION_HIGHLIGHT_CSS, BRIDGE_SCRIPT } from "./bridge-script";
|
|
18
|
+
|
|
19
|
+
export const THEME_TOKENS = [
|
|
20
|
+
"--background",
|
|
21
|
+
"--foreground",
|
|
22
|
+
"--card",
|
|
23
|
+
"--card-foreground",
|
|
24
|
+
"--primary",
|
|
25
|
+
"--primary-foreground",
|
|
26
|
+
"--secondary",
|
|
27
|
+
"--secondary-foreground",
|
|
28
|
+
"--muted",
|
|
29
|
+
"--muted-foreground",
|
|
30
|
+
"--accent",
|
|
31
|
+
"--accent-foreground",
|
|
32
|
+
"--destructive",
|
|
33
|
+
"--destructive-foreground",
|
|
34
|
+
"--success",
|
|
35
|
+
"--success-foreground",
|
|
36
|
+
"--warning",
|
|
37
|
+
"--warning-foreground",
|
|
38
|
+
"--border",
|
|
39
|
+
"--input",
|
|
40
|
+
"--ring",
|
|
41
|
+
"--code-bg",
|
|
42
|
+
"--focus-highlight",
|
|
43
|
+
"--font-sans",
|
|
44
|
+
"--font-mono",
|
|
45
|
+
"--radius",
|
|
46
|
+
] as const;
|
|
47
|
+
|
|
48
|
+
/** Viewer-owned namespace for properties injected into the document. */
|
|
49
|
+
export const PN_TOKEN_PREFIX = "--pn-";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Version-diff highlights. htmlDiff tags the <ins>/<del> it generates with
|
|
53
|
+
* this class so author-written <ins>/<del> markup is never restyled.
|
|
54
|
+
*/
|
|
55
|
+
export const DIFF_HIGHLIGHT_CSS =
|
|
56
|
+
"ins.plannotator-diff{background:#e6ffec;color:#0a7d33;text-decoration:none;border-radius:2px;box-shadow:0 0 0 1px #abf2bc inset}" +
|
|
57
|
+
"del.plannotator-diff{background:#ffebe9;color:#b31d28;text-decoration:line-through;border-radius:2px;box-shadow:0 0 0 1px #ffc1bc inset}";
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* True when the document opts in to following the host theme via
|
|
61
|
+
* `<meta name="plannotator-theme" content="host">` (attribute order/quoting agnostic).
|
|
62
|
+
*/
|
|
63
|
+
export function hasHostThemeOptIn(rawHtml: string): boolean {
|
|
64
|
+
const metas = rawHtml.match(/<meta\b[^>]*>/gi);
|
|
65
|
+
if (!metas) return false;
|
|
66
|
+
return metas.some(
|
|
67
|
+
(tag) =>
|
|
68
|
+
/\bname\s*=\s*["']?plannotator-theme["']?/i.test(tag) &&
|
|
69
|
+
/\bcontent\s*=\s*["']?host["']?/i.test(tag),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Build the theme properties to write into the document. Bare host token names
|
|
75
|
+
* (`--muted`, `--background`, …) collide with author variables, so they are
|
|
76
|
+
* remapped to `--pn-*`; the originals ride along only for host-theme documents.
|
|
77
|
+
*/
|
|
78
|
+
export function buildThemeTokenPayload(
|
|
79
|
+
tokens: Record<string, string>,
|
|
80
|
+
hostTheme: boolean,
|
|
81
|
+
): Record<string, string> {
|
|
82
|
+
const payload: Record<string, string> = {};
|
|
83
|
+
for (const [key, val] of Object.entries(tokens)) {
|
|
84
|
+
payload[PN_TOKEN_PREFIX + key.slice(2)] = val;
|
|
85
|
+
if (hostTheme) payload[key] = val;
|
|
86
|
+
}
|
|
87
|
+
return payload;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface SrcdocInjectionOptions {
|
|
91
|
+
/** Host theme tokens, keyed by bare name (as read from the host root). */
|
|
92
|
+
tokens: Record<string, string>;
|
|
93
|
+
/** Whether the host is currently in its light theme. */
|
|
94
|
+
isLight: boolean;
|
|
95
|
+
/** Document opted in to host theming (see {@link hasHostThemeOptIn}). */
|
|
96
|
+
hostTheme: boolean;
|
|
97
|
+
/** The version-diff view is showing (rawHtml is htmlDiff output). */
|
|
98
|
+
diffActive: boolean;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The `<style>` + `<script>` block spliced into the document's head. */
|
|
102
|
+
export function buildSrcdocInjection({
|
|
103
|
+
tokens,
|
|
104
|
+
isLight,
|
|
105
|
+
hostTheme,
|
|
106
|
+
diffActive,
|
|
107
|
+
}: SrcdocInjectionOptions): string {
|
|
108
|
+
const payload = buildThemeTokenPayload(tokens, hostTheme);
|
|
109
|
+
let themeCSS = ":root {\n";
|
|
110
|
+
for (const [key, val] of Object.entries(payload)) {
|
|
111
|
+
themeCSS += ` ${key}: ${val};\n`;
|
|
112
|
+
}
|
|
113
|
+
themeCSS += "}\n";
|
|
114
|
+
// Host-theme documents mirror the host's light/dark; arbitrary documents keep
|
|
115
|
+
// their own color-scheme resolution (document + OS), like a standalone tab.
|
|
116
|
+
if (hostTheme) {
|
|
117
|
+
themeCSS += `:root { color-scheme: ${isLight ? "light" : "dark"}; }\n`;
|
|
118
|
+
}
|
|
119
|
+
const diffCSS = diffActive ? DIFF_HIGHLIGHT_CSS : "";
|
|
120
|
+
return `<style>${themeCSS}${ANNOTATION_HIGHLIGHT_CSS}${diffCSS}</style><script>${BRIDGE_SCRIPT}</script>`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Splice the injection just before `</head>`, or prepend when there is none. */
|
|
124
|
+
export function injectIntoHead(rawHtml: string, injection: string): string {
|
|
125
|
+
const headClose = rawHtml.indexOf("</head>");
|
|
126
|
+
if (headClose !== -1) {
|
|
127
|
+
return rawHtml.slice(0, headClose) + injection + rawHtml.slice(headClose);
|
|
128
|
+
}
|
|
129
|
+
return injection + rawHtml;
|
|
130
|
+
}
|