@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.
@@ -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
- /** A numeric svg length: `206`, `206pt`, `206px`. */
61
- function svgLength(value: string | null): number {
62
- if (value === null) return Number.NaN;
63
- return Number.parseFloat(value);
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
- <div className="flex h-9 shrink-0 items-center gap-2 border-b border-border pl-4 pr-12">
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
- <DiagramSourcePane
210
- draft={draft}
211
- editable={editable}
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 { DiagramCanvas, svgContentSize, KEY_PAN_PX, type DiagramCanvasHandle, type DiagramEscapeOutcome } from './DiagramCanvas';
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
+ }
@@ -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
+ }
@@ -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
- * Includes visibilitychange fallback for Firefox, which may not fire
6
- * afterprint when print preview is closed without printing.
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 onBeforePrint = () => document.documentElement.classList.add('plannotator-print');
11
- const onAfterPrint = () => document.documentElement.classList.remove('plannotator-print');
12
- const onVisibilityChange = () => {
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
- window.removeEventListener('beforeprint', onBeforePrint);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plannotator/ui",
3
- "version": "0.41.0",
3
+ "version": "0.41.2",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./components/*": "./components/*.tsx",
package/print.css CHANGED
@@ -177,16 +177,39 @@
177
177
 
178
178
  /* ========== Typography ========== */
179
179
 
180
- body, div, span, p, li, td, th, label, strong, em, b, i {
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, b {
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, i {
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
  }