@tanishqkancharla/diffmap 0.3.0 → 0.6.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.
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
2
2
  import { CodeView, type CodeViewHandle } from "@pierre/diffs/react";
3
3
  import type { CodeViewOptions, CodeViewLineSelection } from "@pierre/diffs";
4
4
  import {
5
+ ActivityIndicator,
5
6
  backgroundColor,
6
7
  border,
7
8
  borderColor,
@@ -9,7 +10,11 @@ import {
9
10
  Button,
10
11
  ChevronDown,
11
12
  flex,
13
+ motion,
14
+ radius,
15
+ shadow,
12
16
  spacing,
17
+ Text,
13
18
  text,
14
19
  useTheme,
15
20
  } from "maui";
@@ -19,7 +24,12 @@ import {
19
24
  type SourceAnnotation,
20
25
  type SourceReference,
21
26
  } from "../annotations.js";
22
- import { changedFileItem, type Rev } from "../changes.js";
27
+ import {
28
+ changedFileItem,
29
+ orderForReview,
30
+ type Changes,
31
+ type Rev,
32
+ } from "../changes.js";
23
33
  import type { SourceDefinition } from "../definitions.js";
24
34
  import { useGistPin } from "../gist/pin.ts";
25
35
  import { useGitHubPin } from "../github/pin.ts";
@@ -40,12 +50,25 @@ export type SourceSelection = {
40
50
  type DefinitionNavigation = {
41
51
  selection: SourceSelection | undefined;
42
52
  history: SourceDefinition[];
43
- status: string | undefined;
53
+ /** A definition request in flight, or the error it ended with. */
54
+ request: "finding" | Error | undefined;
55
+ };
56
+
57
+ /** Everything the panel renders from; it keeps the last loaded page while the next one loads. */
58
+ type PanelPage = {
59
+ rev: Rev;
60
+ review: string[];
61
+ selection: SourceSelection | undefined;
62
+ history: SourceDefinition[];
63
+ changes: Changes | Error | undefined;
64
+ source: SourceDefinition | Error | undefined;
44
65
  };
45
66
 
46
67
  export function SourceDiffPanel(props: {
47
68
  /** The changes to show; references with their own rev open that rev instead. */
48
69
  rev: Rev;
70
+ /** Paths to show first, in order, as the rev's changes card lists them. */
71
+ review: string[];
49
72
  /** Local viewers pick other changes with the range picker. */
50
73
  onRevChange?: (rev: Rev) => void;
51
74
  selection: SourceSelection | undefined;
@@ -57,47 +80,81 @@ export function SourceDiffPanel(props: {
57
80
  const [navigation, setNavigation] = useState<DefinitionNavigation>({
58
81
  selection: props.selection,
59
82
  history: [],
60
- status: undefined,
83
+ request: undefined,
61
84
  });
62
85
  if (navigation.selection !== props.selection) {
63
86
  setNavigation({
64
87
  selection: props.selection,
65
88
  history: [],
66
- status: undefined,
89
+ request: undefined,
67
90
  });
68
91
  }
69
- const { history } = navigation;
70
92
  const request = useRef(0);
93
+ const hostFiles = useHostFiles();
94
+ const nextChanges = useChanges(props.rev);
95
+ const nextReference = props.selection?.reference;
96
+ // A rev-less reference resolves against current files; a commit reference names its lines directly.
97
+ const nextSource = useSourceReference(
98
+ nextReference?.kind === "file" && nextReference.rev === undefined
99
+ ? nextReference
100
+ : undefined,
101
+ );
102
+ const loading =
103
+ nextChanges === undefined
104
+ ? "Loading changes…"
105
+ : nextReference?.kind === "file" &&
106
+ nextReference.rev === undefined &&
107
+ nextSource === undefined &&
108
+ (mode === "local" || mode === "github" || hostFiles !== undefined)
109
+ ? "Opening source…"
110
+ : navigation.request === "finding"
111
+ ? "Finding definition…"
112
+ : undefined;
113
+ const next: PanelPage = {
114
+ rev: props.rev,
115
+ review: props.review,
116
+ selection: props.selection,
117
+ history: navigation.history,
118
+ changes: nextChanges,
119
+ source: nextSource,
120
+ };
121
+ // The new page replaces the old one only once it has loaded, so nothing half-loaded shows in between.
122
+ const [page, setPage] = useState(next);
123
+ if (
124
+ loading === undefined &&
125
+ (page.rev !== next.rev ||
126
+ page.review !== next.review ||
127
+ page.selection !== next.selection ||
128
+ page.history !== next.history ||
129
+ page.changes !== next.changes ||
130
+ page.source !== next.source)
131
+ ) {
132
+ setPage(next);
133
+ }
134
+ const { history, selection, changes, source, review } = page;
71
135
  const definition = history.at(-1);
72
136
  const panel = useStyles(styles.panel);
73
- const header = useStyles(styles.header);
137
+ const header = useStyles(styles.header, styles.dimmed);
74
138
  const titleRow = useStyles(styles.titleRow);
75
139
  const titleClass = useStyles(styles.title);
76
140
  const pinClass = useStyles(styles.pin);
77
141
  const links = useStyles(styles.links);
78
142
  const code = useStyles(styles.code);
79
143
  const summary = useStyles(styles.summary);
80
- const codeWrap = useStyles(styles.codeWrap);
144
+ const codeWrap = useStyles(styles.codeWrap, styles.dimmed);
81
145
  const collapseIcon = useStyles(styles.collapseIcon);
82
146
  const github = useGitHubPin();
83
147
  const gist = useGistPin();
84
- const hostFiles = useHostFiles();
85
- const selection = props.selection;
148
+ const loadingClass = useStyles(styles.loading);
149
+ const spinner = useStyles(styles.spinner);
86
150
  const reference = selection?.reference;
87
- const changes = useChanges(props.rev);
88
- const items = useMemo(
89
- () =>
90
- changes === undefined || changes instanceof Error
91
- ? []
92
- : changes.files.flatMap((changed) => changedFileItem(changed) ?? []),
93
- [changes],
94
- );
95
- // A rev-less reference resolves against current files; a commit reference names its lines directly.
96
- const source = useSourceReference(
97
- reference?.kind === "file" && reference.rev === undefined
98
- ? reference
99
- : undefined,
100
- );
151
+ const items = useMemo(() => {
152
+ if (changes === undefined || changes instanceof Error) return [];
153
+ const { reviewed, other } = orderForReview(changes.files, review);
154
+ return [...reviewed, ...other].flatMap(
155
+ (changed) => changedFileItem(changed) ?? [],
156
+ );
157
+ }, [changes, review]);
101
158
  const resolvedFile = source instanceof Error ? undefined : source;
102
159
  const inChanges =
103
160
  resolvedFile !== undefined &&
@@ -108,25 +165,16 @@ export function SourceDiffPanel(props: {
108
165
  ? resolvedFile
109
166
  : undefined);
110
167
  const status =
111
- navigation.status !== undefined
112
- ? navigation.status
168
+ navigation.request instanceof Error
169
+ ? navigation.request.message
113
170
  : source instanceof Error
114
171
  ? source.message
115
172
  : // A shown file replaces the changes, so their error is moot, as in a gist without uncommitted changes.
116
173
  changes instanceof Error && file === undefined
117
174
  ? changes.message
118
- : changes === undefined
119
- ? "Loading changes…"
120
- : reference?.kind === "file" &&
121
- reference.rev === undefined &&
122
- source === undefined &&
123
- (mode === "local" ||
124
- mode === "github" ||
125
- hostFiles !== undefined)
126
- ? "Opening source…"
127
- : items.length === 0 && file === undefined
128
- ? "No changes."
129
- : undefined;
175
+ : changes !== undefined && items.length === 0 && file === undefined
176
+ ? "No changes."
177
+ : undefined;
130
178
  const selectedLines = useMemo<CodeViewLineSelection | null>(() => {
131
179
  if (file !== undefined)
132
180
  return { id: file.path, range: { start: file.start, end: file.end } };
@@ -153,9 +201,9 @@ export function SourceDiffPanel(props: {
153
201
  rev: Rev;
154
202
  selection: SourceSelection | undefined;
155
203
  paths: ReadonlySet<string>;
156
- }>({ rev: props.rev, selection, paths: new Set() });
157
- if (collapse.rev !== props.rev || collapse.selection !== selection) {
158
- setCollapse({ rev: props.rev, selection, paths: new Set() });
204
+ }>({ rev: page.rev, selection, paths: new Set() });
205
+ if (collapse.rev !== page.rev || collapse.selection !== selection) {
206
+ setCollapse({ rev: page.rev, selection, paths: new Set() });
159
207
  }
160
208
  const rangeId = selectedLines?.id;
161
209
  const rangeStart = selectedLines?.range.start;
@@ -297,28 +345,33 @@ export function SourceDiffPanel(props: {
297
345
  text: lineText,
298
346
  });
299
347
  const currentRequest = ++request.current;
300
- setNavigation({ selection, history, status: "Finding definition…" });
348
+ setNavigation({ selection, history, request: "finding" });
301
349
  // oxlint-disable-next-line typescript/no-floating-promises -- Token callbacks cannot await; the request owns its status update.
302
350
  void requestSource("definition", params).then((result) => {
303
351
  if (currentRequest !== request.current) return;
304
352
  setNavigation((current) => {
305
353
  if (current.selection !== selection) return current;
306
- if (result instanceof Error)
307
- return { ...current, status: result.message };
354
+ if (result instanceof Error) return { ...current, request: result };
308
355
  return {
309
356
  selection,
310
357
  history: [...history, result],
311
- status: undefined,
358
+ request: undefined,
312
359
  };
313
360
  });
314
361
  });
315
362
  };
316
363
 
317
364
  return (
318
- <aside id="source-diff-panel" aria-label="Source changes" className={panel}>
365
+ <aside
366
+ id="source-diff-panel"
367
+ aria-label="Source changes"
368
+ aria-busy={loading !== undefined}
369
+ className={panel}
370
+ >
319
371
  <div
320
372
  className={header}
321
373
  data-diffmap-kind="source-header"
374
+ data-loading={loading !== undefined}
322
375
  // Pierre's file headers draw their own top border, which divides them from this header.
323
376
  data-file-headers={!showTitle && items.length > 0}
324
377
  >
@@ -334,7 +387,11 @@ export function SourceDiffPanel(props: {
334
387
  props.onRevChange === undefined &&
335
388
  loadedChanges !== undefined && (
336
389
  <div className={titleRow}>
337
- {loadedChanges.url === undefined ? (
390
+ {selection?.annotation.title !== undefined ? (
391
+ <div className={titleClass} title={selection.annotation.title}>
392
+ {selection.annotation.title}
393
+ </div>
394
+ ) : loadedChanges.url === undefined ? (
338
395
  <div className={titleClass} title={loadedChanges.title}>
339
396
  {loadedChanges.title}
340
397
  </div>
@@ -362,7 +419,7 @@ export function SourceDiffPanel(props: {
362
419
  setNavigation({
363
420
  selection,
364
421
  history: history.slice(0, -1),
365
- status: undefined,
422
+ request: undefined,
366
423
  });
367
424
  }}
368
425
  >
@@ -414,7 +471,13 @@ export function SourceDiffPanel(props: {
414
471
  )}
415
472
  </div>
416
473
  {/* oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- Pierre's file headers are the click targets; this only catches their clicks. */}
417
- <div className={codeWrap} onClick={onHeaderClick}>
474
+ <div
475
+ className={codeWrap}
476
+ data-loading={loading !== undefined}
477
+ // The last page stays visible while the next one loads, but it is no longer the one to act on.
478
+ inert={loading !== undefined}
479
+ onClick={onHeaderClick}
480
+ >
418
481
  <CodeView
419
482
  ref={viewer}
420
483
  className={code}
@@ -455,6 +518,19 @@ export function SourceDiffPanel(props: {
455
518
  )}
456
519
  />
457
520
  </div>
521
+ <div
522
+ className={loadingClass}
523
+ role="status"
524
+ data-loading={loading !== undefined}
525
+ >
526
+ {loading !== undefined && (
527
+ // Inline in the label, the spinner sits on the text's baseline at its size, so it lines up in any font.
528
+ <Text size="sm" color="lowContrast">
529
+ <ActivityIndicator className={spinner} />
530
+ {loading}
531
+ </Text>
532
+ )}
533
+ </div>
458
534
  </aside>
459
535
  );
460
536
  }
@@ -465,6 +541,7 @@ const panelBackgroundCss = `:host { --diffs-light-bg: ${backgroundColor.app}; --
465
541
 
466
542
  const styles = {
467
543
  panel: style(flex({ direction: "column" }), border(["left"], "border"), {
544
+ position: "relative",
468
545
  gridArea: "diff",
469
546
  minWidth: 0,
470
547
  minHeight: 0,
@@ -524,6 +601,28 @@ const styles = {
524
601
  minWidth: 0,
525
602
  minHeight: 0,
526
603
  }),
604
+ // Loads that finish within the delay swap pages without dimming.
605
+ dimmed: style(motion.standard("opacity"), {
606
+ "&[data-loading='true']": { opacity: 0.4, transitionDelay: "150ms" },
607
+ }),
608
+ loading: style(
609
+ radius.md,
610
+ shadow.medium,
611
+ spacing.padding({ x: 6, y: 3 }),
612
+ motion.standard("opacity"),
613
+ {
614
+ position: "absolute",
615
+ top: "50%",
616
+ left: "50%",
617
+ transform: "translate(-50%, -50%)",
618
+ whiteSpace: "nowrap",
619
+ backgroundColor: backgroundColor.app,
620
+ pointerEvents: "none",
621
+ opacity: 0,
622
+ "&[data-loading='true']": { opacity: 1, transitionDelay: "150ms" },
623
+ },
624
+ ),
625
+ spinner: style({ marginRight: spacing.value(4) }),
527
626
  collapseIcon: style({
528
627
  display: "flex",
529
628
  color: colors.gray[11],
@@ -10,6 +10,7 @@ import { motion, useReducedMotion, type Transition } from "motion/react";
10
10
  import {
11
11
  background,
12
12
  colors,
13
+ Drawer,
13
14
  focusRing,
14
15
  motionEasing,
15
16
  overlayMotion,
@@ -67,6 +68,9 @@ export function tocLineWidth(level: number, shallowestLevel: number) {
67
68
  export function TableOfContents(props: {
68
69
  headings: ViewerHeading[];
69
70
  articleRef: RefObject<HTMLElement | null>;
71
+ /** The narrow-screen drawer, opened from the header's TOC button. */
72
+ drawerOpen: boolean;
73
+ onDrawerOpenChange: (open: boolean) => void;
70
74
  }) {
71
75
  const headings = tocHeadings(props.headings);
72
76
  const items = nestHeadings(headings);
@@ -78,6 +82,7 @@ export function TableOfContents(props: {
78
82
  const docked = useDocked(navRef);
79
83
  const railClass = useStyles(styles.rail);
80
84
  const dockedClass = useStyles(styles.docked);
85
+ const drawerClass = useStyles(styles.drawer);
81
86
  const listClass = useStyles(styles.list);
82
87
 
83
88
  if (items.length === 0) return undefined;
@@ -92,24 +97,43 @@ export function TableOfContents(props: {
92
97
  );
93
98
 
94
99
  return (
95
- <nav
96
- ref={navRef}
97
- id="diffmap-toc"
98
- className={railClass}
99
- aria-label="Table of contents"
100
- data-diffmap-kind="toc"
101
- data-docked={docked ? "true" : "false"}
102
- >
103
- {docked ? (
104
- <div className={dockedClass} data-diffmap-kind="toc-list">
105
- {list}
106
- </div>
107
- ) : (
108
- <TocRail headings={headings} activeId={activeId}>
109
- {list}
110
- </TocRail>
111
- )}
112
- </nav>
100
+ <>
101
+ <nav
102
+ ref={navRef}
103
+ id="diffmap-toc"
104
+ className={railClass}
105
+ aria-label="Table of contents"
106
+ data-diffmap-kind="toc"
107
+ data-docked={docked ? "true" : "false"}
108
+ >
109
+ {docked ? (
110
+ <div className={dockedClass} data-diffmap-kind="toc-list">
111
+ {list}
112
+ </div>
113
+ ) : (
114
+ <TocRail headings={headings} activeId={activeId}>
115
+ {list}
116
+ </TocRail>
117
+ )}
118
+ </nav>
119
+ <Drawer
120
+ isOpen={props.drawerOpen}
121
+ onOpenChange={props.onDrawerOpenChange}
122
+ aria-label="Table of contents"
123
+ >
124
+ <nav className={drawerClass} aria-label="Table of contents">
125
+ <TocList
126
+ items={items}
127
+ className={listClass}
128
+ activeId={activeId}
129
+ onSelect={(id) => {
130
+ setActiveId(id);
131
+ props.onDrawerOpenChange(false);
132
+ }}
133
+ />
134
+ </nav>
135
+ </Drawer>
136
+ </>
113
137
  );
114
138
  }
115
139
 
@@ -390,6 +414,9 @@ const styles = {
390
414
  justifyContent: "center",
391
415
  alignItems: "flex-start",
392
416
  pointerEvents: "none",
417
+ "@media (max-width: 600px)": {
418
+ display: "none",
419
+ },
393
420
  }),
394
421
  cluster: style(spacing.padding({ top: 4, bottom: 4, left: 2, right: 6 }), {
395
422
  position: "relative",
@@ -455,6 +482,8 @@ const styles = {
455
482
  pointerEvents: "auto",
456
483
  ...tocListRules,
457
484
  }),
485
+ // The same list in Maui's Drawer, which supplies the surface and scrolling.
486
+ drawer: style(spacing.padding({ x: 3, y: 3 }), tocListRules),
458
487
  list: style(text({ size: "sm", fontWeight: 400, color: "lowContrast" })),
459
488
  item: style({
460
489
  "&::before": {
@@ -0,0 +1,28 @@
1
+ import { Button, UnorderedList } from "maui";
2
+ import { style, useStyles } from "purse-styles";
3
+
4
+ /** Opens the table of contents on narrow screens, where the gutter rail is hidden. */
5
+ export function TocButton(props: { expanded: boolean; onClick: () => void }) {
6
+ const className = useStyles(styles.button);
7
+ return (
8
+ <Button
9
+ className={className}
10
+ variant="quiet"
11
+ aria-label="Table of contents"
12
+ aria-haspopup="dialog"
13
+ aria-expanded={props.expanded}
14
+ onClick={props.onClick}
15
+ >
16
+ <UnorderedList size="sm" />
17
+ </Button>
18
+ );
19
+ }
20
+
21
+ const styles = {
22
+ button: style({
23
+ flexShrink: 0,
24
+ "@media (min-width: 601px)": {
25
+ display: "none",
26
+ },
27
+ }),
28
+ };
@@ -19,6 +19,7 @@ import { ComarkView } from "./ComarkView.tsx";
19
19
  import { SourceDiffPanel, type SourceSelection } from "./SourceDiffPanel.js";
20
20
  import { CloseServerButton } from "./CloseServerButton.tsx";
21
21
  import { DiffButton } from "./DiffButton.tsx";
22
+ import { TocButton } from "./TocButton.tsx";
22
23
  import {
23
24
  hasTableOfContents,
24
25
  TableOfContents,
@@ -45,7 +46,7 @@ export function ViewerApp(props: {
45
46
  const local = props.mode === "local";
46
47
  const documentRev =
47
48
  props.defaultRev ??
48
- viewerDocument.changeRevs[0] ??
49
+ viewerDocument.changeCards[0]?.rev ??
49
50
  viewerDocument.references.find((ref) => ref.rev !== undefined)?.rev ??
50
51
  (local ? ALL : WORKING);
51
52
  const [panelRev, setPanelRev] = useState(documentRev);
@@ -58,9 +59,14 @@ export function ViewerApp(props: {
58
59
  const hasSourceDiffs =
59
60
  local ||
60
61
  viewerDocument.references.length > 0 ||
61
- viewerDocument.changeRevs.length > 0 ||
62
+ viewerDocument.changeCards.length > 0 ||
62
63
  props.defaultRev !== undefined;
63
64
  const diffPanelOpen = hasSourceDiffs && showDiffPanel;
65
+ // The panel lists files in the order the rev's changes card asks them to be reviewed.
66
+ const panelReview =
67
+ viewerDocument.changeCards.find(
68
+ (card) => card.rev === panelRev && card.review.length > 0,
69
+ )?.review ?? NO_REVIEW;
64
70
  const body = useStyles(styles.body);
65
71
  const [shutDown, setShutDown] = useState(false);
66
72
  const parsedTitle =
@@ -82,6 +88,7 @@ export function ViewerApp(props: {
82
88
  const stage = useStyles(styles.stage);
83
89
  const articleRef = useRef<HTMLElement>(null);
84
90
  const hasToc = hasTableOfContents(viewerDocument.headings);
91
+ const [tocDrawerOpen, setTocDrawerOpen] = useState(false);
85
92
  useEffect(() => {
86
93
  document.title = title;
87
94
  }, [title]);
@@ -108,6 +115,12 @@ export function ViewerApp(props: {
108
115
  <div className={shell}>
109
116
  <header className={header}>
110
117
  <div className={heading}>
118
+ {hasToc && (
119
+ <TocButton
120
+ expanded={tocDrawerOpen}
121
+ onClick={() => setTocDrawerOpen(true)}
122
+ />
123
+ )}
111
124
  <div className={titleClass}>{title}</div>
112
125
  </div>
113
126
  <div className={actions}>
@@ -150,6 +163,8 @@ export function ViewerApp(props: {
150
163
  <TableOfContents
151
164
  headings={viewerDocument.headings}
152
165
  articleRef={articleRef}
166
+ drawerOpen={tocDrawerOpen}
167
+ onDrawerOpenChange={setTocDrawerOpen}
153
168
  />
154
169
  )}
155
170
  </div>
@@ -157,6 +172,7 @@ export function ViewerApp(props: {
157
172
  {diffPanelOpen && (
158
173
  <SourceDiffPanel
159
174
  rev={panelRev}
175
+ review={panelReview}
160
176
  onRevChange={
161
177
  local
162
178
  ? (rev) => {
@@ -177,6 +193,8 @@ export function ViewerApp(props: {
177
193
  );
178
194
  }
179
195
 
196
+ const NO_REVIEW: string[] = [];
197
+
180
198
  function useViewerMeta(enabled: boolean) {
181
199
  const [meta, setMeta] = useState<ViewerMeta>();
182
200
  useEffect(() => {
@@ -265,6 +283,10 @@ const styles = {
265
283
  backgroundColor: backgroundColor.app,
266
284
  // Scrollport size for the sticky gutter rail (`100cqh`).
267
285
  containerType: "size",
286
+ "@media (max-width: 600px)": {
287
+ paddingInline: spacing.value(6),
288
+ paddingBlock: spacing.value(8),
289
+ },
268
290
  }),
269
291
  prose: style({
270
292
  display: "grid",
@@ -273,7 +295,10 @@ const styles = {
273
295
  maxWidth: "none",
274
296
  minWidth: 0,
275
297
  "&[data-has-toc='true']": {
276
- gridTemplateColumns: `minmax(${String(TOC_SIDE_MARGIN_PX)}px, 1fr) minmax(0, ${proseMaxWidth}) minmax(${String(TOC_SIDE_MARGIN_PX)}px, 1fr)`,
298
+ gridTemplateColumns: `var(--diffmap-toc-columns, minmax(${String(TOC_SIDE_MARGIN_PX)}px, 1fr) minmax(0, ${proseMaxWidth}) minmax(${String(TOC_SIDE_MARGIN_PX)}px, 1fr))`,
299
+ },
300
+ "@media (max-width: 600px)": {
301
+ "--diffmap-toc-columns": "0 minmax(0, 1fr) 0",
277
302
  },
278
303
  }),
279
304
  content: style({
@@ -0,0 +1,69 @@
1
+ const edgeSelector =
2
+ ".edge, .edge-label, .message, .class-relationship, .er-relationship";
3
+
4
+ /**
5
+ * Marks what changed in a `mermaid diff`. `svg` draws every line; `today`
6
+ * and `proposed` draw each side alone. A node or edge missing from Proposed
7
+ * gets data-change="-", one missing from Today "+", and a node whose label
8
+ * differs between the sides "~".
9
+ */
10
+ export function markDiagramChanges(
11
+ svg: string,
12
+ sides: { today: string; proposed: string },
13
+ ) {
14
+ const document = parse(svg);
15
+ const today = parse(sides.today);
16
+ const proposed = parse(sides.proposed);
17
+
18
+ const todayNodes = nodeLabels(today);
19
+ const proposedNodes = nodeLabels(proposed);
20
+ for (const node of document.querySelectorAll("[data-id]")) {
21
+ const id = node.getAttribute("data-id");
22
+ if (!proposedNodes.has(id)) node.setAttribute("data-change", "-");
23
+ else if (!todayNodes.has(id)) node.setAttribute("data-change", "+");
24
+ else if (todayNodes.get(id) !== proposedNodes.get(id))
25
+ node.setAttribute("data-change", "~");
26
+ }
27
+ // A removed sequence participant takes its lifeline with it.
28
+ for (const lifeline of document.querySelectorAll("[data-actor]")) {
29
+ const id = lifeline.getAttribute("data-actor");
30
+ if (!proposedNodes.has(id)) lifeline.setAttribute("data-change", "-");
31
+ else if (!todayNodes.has(id)) lifeline.setAttribute("data-change", "+");
32
+ }
33
+
34
+ const todayEdges = edgeKeys(today);
35
+ const proposedEdges = edgeKeys(proposed);
36
+ for (const edge of document.querySelectorAll(edgeSelector)) {
37
+ const key = edgeKey(edge);
38
+ if (!proposedEdges.has(key)) edge.setAttribute("data-change", "-");
39
+ else if (!todayEdges.has(key)) edge.setAttribute("data-change", "+");
40
+ }
41
+ return document.documentElement.outerHTML;
42
+ }
43
+
44
+ function parse(svg: string) {
45
+ return new DOMParser().parseFromString(svg, "image/svg+xml");
46
+ }
47
+
48
+ function nodeLabels(document: Document) {
49
+ return new Map(
50
+ [...document.querySelectorAll("[data-id]")].map((node) => [
51
+ node.getAttribute("data-id"),
52
+ node.getAttribute("data-label"),
53
+ ]),
54
+ );
55
+ }
56
+
57
+ /** An edge, edge label, or message by its kind, ends, and label. */
58
+ function edgeKey(edge: Element) {
59
+ return [
60
+ edge.getAttribute("class"),
61
+ edge.getAttribute("data-from"),
62
+ edge.getAttribute("data-to"),
63
+ edge.getAttribute("data-label") ?? "",
64
+ ].join("\u0000");
65
+ }
66
+
67
+ function edgeKeys(document: Document) {
68
+ return new Set([...document.querySelectorAll(edgeSelector)].map(edgeKey));
69
+ }