@plannotator/ui 0.41.0 → 0.41.2
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 +73 -2
- package/README.md +2 -2
- package/components/DiagramBlock.tsx +39 -38
- package/components/Settings.tsx +30 -0
- package/components/ThemeProvider.tsx +27 -1
- package/components/Viewer.tsx +42 -24
- package/components/diagram/DiagramCanvas.tsx +30 -34
- package/components/diagram/DiagramPending.tsx +60 -0
- package/components/diagram/DiagramPopout.tsx +3 -1
- package/components/diagram/DiagramViewer.tsx +17 -8
- package/components/diagram/diagramControls.ts +41 -0
- package/components/diagram/index.ts +4 -1
- package/components/diagram/svgContentSize.ts +47 -0
- package/config/settings.ts +22 -0
- package/hooks/usePrintMedia.ts +70 -0
- package/hooks/usePrintMode.ts +11 -15
- package/package.json +1 -1
- package/print.css +27 -4
- package/styles.css +1 -1
- package/utils/diagram-render.ts +14 -7
- package/utils/diagramShadow.ts +27 -0
- package/utils/mermaid.ts +12 -0
- package/utils/mermaidTheme.ts +138 -7
|
@@ -14,6 +14,8 @@ import { cn } from '../../lib/utils';
|
|
|
14
14
|
import { diagramHitSource } from '../../utils/diagram-render';
|
|
15
15
|
import { isMac, isModKeyHeld } from '../../utils/platform';
|
|
16
16
|
import { Button } from '../ui/button';
|
|
17
|
+
import { isDiagramControlEvent } from './diagramControls';
|
|
18
|
+
import { svgContentSize } from './svgContentSize';
|
|
17
19
|
import {
|
|
18
20
|
DRAG_THRESHOLD_PX,
|
|
19
21
|
TOUCH_DRAG_THRESHOLD_PX,
|
|
@@ -50,6 +52,13 @@ import {
|
|
|
50
52
|
* `<a href>` that the render slot's sanitizer strips, so the canvas owns
|
|
51
53
|
* every click and there is no armed switch.
|
|
52
54
|
*
|
|
55
|
+
* Chrome painted OVER the canvas (the zoom strip, the composer, the source
|
|
56
|
+
* pane, the popout's header) is never a diagram target: a pointer event
|
|
57
|
+
* whose composed path contains a control resolves nothing, opens no
|
|
58
|
+
* composer and starts no pan (`diagramControls.ts`). Without that the
|
|
59
|
+
* `elementsFromPoint` walk stepped past the control to the part behind it,
|
|
60
|
+
* so pressing Zoom out over a node opened the composer on that node.
|
|
61
|
+
*
|
|
53
62
|
* The render slot hands over a sanitized svg NODE, not markup: the wrapper
|
|
54
63
|
* mounts it with `replaceChildren` once per render, so no html string ever
|
|
55
64
|
* crosses into the app DOM here. The overlay (a sibling of the wrapper,
|
|
@@ -57,40 +66,10 @@ import {
|
|
|
57
66
|
* viewport so rings reproject on every change.
|
|
58
67
|
*/
|
|
59
68
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/** The svg's intrinsic size, from its viewBox (Mermaid and Graphviz always
|
|
67
|
-
* write one), else its `width` / `height` attributes (a `pt` or `px` suffix
|
|
68
|
-
* is accepted, as Graphviz writes them). */
|
|
69
|
-
export function svgContentSize(svg: SVGSVGElement): ContentSize | null {
|
|
70
|
-
const viewBox = svg.getAttribute('viewBox');
|
|
71
|
-
if (viewBox !== null) {
|
|
72
|
-
const parts = viewBox
|
|
73
|
-
.trim()
|
|
74
|
-
.split(/[\s,]+/u)
|
|
75
|
-
.map(Number);
|
|
76
|
-
const width = parts[2];
|
|
77
|
-
const height = parts[3];
|
|
78
|
-
if (
|
|
79
|
-
parts.length === 4 &&
|
|
80
|
-
width !== undefined &&
|
|
81
|
-
height !== undefined &&
|
|
82
|
-
Number.isFinite(width) &&
|
|
83
|
-
Number.isFinite(height) &&
|
|
84
|
-
width > 0 &&
|
|
85
|
-
height > 0
|
|
86
|
-
) {
|
|
87
|
-
return { width, height };
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
const width = svgLength(svg.getAttribute('width'));
|
|
91
|
-
const height = svgLength(svg.getAttribute('height'));
|
|
92
|
-
return Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0 ? { width, height } : null;
|
|
93
|
-
}
|
|
69
|
+
// `svgContentSize` is the canvas's, but the document's fence block needs it
|
|
70
|
+
// without the canvas: it lives in its own dependency-free module and is
|
|
71
|
+
// re-exported here so every published path keeps resolving.
|
|
72
|
+
export { svgContentSize } from './svgContentSize';
|
|
94
73
|
|
|
95
74
|
/** One arrow-key press pans this far (the diagram moves WITH the arrow, as
|
|
96
75
|
* a scroll would); Shift multiplies it by five. */
|
|
@@ -200,6 +179,10 @@ export function DiagramCanvas({
|
|
|
200
179
|
(event: ReactPointerEvent): Element | null => {
|
|
201
180
|
const wrapper = wrapperRef.current;
|
|
202
181
|
if (wrapper === null) return null;
|
|
182
|
+
// The zoom strip and the composer are painted OVER the canvas and are
|
|
183
|
+
// not in the svg, so the walk below would step past them to whatever
|
|
184
|
+
// part sits underneath. A press on chrome addresses no part.
|
|
185
|
+
if (isDiagramControlEvent(event)) return null;
|
|
203
186
|
// Everything under the pointer, topmost first. Without a layout
|
|
204
187
|
// engine (happy-dom) the event's own target is all there is.
|
|
205
188
|
const doc = wrapper.ownerDocument;
|
|
@@ -259,6 +242,11 @@ export function DiagramCanvas({
|
|
|
259
242
|
|
|
260
243
|
const onPointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
|
261
244
|
if (event.button !== 0) return;
|
|
245
|
+
// Pressing a control is not the start of a pan either.
|
|
246
|
+
if (isDiagramControlEvent(event)) {
|
|
247
|
+
pressRef.current = null;
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
262
250
|
pressRef.current = {
|
|
263
251
|
id: event.pointerId,
|
|
264
252
|
x: event.clientX,
|
|
@@ -302,6 +290,13 @@ export function DiagramCanvas({
|
|
|
302
290
|
const press = pressRef.current;
|
|
303
291
|
if (press === null || press.id !== event.pointerId) return;
|
|
304
292
|
pressRef.current = null;
|
|
293
|
+
// Released over a control (the press began on the canvas): not a click
|
|
294
|
+
// on the diagram, and `targetUnder` answering null would otherwise
|
|
295
|
+
// read as "comment on the whole diagram".
|
|
296
|
+
if (isDiagramControlEvent(event)) {
|
|
297
|
+
if (press.panning) setPanning(false);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
305
300
|
if (press.panning) {
|
|
306
301
|
setPanning(false);
|
|
307
302
|
const target = event.currentTarget;
|
|
@@ -395,6 +390,7 @@ export function DiagramCanvas({
|
|
|
395
390
|
// comments). On a narrow screen the strip takes the left edge so it
|
|
396
391
|
// never stacks under a host's own bottom-right controls.
|
|
397
392
|
data-print-hide=""
|
|
393
|
+
data-diagram-control=""
|
|
398
394
|
data-diagram-zoom-strip=""
|
|
399
395
|
className="absolute bottom-3 right-3 z-10 flex items-center gap-0.5 rounded-md border border-border bg-card/85 p-0.5 backdrop-blur max-md:bottom-4 max-md:left-4 max-md:right-auto"
|
|
400
396
|
role="group"
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { DiagramKind } from '@plannotator/core/diagram-anchor';
|
|
3
|
+
import type { Block } from '../../types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The diagram fence before there is a diagram: the source under a quiet
|
|
7
|
+
* "Rendering diagram…" status.
|
|
8
|
+
*
|
|
9
|
+
* It lives in its own file, importing nothing but React and two types,
|
|
10
|
+
* because it is the ONE pending state two callers must agree on. The block
|
|
11
|
+
* shows it while the engine chunk and the first render are in flight
|
|
12
|
+
* (`DiagramBlock`'s `renderFallback`), and the document shows it as the
|
|
13
|
+
* Suspense fallback while the diagram block's own chunk loads
|
|
14
|
+
* (`Viewer`) — a chunked host reaches the second one first. Same markup in
|
|
15
|
+
* both places, so a document with a diagram paints the source fence once and
|
|
16
|
+
* never flashes or jumps between the two.
|
|
17
|
+
*
|
|
18
|
+
* Keep it dependency-free: anything imported here lands in the closure of
|
|
19
|
+
* every document read, diagram or not.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** The fence's own text, as the document would show it unrendered. */
|
|
23
|
+
export const DiagramInlineSource: React.FC<{ block: Block; kind: DiagramKind }> = ({ block, kind }) => (
|
|
24
|
+
<pre className="rounded-lg text-[13px] overflow-x-auto bg-muted/50 border border-border/30 p-4">
|
|
25
|
+
<code className={`pn-code font-mono language-${block.language?.trim().split(/\s+/, 1)[0] ?? kind}`}>{block.content}</code>
|
|
26
|
+
</pre>
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
/** The status line plus the source. `data-mermaid-pending` is the hook the
|
|
30
|
+
* Mermaid tests wait on and is kept exactly as it was. */
|
|
31
|
+
export const DiagramPending: React.FC<{ block: Block; kind: DiagramKind }> = ({ block, kind }) => (
|
|
32
|
+
<>
|
|
33
|
+
<div
|
|
34
|
+
role="status"
|
|
35
|
+
aria-live="polite"
|
|
36
|
+
data-diagram-pending=""
|
|
37
|
+
{...(kind === 'mermaid' ? { 'data-mermaid-pending': '' } : {})}
|
|
38
|
+
className="mb-1.5 flex items-center gap-1.5 text-xs text-muted-foreground"
|
|
39
|
+
>
|
|
40
|
+
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-muted-foreground/70" aria-hidden="true" />
|
|
41
|
+
Rendering diagram…
|
|
42
|
+
</div>
|
|
43
|
+
<DiagramInlineSource block={block} kind={kind} />
|
|
44
|
+
</>
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The same pending state inside the boxes the block itself renders it in
|
|
49
|
+
* (the block wrapper, the inline host, the viewer's fallback slot), so the
|
|
50
|
+
* Suspense fallback occupies the same space the block will.
|
|
51
|
+
*/
|
|
52
|
+
export const DiagramBlockPending: React.FC<{ block: Block; kind: DiagramKind }> = ({ block, kind }) => (
|
|
53
|
+
<div className="annotation-exclude my-5 group relative" data-block-id={block.id} data-pinpoint-ignore="" data-diagram-block={kind}>
|
|
54
|
+
<div data-diagram-inline="">
|
|
55
|
+
<div data-diagram-fallback="" className="h-full min-h-0 w-full">
|
|
56
|
+
<DiagramPending block={block} kind={kind} />
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
@@ -42,7 +42,9 @@ export function DiagramPopout({
|
|
|
42
42
|
className="h-[calc(100vh-2rem)] w-[calc(100vw-2rem)] max-w-none"
|
|
43
43
|
dataAttributes={{ 'data-diagram-popout': '', ...dataAttributes }}
|
|
44
44
|
>
|
|
45
|
-
|
|
45
|
+
{/* The popout's own chrome: a press here addresses no diagram part
|
|
46
|
+
(see diagram/diagramControls). */}
|
|
47
|
+
<div data-diagram-control="" data-diagram-popout-chrome="" className="flex h-9 shrink-0 items-center gap-2 border-b border-border pl-4 pr-12">
|
|
46
48
|
<span className="truncate text-xs font-medium text-foreground">{title}</span>
|
|
47
49
|
<span className="flex-1" />
|
|
48
50
|
{hasPane && (
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
|
|
1
|
+
import { lazy, Suspense, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
|
|
2
2
|
import type { DiagramKind } from '@plannotator/core/diagram-anchor';
|
|
3
3
|
import { cn } from '../../lib/utils';
|
|
4
4
|
import { diagramFamilyOf } from '../../utils/diagram-anchor';
|
|
@@ -6,7 +6,6 @@ import { diagramFinder, type DiagramTheme } from '../../utils/diagram-render';
|
|
|
6
6
|
import { DiagramCanvas, type DiagramCanvasHandle, type DiagramEscapeOutcome } from './DiagramCanvas';
|
|
7
7
|
import { DiagramComposer } from './DiagramComposer';
|
|
8
8
|
import { DiagramOverlay } from './DiagramOverlay';
|
|
9
|
-
import { DiagramSourcePane } from './DiagramSourcePane';
|
|
10
9
|
import { useDiagramComments, type DiagramComment, type DiagramCreateComment } from './useDiagramComments';
|
|
11
10
|
import { useDiagramRender, type DiagramRenderState } from './useDiagramRender';
|
|
12
11
|
import { useDiagramSourceDraft, type SaveResult } from './useDiagramSourceDraft';
|
|
@@ -21,6 +20,19 @@ import { useDiagramSourceDraft, type SaveResult } from './useDiagramSourceDraft'
|
|
|
21
20
|
* (`components/DiagramBlock`) and again at full size in the popout; a host
|
|
22
21
|
* with its own document store renders it wherever a diagram lives.
|
|
23
22
|
*/
|
|
23
|
+
/**
|
|
24
|
+
* The Source pane is CodeMirror, and a viewer that can never open one (every
|
|
25
|
+
* fence in a Plannotator document today: no host passes `onSave`) must not
|
|
26
|
+
* carry it. It loads on the first open of the pane and never before, so the
|
|
27
|
+
* editor stays out of the document-read closure of a chunked host.
|
|
28
|
+
*/
|
|
29
|
+
const DiagramSourcePane = lazy(async () => ({ default: (await import('./DiagramSourcePane')).DiagramSourcePane }));
|
|
30
|
+
|
|
31
|
+
/** The pane's box while its chunk loads: same class list, so the split it
|
|
32
|
+
* opens into is already the right size and nothing jumps when it lands.
|
|
33
|
+
* Never `null` here — that would collapse the row back onto the canvas. */
|
|
34
|
+
const PANE_CLASS = 'order-last min-h-0 shrink-0 basis-2/5 border-t border-border md:order-first md:w-80 md:basis-auto md:border-r md:border-t-0';
|
|
35
|
+
|
|
24
36
|
export interface DiagramViewerProps {
|
|
25
37
|
readonly kind: DiagramKind;
|
|
26
38
|
/** The diagram text. With `onSave` this is the saved baseline the pane's
|
|
@@ -206,12 +218,9 @@ export function DiagramViewer({
|
|
|
206
218
|
column and the pane stays stacked UNDER the canvas, which
|
|
207
219
|
`order-last` keeps while `md:order-first` puts it left from `md`. */}
|
|
208
220
|
{hasPane && sourceOpen && (
|
|
209
|
-
<
|
|
210
|
-
draft={draft}
|
|
211
|
-
|
|
212
|
-
markedLines={markedLines}
|
|
213
|
-
className="order-last min-h-0 shrink-0 basis-2/5 border-t border-border md:order-first md:w-80 md:basis-auto md:border-r md:border-t-0"
|
|
214
|
-
/>
|
|
221
|
+
<Suspense fallback={<div data-diagram-source-pane-pending="" aria-hidden="true" className={PANE_CLASS} />}>
|
|
222
|
+
<DiagramSourcePane draft={draft} editable={editable} markedLines={markedLines} className={PANE_CLASS} />
|
|
223
|
+
</Suspense>
|
|
215
224
|
)}
|
|
216
225
|
<div className="relative min-h-0 min-w-0 flex-1">
|
|
217
226
|
{showFallback ? (
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The canvas's own chrome, and why a pointer event on it must stop there.
|
|
3
|
+
*
|
|
4
|
+
* `DiagramCanvas` resolves what a click means over EVERYTHING under the
|
|
5
|
+
* pointer (`elementsFromPoint`, node → edge → cluster), because the edge hit
|
|
6
|
+
* layer sits above the nodes. The controls painted over the canvas — the
|
|
7
|
+
* zoom strip, the comment composer, the popout's own chrome — are not in the
|
|
8
|
+
* svg, so that walk skipped straight past them to whatever part happened to
|
|
9
|
+
* be underneath: pressing Zoom out over a node opened the composer on that
|
|
10
|
+
* node, and a press on the strip could start a pan.
|
|
11
|
+
*
|
|
12
|
+
* So a pointer event whose composed path contains a control surface never
|
|
13
|
+
* resolves a target, never opens the composer, and never starts a pan. It is
|
|
14
|
+
* the path rather than the point: a control knows it is a control, while a
|
|
15
|
+
* rectangle test would have to be kept in step with the layout.
|
|
16
|
+
*
|
|
17
|
+
* Mark new chrome with `data-diagram-control`. Buttons and toolbars count
|
|
18
|
+
* without marking, since anything the pointer can press is chrome by
|
|
19
|
+
* definition.
|
|
20
|
+
*/
|
|
21
|
+
export const DIAGRAM_CONTROL_SELECTOR =
|
|
22
|
+
'[data-diagram-control],[data-diagram-composer],[data-diagram-source-pane],[data-diagram-popout-chrome],button,[role="toolbar"],[role="button"],input,textarea,select,a[href]';
|
|
23
|
+
|
|
24
|
+
function isElement(value: unknown): value is Element {
|
|
25
|
+
return typeof (value as Element | null)?.matches === 'function';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Whether this pointer event was aimed at the canvas's chrome rather than
|
|
29
|
+
* at the diagram. */
|
|
30
|
+
export function isDiagramControlEvent(event: { target: EventTarget | null; nativeEvent?: Event }): boolean {
|
|
31
|
+
const native = event.nativeEvent ?? (event as unknown as Event);
|
|
32
|
+
const path = typeof native?.composedPath === 'function' ? native.composedPath() : [];
|
|
33
|
+
for (const entry of path) {
|
|
34
|
+
if (isElement(entry) && entry.matches(DIAGRAM_CONTROL_SELECTOR)) return true;
|
|
35
|
+
}
|
|
36
|
+
// `composedPath()` is empty once dispatch has finished, and jsdom-class
|
|
37
|
+
// DOMs may not implement it at all: the target's own ancestry is the same
|
|
38
|
+
// answer for everything but a shadow root.
|
|
39
|
+
const target = event.target;
|
|
40
|
+
return isElement(target) && target.closest(DIAGRAM_CONTROL_SELECTOR) !== null;
|
|
41
|
+
}
|
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
*/
|
|
9
9
|
export { DiagramViewer, type DiagramViewerProps } from './DiagramViewer';
|
|
10
10
|
export { DiagramPopout } from './DiagramPopout';
|
|
11
|
-
export {
|
|
11
|
+
export { DiagramPending, DiagramInlineSource, DiagramBlockPending } from './DiagramPending';
|
|
12
|
+
export { isDiagramControlEvent, DIAGRAM_CONTROL_SELECTOR } from './diagramControls';
|
|
13
|
+
export { svgContentSize } from './svgContentSize';
|
|
14
|
+
export { DiagramCanvas, KEY_PAN_PX, type DiagramCanvasHandle, type DiagramEscapeOutcome } from './DiagramCanvas';
|
|
12
15
|
export { DiagramOverlay } from './DiagramOverlay';
|
|
13
16
|
export { DiagramComposer } from './DiagramComposer';
|
|
14
17
|
export { DiagramSourcePane } from './DiagramSourcePane';
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ContentSize } from './useDiagramViewport';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The rendered diagram's intrinsic size — a pure read of two svg attributes.
|
|
5
|
+
*
|
|
6
|
+
* It lives apart from `DiagramCanvas` (which re-exports it, so every
|
|
7
|
+
* published path still resolves) because the document's fence block needs
|
|
8
|
+
* it to size its inline box and nothing else of the canvas: importing it
|
|
9
|
+
* from the canvas dragged the whole zoom/pan surface, its viewport hook and
|
|
10
|
+
* lucide into any closure that touched a diagram block. The type import is
|
|
11
|
+
* erased, so this file's runtime dependencies are none.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** A numeric svg length: `206`, `206pt`, `206px`. */
|
|
15
|
+
function svgLength(value: string | null): number {
|
|
16
|
+
if (value === null) return Number.NaN;
|
|
17
|
+
return Number.parseFloat(value);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The svg's intrinsic size, from its viewBox (Mermaid and Graphviz always
|
|
21
|
+
* write one), else its `width` / `height` attributes (a `pt` or `px` suffix
|
|
22
|
+
* is accepted, as Graphviz writes them). */
|
|
23
|
+
export function svgContentSize(svg: SVGSVGElement): ContentSize | null {
|
|
24
|
+
const viewBox = svg.getAttribute('viewBox');
|
|
25
|
+
if (viewBox !== null) {
|
|
26
|
+
const parts = viewBox
|
|
27
|
+
.trim()
|
|
28
|
+
.split(/[\s,]+/u)
|
|
29
|
+
.map(Number);
|
|
30
|
+
const width = parts[2];
|
|
31
|
+
const height = parts[3];
|
|
32
|
+
if (
|
|
33
|
+
parts.length === 4 &&
|
|
34
|
+
width !== undefined &&
|
|
35
|
+
height !== undefined &&
|
|
36
|
+
Number.isFinite(width) &&
|
|
37
|
+
Number.isFinite(height) &&
|
|
38
|
+
width > 0 &&
|
|
39
|
+
height > 0
|
|
40
|
+
) {
|
|
41
|
+
return { width, height };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const width = svgLength(svg.getAttribute('width'));
|
|
45
|
+
const height = svgLength(svg.getAttribute('height'));
|
|
46
|
+
return Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0 ? { width, height } : null;
|
|
47
|
+
}
|
package/config/settings.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
type TokenHoverDelay,
|
|
23
23
|
type TokenHoverTrigger,
|
|
24
24
|
} from '@plannotator/core/token-hover';
|
|
25
|
+
import { DEFAULT_DIAGRAM_SHADOW, isDiagramShadow } from '../utils/diagramShadow';
|
|
25
26
|
import { storage } from '../utils/storage';
|
|
26
27
|
import { generateIdentity } from '../utils/generateIdentity';
|
|
27
28
|
import {
|
|
@@ -170,6 +171,27 @@ export const SETTINGS = {
|
|
|
170
171
|
serverKey: undefined, fromServer: undefined, toServer: undefined,
|
|
171
172
|
},
|
|
172
173
|
|
|
174
|
+
/**
|
|
175
|
+
* How strong the drop shadow under Mermaid diagram nodes is, 0..100, where
|
|
176
|
+
* 100 is Mermaid 12's own default geometry. Default 70: the shipped neo look
|
|
177
|
+
* with its halo toned down (the colour is always derived from the palette,
|
|
178
|
+
* see `utils/mermaidTheme`). Cookie-only, like the other display knobs.
|
|
179
|
+
*/
|
|
180
|
+
diagramShadow: {
|
|
181
|
+
defaultValue: DEFAULT_DIAGRAM_SHADOW as number,
|
|
182
|
+
fromCookie: () => {
|
|
183
|
+
// `Number(null)` and `Number('')` are 0, which is a VALID amount here
|
|
184
|
+
// (unlike the token-hover steps), so an absent cookie must be rejected
|
|
185
|
+
// before the guard sees it — otherwise no cookie reads as "no shadow".
|
|
186
|
+
const raw = storage.getItem('plannotator-diagram-shadow');
|
|
187
|
+
if (raw === null || raw.trim() === '') return undefined;
|
|
188
|
+
const parsed = Number(raw);
|
|
189
|
+
return isDiagramShadow(parsed) ? parsed : undefined;
|
|
190
|
+
},
|
|
191
|
+
toCookie: (value: number) => storage.setItem('plannotator-diagram-shadow', String(value)),
|
|
192
|
+
serverKey: undefined, fromServer: undefined, toServer: undefined,
|
|
193
|
+
},
|
|
194
|
+
|
|
173
195
|
vimModeEnabled: {
|
|
174
196
|
// Vim bindings deliberately default OFF. Unmodified letter keys must remain
|
|
175
197
|
// inert for existing users until they explicitly opt into modal document
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* "Is this document being rendered for paper right now?"
|
|
5
|
+
*
|
|
6
|
+
* Two signals, because neither one alone covers every path:
|
|
7
|
+
*
|
|
8
|
+
* - `beforeprint` / `afterprint` — what a real Cmd+P and the browser's print
|
|
9
|
+
* preview fire. Handlers run BEFORE the print snapshot, so a DOM or class
|
|
10
|
+
* write made from one is in the printed output; a React state update
|
|
11
|
+
* scheduled from one is not guaranteed to be.
|
|
12
|
+
* - `matchMedia('print')` change — what headless print emulation
|
|
13
|
+
* (`page.emulateMedia({ media: 'print' })`) and some preview
|
|
14
|
+
* implementations fire instead; there the page keeps living in print media,
|
|
15
|
+
* so async work (a diagram re-render) does land.
|
|
16
|
+
*
|
|
17
|
+
* Firefox may skip `afterprint` when a preview is dismissed, so a
|
|
18
|
+
* `visibilitychange` back to a visible document also exits print mode.
|
|
19
|
+
*/
|
|
20
|
+
export function subscribePrintMedia(setPrinting: (printing: boolean) => void): () => void {
|
|
21
|
+
if (typeof window === 'undefined') return () => {};
|
|
22
|
+
|
|
23
|
+
const onBeforePrint = () => setPrinting(true);
|
|
24
|
+
const onAfterPrint = () => setPrinting(false);
|
|
25
|
+
const onVisibilityChange = () => {
|
|
26
|
+
if (!document.hidden) setPrinting(false);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
window.addEventListener('beforeprint', onBeforePrint);
|
|
30
|
+
window.addEventListener('afterprint', onAfterPrint);
|
|
31
|
+
document.addEventListener('visibilitychange', onVisibilityChange);
|
|
32
|
+
|
|
33
|
+
const query = typeof window.matchMedia === 'function' ? window.matchMedia('print') : null;
|
|
34
|
+
const onMediaChange = (event: MediaQueryListEvent) => setPrinting(event.matches);
|
|
35
|
+
query?.addEventListener?.('change', onMediaChange);
|
|
36
|
+
if (query?.matches) setPrinting(true);
|
|
37
|
+
|
|
38
|
+
return () => {
|
|
39
|
+
window.removeEventListener('beforeprint', onBeforePrint);
|
|
40
|
+
window.removeEventListener('afterprint', onAfterPrint);
|
|
41
|
+
document.removeEventListener('visibilitychange', onVisibilityChange);
|
|
42
|
+
query?.removeEventListener?.('change', onMediaChange);
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* React binding over `subscribePrintMedia`. `onEnter` / `onExit` run inside the
|
|
48
|
+
* event handler itself, which is the only place a change is guaranteed to
|
|
49
|
+
* reach a real print snapshot; the returned boolean is the ordinary (async)
|
|
50
|
+
* state for everything that can wait.
|
|
51
|
+
*/
|
|
52
|
+
export function usePrintMedia(callbacks?: { onEnter?: () => void; onExit?: () => void }): boolean {
|
|
53
|
+
const [printing, setPrinting] = useState(false);
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
let active = false;
|
|
57
|
+
return subscribePrintMedia((next) => {
|
|
58
|
+
if (next === active) return;
|
|
59
|
+
active = next;
|
|
60
|
+
if (next) callbacks?.onEnter?.();
|
|
61
|
+
else callbacks?.onExit?.();
|
|
62
|
+
setPrinting(next);
|
|
63
|
+
});
|
|
64
|
+
// The callbacks are read through the closure on purpose: re-subscribing on
|
|
65
|
+
// every render would drop the listener mid-print.
|
|
66
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
67
|
+
}, []);
|
|
68
|
+
|
|
69
|
+
return printing;
|
|
70
|
+
}
|
package/hooks/usePrintMode.ts
CHANGED
|
@@ -1,26 +1,22 @@
|
|
|
1
1
|
import { useEffect } from 'react';
|
|
2
|
+
import { subscribePrintMedia } from './usePrintMedia';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Manages print mode by toggling 'plannotator-print' class on <html>.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
6
|
+
*
|
|
7
|
+
* Driven by `subscribePrintMedia`, so the class is applied for print media
|
|
8
|
+
* emulation (`page.emulateMedia({ media: 'print' })`, which fires no
|
|
9
|
+
* `beforeprint`) as well as for a real print, and is removed again on
|
|
10
|
+
* `afterprint` or on the visibility change Firefox leaves behind when a
|
|
11
|
+
* preview is dismissed without printing.
|
|
7
12
|
*/
|
|
8
13
|
export function usePrintMode() {
|
|
9
14
|
useEffect(() => {
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
if (!document.hidden && document.documentElement.classList.contains('plannotator-print')) {
|
|
14
|
-
document.documentElement.classList.remove('plannotator-print');
|
|
15
|
-
}
|
|
16
|
-
};
|
|
17
|
-
window.addEventListener('beforeprint', onBeforePrint);
|
|
18
|
-
window.addEventListener('afterprint', onAfterPrint);
|
|
19
|
-
document.addEventListener('visibilitychange', onVisibilityChange);
|
|
15
|
+
const unsubscribe = subscribePrintMedia((printing) => {
|
|
16
|
+
document.documentElement.classList.toggle('plannotator-print', printing);
|
|
17
|
+
});
|
|
20
18
|
return () => {
|
|
21
|
-
|
|
22
|
-
window.removeEventListener('afterprint', onAfterPrint);
|
|
23
|
-
document.removeEventListener('visibilitychange', onVisibilityChange);
|
|
19
|
+
unsubscribe();
|
|
24
20
|
document.documentElement.classList.remove('plannotator-print');
|
|
25
21
|
};
|
|
26
22
|
}, []);
|
package/package.json
CHANGED
package/print.css
CHANGED
|
@@ -177,16 +177,39 @@
|
|
|
177
177
|
|
|
178
178
|
/* ========== Typography ========== */
|
|
179
179
|
|
|
180
|
-
|
|
180
|
+
/*
|
|
181
|
+
* `:not([data-diagram-block] *)` keeps every typography rule off DIAGRAM
|
|
182
|
+
* content. Mermaid 12 draws node and edge labels as real HTML inside
|
|
183
|
+
* `<foreignObject>` (`span.nodeLabel > p`, `span.edgeLabel`), so a blanket
|
|
184
|
+
* `div, span, p { color: #1a1a1a !important }` repainted them — over a dark
|
|
185
|
+
* node fill that prints as an empty box. A diagram colours itself (and
|
|
186
|
+
* prints in the light half of the palette, see ThemeProvider), and its own
|
|
187
|
+
* `<style>` has no `!important` to defend itself with, so the exemption has
|
|
188
|
+
* to live here.
|
|
189
|
+
*/
|
|
190
|
+
body,
|
|
191
|
+
div:not([data-diagram-block] *),
|
|
192
|
+
span:not([data-diagram-block] *),
|
|
193
|
+
p:not([data-diagram-block] *),
|
|
194
|
+
li:not([data-diagram-block] *),
|
|
195
|
+
td:not([data-diagram-block] *),
|
|
196
|
+
th:not([data-diagram-block] *),
|
|
197
|
+
label:not([data-diagram-block] *),
|
|
198
|
+
strong:not([data-diagram-block] *),
|
|
199
|
+
em:not([data-diagram-block] *),
|
|
200
|
+
b:not([data-diagram-block] *),
|
|
201
|
+
i:not([data-diagram-block] *) {
|
|
181
202
|
color: #1a1a1a !important;
|
|
182
203
|
}
|
|
183
204
|
|
|
184
|
-
strong,
|
|
205
|
+
strong:not([data-diagram-block] *),
|
|
206
|
+
b:not([data-diagram-block] *) {
|
|
185
207
|
font-weight: bold !important;
|
|
186
208
|
color: #000 !important;
|
|
187
209
|
}
|
|
188
210
|
|
|
189
|
-
em,
|
|
211
|
+
em:not([data-diagram-block] *),
|
|
212
|
+
i:not([data-diagram-block] *) {
|
|
190
213
|
font-style: italic !important;
|
|
191
214
|
color: #1a1a1a !important;
|
|
192
215
|
}
|
|
@@ -231,7 +254,7 @@
|
|
|
231
254
|
color: #000 !important;
|
|
232
255
|
}
|
|
233
256
|
|
|
234
|
-
p {
|
|
257
|
+
p:not([data-diagram-block] *) {
|
|
235
258
|
margin: 0.5em 0;
|
|
236
259
|
color: #1a1a1a !important;
|
|
237
260
|
}
|