@kgdiff/viewer 0.1.0 → 0.3.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 +44 -3
- package/dist/controller.d.ts +40 -7
- package/dist/controller.d.ts.map +1 -1
- package/dist/controller.js +194 -45
- package/dist/controller.js.map +1 -1
- package/dist/element.d.ts +12 -2
- package/dist/element.d.ts.map +1 -1
- package/dist/element.js +132 -8
- package/dist/element.js.map +1 -1
- package/dist/framing.d.ts +20 -1
- package/dist/framing.d.ts.map +1 -1
- package/dist/framing.js +49 -0
- package/dist/framing.js.map +1 -1
- package/dist/i18n.d.ts +2 -3
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +4 -6
- package/dist/i18n.js.map +1 -1
- package/dist/index.d.ts +48 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/prefs.d.ts +7 -1
- package/dist/prefs.d.ts.map +1 -1
- package/dist/prefs.js +15 -0
- package/dist/prefs.js.map +1 -1
- package/dist/standalone/kgdiff-viewer-compute.js +1 -1
- package/dist/standalone/kgdiff-viewer.js +4 -4
- package/dist/styles.d.ts.map +1 -1
- package/dist/styles.js +29 -17
- package/dist/styles.js.map +1 -1
- package/dist/text-diff.d.ts +14 -2
- package/dist/text-diff.d.ts.map +1 -1
- package/dist/text-diff.js +52 -2
- package/dist/text-diff.js.map +1 -1
- package/dist/ui/detail.d.ts.map +1 -1
- package/dist/ui/detail.js +194 -151
- package/dist/ui/detail.js.map +1 -1
- package/dist/ui/stage.d.ts +4 -0
- package/dist/ui/stage.d.ts.map +1 -1
- package/dist/ui/stage.js +30 -4
- package/dist/ui/stage.js.map +1 -1
- package/package.json +6 -6
- package/src/controller.ts +206 -41
- package/src/element.ts +148 -8
- package/src/framing.ts +65 -1
- package/src/i18n.ts +4 -6
- package/src/index.ts +47 -1
- package/src/prefs.ts +26 -1
- package/src/styles.ts +29 -17
- package/src/text-diff.ts +57 -2
- package/src/ui/detail.ts +253 -245
- package/src/ui/stage.ts +44 -3
package/src/ui/detail.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Detail panel (docs/interaction.md §Detail panel): for the selected hunk (or node) — title,
|
|
3
|
-
* family pill, prev / next, reviewed toggle;
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* family pill, prev / next, reviewed toggle; then what changed, first: one row per changed
|
|
4
|
+
* facet, before → after (character diff for names, word diff for text, set changes paired into
|
|
5
|
+
* edits); merge / split composition, relations as sentences and match evidence; the unchanged
|
|
6
|
+
* rest last, its attributes folded. Added, removed and inspected nodes show their overview,
|
|
7
|
+
* attributes and relations.
|
|
6
8
|
*/
|
|
7
9
|
import type {
|
|
8
10
|
AttrChange,
|
|
9
|
-
AttrValue,
|
|
10
11
|
DiffResult,
|
|
11
12
|
EdgeFacet,
|
|
12
13
|
MatchEvidence,
|
|
@@ -19,12 +20,14 @@ import type { Family, Scene, SceneHunk } from '@kgdiff/scene';
|
|
|
19
20
|
import type { Topic } from '../controller.js';
|
|
20
21
|
import { attr, h, icon, iconButton, replaceKeepingFocus } from '../dom.js';
|
|
21
22
|
import { formatPercent, type Translator } from '../i18n.js';
|
|
22
|
-
import { type DiffSegment, diffChars, diffWords } from '../text-diff.js';
|
|
23
|
+
import { type DiffSegment, diffChars, diffWords, pairItems } from '../text-diff.js';
|
|
23
24
|
import { formatValue, listItems, sceneHunkGlyph, type UiContext } from './context.js';
|
|
24
25
|
|
|
25
26
|
const MAX_NODES = 12;
|
|
26
27
|
const MAX_RELATIONS = 12;
|
|
27
28
|
const CLAMP_CHARS = 180;
|
|
29
|
+
/** Up to this many characters (both sides together) a value change reads `before → after`. */
|
|
30
|
+
const INLINE_CHARS = 60;
|
|
28
31
|
|
|
29
32
|
type Child = Node | string | null | false | undefined;
|
|
30
33
|
|
|
@@ -80,6 +83,63 @@ function chips(
|
|
|
80
83
|
);
|
|
81
84
|
}
|
|
82
85
|
|
|
86
|
+
/** `before → after` on one line, wrapping when long. */
|
|
87
|
+
function pair(before: Child[], after: Child[], tr: Translator): HTMLElement {
|
|
88
|
+
return h(
|
|
89
|
+
'div',
|
|
90
|
+
{ class: 'pair' },
|
|
91
|
+
h('span', { class: 'b' }, before),
|
|
92
|
+
h('span', { class: 'arr', 'aria-hidden': 'true', text: '→' }),
|
|
93
|
+
h('span', { class: 'sr-only', text: tr.t('detail.changedTo') }),
|
|
94
|
+
h('span', null, after),
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** `before → after` with the changed characters marked. */
|
|
99
|
+
function textPair(before: string, after: string, tr: Translator): HTMLElement {
|
|
100
|
+
const segs = diffChars(before, after);
|
|
101
|
+
return pair(marked(segs, 'before'), marked(segs, 'after'), tr);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** One side of a change on a line of its own: `− before` or `+ after`. */
|
|
105
|
+
function signed(side: 'old' | 'new', content: Child): HTMLElement {
|
|
106
|
+
return h(
|
|
107
|
+
'div',
|
|
108
|
+
{ class: `line ${side}` },
|
|
109
|
+
h('span', { class: 'sign', 'aria-hidden': 'true', text: side === 'old' ? '−' : '+' }),
|
|
110
|
+
h('div', null, content),
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** A set change (synonyms, types, parents, list values): edits as pairs, the rest as chips. */
|
|
115
|
+
function items(
|
|
116
|
+
added: readonly string[],
|
|
117
|
+
removed: readonly string[],
|
|
118
|
+
tr: Translator,
|
|
119
|
+
same: readonly string[] = [],
|
|
120
|
+
): Child[] {
|
|
121
|
+
const p = pairItems(removed, added);
|
|
122
|
+
const out: Child[] = p.pairs.map(([b, a]) => textPair(b, a, tr));
|
|
123
|
+
if (p.added.length > 0 || p.removed.length > 0 || same.length > 0) {
|
|
124
|
+
out.push(chips(p.added, p.removed, same));
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Key · value rows. */
|
|
130
|
+
function table(rows: readonly (readonly [string, Child | Child[]])[], cls = 'attrs'): HTMLElement {
|
|
131
|
+
return h(
|
|
132
|
+
'table',
|
|
133
|
+
{ class: cls },
|
|
134
|
+
h('colgroup', null, h('col', { class: 'k' }), h('col')),
|
|
135
|
+
h(
|
|
136
|
+
'tbody',
|
|
137
|
+
null,
|
|
138
|
+
rows.map(([k, v]) => h('tr', null, h('td', { class: 'k', text: k }), h('td', null, v))),
|
|
139
|
+
),
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
83
143
|
function familyOfEdge(e: UnionEdge): Family {
|
|
84
144
|
if (e.status === 'added') return 'added';
|
|
85
145
|
if (e.status === 'removed') {
|
|
@@ -228,11 +288,15 @@ export class Detail {
|
|
|
228
288
|
];
|
|
229
289
|
const nodeIds = core?.nodes ?? [];
|
|
230
290
|
if (nodeIds.length > 1) parts.push(this.#nodeList(nodeIds, focusId, scene, diff, tr));
|
|
231
|
-
|
|
291
|
+
const [lead, rest] = focus
|
|
292
|
+
? this.#nodeSections(focus, diff, tr, nodeIds.length > 1, true)
|
|
293
|
+
: [[], []];
|
|
232
294
|
const edges = (core?.edges ?? [])
|
|
233
295
|
.map((id) => diff.index.edge(id))
|
|
234
296
|
.filter((e): e is UnionEdge => e !== undefined);
|
|
297
|
+
parts.push(...lead);
|
|
235
298
|
if (edges.length > 0) parts.push(this.#relations(edges, focusId, scene, diff, tr, true));
|
|
299
|
+
parts.push(...rest);
|
|
236
300
|
replaceKeepingFocus(this.#ctx.root, this.#body, this.#body, ...parts);
|
|
237
301
|
}
|
|
238
302
|
|
|
@@ -245,6 +309,7 @@ export class Detail {
|
|
|
245
309
|
return;
|
|
246
310
|
}
|
|
247
311
|
const edges = diff.index.edgesOf(node.id);
|
|
312
|
+
const [lead, rest] = this.#nodeSections(node, diff, tr, false, false);
|
|
248
313
|
replaceKeepingFocus(
|
|
249
314
|
this.#ctx.root,
|
|
250
315
|
this.#body,
|
|
@@ -261,8 +326,9 @@ export class Detail {
|
|
|
261
326
|
this.#nav(tr, undefined),
|
|
262
327
|
),
|
|
263
328
|
h('h2', { class: 'd-title', text: node.label }),
|
|
264
|
-
...
|
|
329
|
+
...lead,
|
|
265
330
|
edges.length > 0 ? this.#relations(edges, node.id, scene, diff, tr, false) : null,
|
|
331
|
+
...rest,
|
|
266
332
|
);
|
|
267
333
|
}
|
|
268
334
|
|
|
@@ -329,265 +395,207 @@ export class Detail {
|
|
|
329
395
|
);
|
|
330
396
|
}
|
|
331
397
|
|
|
398
|
+
/**
|
|
399
|
+
* The node's sections, split around the relations. A modified node (or an unchanged one a hunk
|
|
400
|
+
* points at) leads with what changed and ends with its unchanged rest; added, removed and
|
|
401
|
+
* inspected nodes lead with what they are.
|
|
402
|
+
*/
|
|
332
403
|
#nodeSections(
|
|
333
404
|
node: UnionNode,
|
|
334
405
|
diff: DiffResult,
|
|
335
406
|
tr: Translator,
|
|
336
407
|
showName: boolean,
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
const facets = node.facets;
|
|
340
|
-
const find = <K extends NodeFacet['kind']>(kind: K) =>
|
|
341
|
-
facets.find((f): f is Extract<NodeFacet, { kind: K }> => f.kind === kind);
|
|
408
|
+
inHunk: boolean,
|
|
409
|
+
): [HTMLElement[], HTMLElement[]] {
|
|
342
410
|
const label = (id: UnionId): string => diff.index.node(id)?.label ?? id;
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
const rekey = find('rekey');
|
|
346
|
-
const types = find('types');
|
|
347
|
-
const deprecate = find('deprecate');
|
|
348
|
-
const current = node.after ?? node.before;
|
|
349
|
-
const idRows: [string, Child | Child[]][] = [];
|
|
350
|
-
if (showName) idRows.push([tr.t('detail.name'), h('span', { text: node.label })]);
|
|
351
|
-
idRows.push([
|
|
352
|
-
tr.t('detail.id'),
|
|
353
|
-
rekey
|
|
354
|
-
? [
|
|
355
|
-
h(
|
|
356
|
-
'span',
|
|
357
|
-
{ class: 'id-text' },
|
|
358
|
-
h('del', { class: 'hl', text: rekey.from }),
|
|
359
|
-
' → ',
|
|
360
|
-
h('ins', { class: 'hl', text: rekey.to }),
|
|
361
|
-
),
|
|
362
|
-
rekey.score < 1
|
|
363
|
-
? h('span', {
|
|
364
|
-
class: 'note-line',
|
|
365
|
-
text: ` · ${tr.t('detail.matchScore', { p: formatPercent(rekey.score) })}`,
|
|
366
|
-
})
|
|
367
|
-
: null,
|
|
368
|
-
]
|
|
369
|
-
: h('span', { class: 'id-text', text: node.afterId ?? node.beforeId ?? node.id }),
|
|
370
|
-
]);
|
|
371
|
-
if (types) idRows.push([tr.t('detail.types'), chips(types.added, types.removed)]);
|
|
372
|
-
else if (node.types.length > 0) idRows.push([tr.t('detail.types'), chips([], [], node.types)]);
|
|
373
|
-
const statusBits: Child[] = [tr.t(`status.${node.status}`)];
|
|
374
|
-
if (current?.deprecated && !deprecate) statusBits.push(` · ${tr.t('detail.deprecated')}`);
|
|
375
|
-
if (deprecate) {
|
|
376
|
-
statusBits.push(
|
|
377
|
-
' · ',
|
|
378
|
-
h('strong', {
|
|
379
|
-
text: tr.t(deprecate.deprecated ? 'detail.deprecated' : 'detail.undeprecated'),
|
|
380
|
-
}),
|
|
381
|
-
);
|
|
382
|
-
}
|
|
383
|
-
if (current?.implicit) statusBits.push(` · ${tr.t('detail.implicit')}`);
|
|
384
|
-
idRows.push([tr.t('detail.status'), statusBits]);
|
|
385
|
-
out.push(section(tr.t('detail.identity'), null, kv(idRows)));
|
|
386
|
-
|
|
387
|
-
// Name
|
|
388
|
-
const rename = find('rename');
|
|
389
|
-
if (rename) {
|
|
390
|
-
const segs = diffChars(rename.from, rename.to);
|
|
391
|
-
out.push(
|
|
392
|
-
section(
|
|
393
|
-
tr.t('detail.name'),
|
|
394
|
-
null,
|
|
395
|
-
h(
|
|
396
|
-
'div',
|
|
397
|
-
{ class: 'namediff' },
|
|
398
|
-
h(
|
|
399
|
-
'div',
|
|
400
|
-
{ class: 'line old', 'aria-label': `${tr.t('detail.before')}: ${rename.from}` },
|
|
401
|
-
h('span', { class: 'sign', 'aria-hidden': 'true', text: '−' }),
|
|
402
|
-
h('span', null, ...marked(segs, 'before')),
|
|
403
|
-
),
|
|
404
|
-
h(
|
|
405
|
-
'div',
|
|
406
|
-
{ class: 'line new', 'aria-label': `${tr.t('detail.after')}: ${rename.to}` },
|
|
407
|
-
h('span', { class: 'sign', 'aria-hidden': 'true', text: '+' }),
|
|
408
|
-
h('span', null, ...marked(segs, 'after')),
|
|
409
|
-
),
|
|
410
|
-
),
|
|
411
|
-
),
|
|
412
|
-
);
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
// Attributes
|
|
416
|
-
const attrs = find('attrs');
|
|
417
|
-
const changes = attrs?.changes ?? [];
|
|
418
|
-
const all = current?.attrs ?? {};
|
|
419
|
-
const changedKeys = new Set(changes.map((x) => x.key));
|
|
420
|
-
const unchanged = Object.keys(all)
|
|
421
|
-
.filter((k) => !changedKeys.has(k))
|
|
422
|
-
.sort();
|
|
423
|
-
if (changes.length > 0 || unchanged.length > 0) {
|
|
424
|
-
out.push(this.#attributes(node.id, changes, unchanged, all, tr));
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
// Synonyms
|
|
428
|
-
const aliases = find('aliases');
|
|
429
|
-
if (aliases)
|
|
430
|
-
out.push(section(tr.t('detail.aliases'), null, chips(aliases.added, aliases.removed)));
|
|
431
|
-
else if (current && current.aliases.length > 0 && node.status === 'unchanged') {
|
|
432
|
-
out.push(section(tr.t('detail.aliases'), null, chips([], [], current.aliases)));
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
// Hierarchy
|
|
436
|
-
const move = find('move');
|
|
437
|
-
if (move) {
|
|
438
|
-
const dl = h('dl', { class: 'hier' });
|
|
439
|
-
dl.append(
|
|
440
|
-
h('dt', { text: tr.t('detail.before') }),
|
|
441
|
-
h(
|
|
442
|
-
'dd',
|
|
443
|
-
null,
|
|
444
|
-
move.from.length
|
|
445
|
-
? move.from.map((id) => chip(label(id), 'del'))
|
|
446
|
-
: chip(tr.t('detail.none'), 'same'),
|
|
447
|
-
),
|
|
448
|
-
h('dt', { text: tr.t('detail.after') }),
|
|
449
|
-
h(
|
|
450
|
-
'dd',
|
|
451
|
-
null,
|
|
452
|
-
move.to.length
|
|
453
|
-
? move.to.map((id) => chip(label(id), 'add'))
|
|
454
|
-
: chip(tr.t('detail.none'), 'same'),
|
|
455
|
-
),
|
|
456
|
-
);
|
|
457
|
-
out.push(section(tr.t('detail.hierarchy'), null, dl));
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
// Replacement links
|
|
461
|
-
const replacement = find('replacement');
|
|
462
|
-
if (replacement) {
|
|
463
|
-
const rows: [string, Child][] = [];
|
|
464
|
-
const { replacedBy, replaces } = replacement;
|
|
465
|
-
if (replacedBy.added.length || replacedBy.removed.length) {
|
|
466
|
-
rows.push([tr.t('detail.replacedBy'), chips(replacedBy.added, replacedBy.removed)]);
|
|
467
|
-
}
|
|
468
|
-
if (replaces.added.length || replaces.removed.length) {
|
|
469
|
-
rows.push([tr.t('detail.replaces'), chips(replaces.added, replaces.removed)]);
|
|
470
|
-
}
|
|
471
|
-
if (rows.length) out.push(section(tr.t('detail.deprecated'), null, kv(rows)));
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
// Composition
|
|
411
|
+
const fold = node.status === 'modified' || (inHunk && node.status === 'unchanged');
|
|
412
|
+
const changes = this.#changes(node, diff, tr, label);
|
|
475
413
|
const comp = this.#composition(node, diff, tr, label);
|
|
476
|
-
if (comp) out.push(comp);
|
|
477
|
-
|
|
478
|
-
// Evidence
|
|
479
414
|
const evidence = this.#evidence(node, diff, tr, label);
|
|
480
|
-
|
|
481
|
-
|
|
415
|
+
const overview = this.#overview(node, tr, showName, fold);
|
|
416
|
+
const present = (xs: readonly (HTMLElement | null)[]): HTMLElement[] =>
|
|
417
|
+
xs.filter((x): x is HTMLElement => x !== null);
|
|
418
|
+
return fold
|
|
419
|
+
? [present([comp, changes]), present([evidence, ...overview])]
|
|
420
|
+
: [present([...overview, changes, comp, evidence]), []];
|
|
482
421
|
}
|
|
483
422
|
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
all: Readonly<Record<string, AttrValue>>,
|
|
423
|
+
/** What changed on the node: one row per facet (per key for attributes), in facet order. */
|
|
424
|
+
#changes(
|
|
425
|
+
node: UnionNode,
|
|
426
|
+
diff: DiffResult,
|
|
489
427
|
tr: Translator,
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
428
|
+
label: (id: UnionId) => string,
|
|
429
|
+
): HTMLElement | null {
|
|
430
|
+
// Replacement links hold ids as written: name the node they point at when there is one.
|
|
431
|
+
const named = (ids: readonly string[]): string[] =>
|
|
432
|
+
ids.map((id) => (diff.index.nodeByAfter(id) ?? diff.index.nodeByBefore(id))?.label ?? id);
|
|
433
|
+
const rows: [string, Child | Child[]][] = [];
|
|
434
|
+
for (const f of node.facets) {
|
|
435
|
+
switch (f.kind) {
|
|
436
|
+
case 'rekey':
|
|
437
|
+
rows.push([
|
|
438
|
+
tr.t('detail.id'),
|
|
439
|
+
[
|
|
440
|
+
h('div', { class: 'id-text' }, textPair(f.from, f.to, tr)),
|
|
441
|
+
f.score < 1
|
|
442
|
+
? h('div', {
|
|
443
|
+
class: 'note-line',
|
|
444
|
+
text: tr.t('detail.matchScore', { p: formatPercent(f.score) }),
|
|
445
|
+
})
|
|
446
|
+
: null,
|
|
447
|
+
],
|
|
448
|
+
]);
|
|
449
|
+
break;
|
|
450
|
+
case 'rename':
|
|
451
|
+
rows.push([tr.t('detail.name'), textPair(f.from, f.to, tr)]);
|
|
452
|
+
break;
|
|
453
|
+
case 'types':
|
|
454
|
+
case 'aliases':
|
|
455
|
+
rows.push([tr.t(`detail.${f.kind}`), items(f.added, f.removed, tr)]);
|
|
456
|
+
break;
|
|
457
|
+
case 'attrs':
|
|
458
|
+
for (const ch of f.changes) rows.push([ch.key, this.#attrChange(node.id, ch, tr)]);
|
|
459
|
+
break;
|
|
460
|
+
case 'move':
|
|
461
|
+
rows.push([tr.t('detail.hierarchy'), items(f.to.map(label), f.from.map(label), tr)]);
|
|
462
|
+
break;
|
|
463
|
+
case 'deprecate':
|
|
464
|
+
rows.push([
|
|
465
|
+
tr.t('detail.status'),
|
|
466
|
+
h('strong', {
|
|
467
|
+
text: tr.t(f.deprecated ? 'detail.deprecated' : 'detail.undeprecated'),
|
|
468
|
+
}),
|
|
469
|
+
]);
|
|
470
|
+
break;
|
|
471
|
+
case 'replacement':
|
|
472
|
+
for (const [key, links] of [
|
|
473
|
+
['detail.replacedBy', f.replacedBy],
|
|
474
|
+
['detail.replaces', f.replaces],
|
|
475
|
+
] as const) {
|
|
476
|
+
if (links.added.length > 0 || links.removed.length > 0) {
|
|
477
|
+
rows.push([tr.t(key), items(named(links.added), named(links.removed), tr)]);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
break;
|
|
481
|
+
default:
|
|
482
|
+
// Merges and splits: the composition section.
|
|
483
|
+
break;
|
|
523
484
|
}
|
|
524
485
|
}
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
? h(
|
|
529
|
-
'button',
|
|
530
|
-
{
|
|
531
|
-
type: 'button',
|
|
532
|
-
class: 'disclosure',
|
|
533
|
-
'data-focus-key': 'attrs-more',
|
|
534
|
-
'aria-expanded': String(this.#showUnchangedAttrs),
|
|
535
|
-
onclick: () => {
|
|
536
|
-
this.#showUnchangedAttrs = !this.#showUnchangedAttrs;
|
|
537
|
-
this.#render();
|
|
538
|
-
},
|
|
539
|
-
},
|
|
540
|
-
tr.plural('detail.unchangedAttrs', unchanged.length),
|
|
541
|
-
icon('chevron-down'),
|
|
542
|
-
)
|
|
543
|
-
: null;
|
|
544
|
-
return section(tr.t('detail.attributes'), null, table, toggle);
|
|
486
|
+
return rows.length > 0
|
|
487
|
+
? section(tr.t('detail.changes'), rows.length, table(rows, 'attrs changes'))
|
|
488
|
+
: null;
|
|
545
489
|
}
|
|
546
490
|
|
|
547
|
-
|
|
548
|
-
|
|
491
|
+
/** One attribute's change: lists as set changes, short values inline, long ones a line a side. */
|
|
492
|
+
#attrChange(nodeId: string, ch: AttrChange, tr: Translator): Child[] {
|
|
549
493
|
const beforeList = listItems(ch.before);
|
|
550
494
|
const afterList = listItems(ch.after);
|
|
551
|
-
let before: Child | Child[];
|
|
552
|
-
let after: Child | Child[];
|
|
553
495
|
if (beforeList && afterList) {
|
|
554
|
-
const a = new Set(afterList);
|
|
555
496
|
const b = new Set(beforeList);
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
beforeList.
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
'div',
|
|
563
|
-
{ class: 'chips' },
|
|
564
|
-
afterList.map((x) => chip(x, b.has(x) ? 'same' : 'add')),
|
|
497
|
+
const a = new Set(afterList);
|
|
498
|
+
return items(
|
|
499
|
+
afterList.filter((x) => !b.has(x)),
|
|
500
|
+
beforeList.filter((x) => !a.has(x)),
|
|
501
|
+
tr,
|
|
502
|
+
afterList.filter((x) => b.has(x)),
|
|
565
503
|
);
|
|
566
|
-
} else if (typeof ch.before === 'string' && typeof ch.after === 'string') {
|
|
567
|
-
const segs = diffWords(ch.before, ch.after);
|
|
568
|
-
before = this.#value(`${nodeId}|b|${ch.key}`, ch.before, tr, marked(segs, 'before'));
|
|
569
|
-
after = this.#value(`${nodeId}|a|${ch.key}`, ch.after, tr, marked(segs, 'after'));
|
|
570
|
-
} else {
|
|
571
|
-
before =
|
|
572
|
-
ch.before === undefined
|
|
573
|
-
? empty()
|
|
574
|
-
: this.#value(`${nodeId}|b|${ch.key}`, formatValue(ch.before), tr, [
|
|
575
|
-
h('del', { class: 'hl', text: formatValue(ch.before) }),
|
|
576
|
-
]);
|
|
577
|
-
after =
|
|
578
|
-
ch.after === undefined
|
|
579
|
-
? empty()
|
|
580
|
-
: this.#value(`${nodeId}|a|${ch.key}`, formatValue(ch.after), tr, [
|
|
581
|
-
h('ins', { class: 'hl', text: formatValue(ch.after) }),
|
|
582
|
-
]);
|
|
583
504
|
}
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
h('
|
|
589
|
-
|
|
590
|
-
|
|
505
|
+
const key = `${nodeId}|${ch.key}`;
|
|
506
|
+
const before = formatValue(ch.before);
|
|
507
|
+
const after = formatValue(ch.after);
|
|
508
|
+
if (ch.before === undefined) {
|
|
509
|
+
const ins = h('ins', { class: 'hl', text: after });
|
|
510
|
+
return [signed('new', this.#value(`${key}|a`, after, tr, [ins]))];
|
|
511
|
+
}
|
|
512
|
+
if (ch.after === undefined) {
|
|
513
|
+
const del = h('del', { class: 'hl', text: before });
|
|
514
|
+
return [signed('old', this.#value(`${key}|b`, before, tr, [del]))];
|
|
515
|
+
}
|
|
516
|
+
const segs: readonly DiffSegment[] =
|
|
517
|
+
typeof ch.before === 'string' && typeof ch.after === 'string'
|
|
518
|
+
? diffWords(before, after)
|
|
519
|
+
: [
|
|
520
|
+
{ op: 'delete', text: before },
|
|
521
|
+
{ op: 'insert', text: after },
|
|
522
|
+
];
|
|
523
|
+
const b = marked(segs, 'before');
|
|
524
|
+
const a = marked(segs, 'after');
|
|
525
|
+
return before.length + after.length <= INLINE_CHARS && !/\n/.test(before + after)
|
|
526
|
+
? [pair(b, a, tr)]
|
|
527
|
+
: [
|
|
528
|
+
signed('old', this.#value(`${key}|b`, before, tr, b)),
|
|
529
|
+
signed('new', this.#value(`${key}|a`, after, tr, a)),
|
|
530
|
+
];
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Identity and attributes, without what the changes section shows. Folded, the attributes
|
|
535
|
+
* (all unchanged) hide behind a toggle; otherwise they get a section of their own.
|
|
536
|
+
*/
|
|
537
|
+
#overview(node: UnionNode, tr: Translator, showName: boolean, fold: boolean): HTMLElement[] {
|
|
538
|
+
const has = (kind: NodeFacet['kind']): boolean => node.facets.some((f) => f.kind === kind);
|
|
539
|
+
const current = node.after ?? node.before;
|
|
540
|
+
const rows: [string, Child | Child[]][] = [];
|
|
541
|
+
if (showName && !has('rename')) rows.push([tr.t('detail.name'), node.label]);
|
|
542
|
+
if (!has('rekey')) {
|
|
543
|
+
rows.push([
|
|
544
|
+
tr.t('detail.id'),
|
|
545
|
+
h('span', { class: 'id-text', text: node.afterId ?? node.beforeId ?? node.id }),
|
|
546
|
+
]);
|
|
547
|
+
}
|
|
548
|
+
if (!has('types') && node.types.length > 0) {
|
|
549
|
+
rows.push([tr.t('detail.types'), chips([], [], node.types)]);
|
|
550
|
+
}
|
|
551
|
+
if (!has('aliases') && current && current.aliases.length > 0) {
|
|
552
|
+
rows.push([tr.t('detail.aliases'), chips([], [], current.aliases)]);
|
|
553
|
+
}
|
|
554
|
+
const status: Child[] = [tr.t(`status.${node.status}`)];
|
|
555
|
+
if (current?.deprecated && !has('deprecate')) status.push(` · ${tr.t('detail.deprecated')}`);
|
|
556
|
+
if (current?.implicit) status.push(` · ${tr.t('detail.implicit')}`);
|
|
557
|
+
rows.push([tr.t('detail.status'), status]);
|
|
558
|
+
|
|
559
|
+
const all = current?.attrs ?? {};
|
|
560
|
+
const changed = new Set<string>();
|
|
561
|
+
for (const f of node.facets) {
|
|
562
|
+
if (f.kind === 'attrs') for (const c of f.changes) changed.add(c.key);
|
|
563
|
+
}
|
|
564
|
+
const keys = Object.keys(all)
|
|
565
|
+
.filter((k) => !changed.has(k))
|
|
566
|
+
.sort();
|
|
567
|
+
const attrs = (): HTMLElement =>
|
|
568
|
+
table(keys.map((k) => [k, this.#value(`${node.id}|u|${k}`, formatValue(all[k]), tr)]));
|
|
569
|
+
if (!fold) {
|
|
570
|
+
const out = [section(tr.t('detail.identity'), null, kv(rows))];
|
|
571
|
+
if (keys.length > 0) out.push(section(tr.t('detail.attributes'), null, attrs()));
|
|
572
|
+
return out;
|
|
573
|
+
}
|
|
574
|
+
return [
|
|
575
|
+
section(
|
|
576
|
+
tr.t('detail.identity'),
|
|
577
|
+
null,
|
|
578
|
+
kv(rows),
|
|
579
|
+
keys.length > 0
|
|
580
|
+
? h(
|
|
581
|
+
'button',
|
|
582
|
+
{
|
|
583
|
+
type: 'button',
|
|
584
|
+
class: 'disclosure',
|
|
585
|
+
'data-focus-key': 'attrs-more',
|
|
586
|
+
'aria-expanded': String(this.#showUnchangedAttrs),
|
|
587
|
+
onclick: () => {
|
|
588
|
+
this.#showUnchangedAttrs = !this.#showUnchangedAttrs;
|
|
589
|
+
this.#render();
|
|
590
|
+
},
|
|
591
|
+
},
|
|
592
|
+
tr.plural('detail.unchangedAttrs', keys.length),
|
|
593
|
+
icon('chevron-down'),
|
|
594
|
+
)
|
|
595
|
+
: null,
|
|
596
|
+
keys.length > 0 && this.#showUnchangedAttrs ? attrs() : null,
|
|
597
|
+
),
|
|
598
|
+
];
|
|
591
599
|
}
|
|
592
600
|
|
|
593
601
|
/** A value cell, clamped to 4 lines with “Show more” when long. */
|
package/src/ui/stage.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The canvas area and its floating chrome: replay caption and transport, swipe divider,
|
|
3
|
-
* changes-only context stepper, legend,
|
|
4
|
-
* loading / identical / error / empty states.
|
|
5
|
-
* element reserves their space through the
|
|
3
|
+
* changes-only context stepper, legend (a card, or one line of dots for chrome-less embeds),
|
|
4
|
+
* zoom controls + minimap, hover tooltip and the loading / identical / error / empty states.
|
|
5
|
+
* Overlays never cover the current hunk: the element reserves their space through the
|
|
6
|
+
* renderer's safe area.
|
|
6
7
|
*/
|
|
7
8
|
import { KgdiffError, type Rect } from '@kgdiff/core';
|
|
8
9
|
import type { ChangeFamily, Family } from '@kgdiff/scene';
|
|
@@ -24,6 +25,9 @@ import { sceneHunkGlyph, type UiContext } from './context.js';
|
|
|
24
25
|
|
|
25
26
|
const TICK_W = 1000;
|
|
26
27
|
|
|
28
|
+
/** How the legend shows: the card, one line of dots, or not at all. */
|
|
29
|
+
export type LegendStyle = 'full' | 'mini' | 'none';
|
|
30
|
+
|
|
27
31
|
/** Legend swatch: the node cue of each family (docs/visual-language.md §Meaning families). */
|
|
28
32
|
function swatch(family: Family): SVGSVGElement {
|
|
29
33
|
const v = (name: string) => `var(--kg-${name})`;
|
|
@@ -84,6 +88,7 @@ export class Stage {
|
|
|
84
88
|
readonly #legend: HTMLElement;
|
|
85
89
|
readonly #legendHead: HTMLButtonElement;
|
|
86
90
|
readonly #legendBody: HTMLElement;
|
|
91
|
+
readonly #miniLegend: HTMLElement;
|
|
87
92
|
readonly #zoomIn: HTMLButtonElement;
|
|
88
93
|
readonly #zoomOut: HTMLButtonElement;
|
|
89
94
|
readonly #fitButton: HTMLButtonElement;
|
|
@@ -236,6 +241,7 @@ export class Stage {
|
|
|
236
241
|
this.#legendHead,
|
|
237
242
|
this.#legendBody,
|
|
238
243
|
);
|
|
244
|
+
this.#miniLegend = h('div', { class: 'mini-legend float bottom-left', role: 'list' });
|
|
239
245
|
|
|
240
246
|
// Zoom + minimap
|
|
241
247
|
this.#zoomIn = iconButton('plus', '', () => c.zoom(1));
|
|
@@ -264,6 +270,7 @@ export class Stage {
|
|
|
264
270
|
this.#caption,
|
|
265
271
|
this.#stepper,
|
|
266
272
|
this.#legend,
|
|
273
|
+
this.#miniLegend,
|
|
267
274
|
this.#mapCluster,
|
|
268
275
|
this.#finished,
|
|
269
276
|
this.#transport,
|
|
@@ -297,6 +304,7 @@ export class Stage {
|
|
|
297
304
|
const obstacles: Rect[] = [];
|
|
298
305
|
const parts = [
|
|
299
306
|
this.#legend,
|
|
307
|
+
this.#miniLegend,
|
|
300
308
|
this.#mapCluster,
|
|
301
309
|
this.#transport,
|
|
302
310
|
this.#finished,
|
|
@@ -326,12 +334,19 @@ export class Stage {
|
|
|
326
334
|
attr(this.#legendHead, 'aria-expanded', String(open));
|
|
327
335
|
}
|
|
328
336
|
|
|
337
|
+
/** Legend style, and whether clicks select (the tooltip only hints at clicking then). */
|
|
338
|
+
setChrome(legend: LegendStyle, selectable: boolean): void {
|
|
339
|
+
this.el.dataset.legend = legend;
|
|
340
|
+
this.el.dataset.selectable = String(selectable);
|
|
341
|
+
}
|
|
342
|
+
|
|
329
343
|
update(topics: ReadonlySet<Topic>): void {
|
|
330
344
|
const c = this.#ctx.controller;
|
|
331
345
|
const tr = this.#ctx.tr();
|
|
332
346
|
const all = topics.has('status') || topics.has('data') || topics.has('locale');
|
|
333
347
|
if (all) {
|
|
334
348
|
this.#renderLabels(tr);
|
|
349
|
+
this.#renderMiniLegend(tr);
|
|
335
350
|
this.#renderState(tr);
|
|
336
351
|
this.#renderTicks();
|
|
337
352
|
this.#renderCanvasLabel(tr);
|
|
@@ -381,6 +396,32 @@ export class Stage {
|
|
|
381
396
|
);
|
|
382
397
|
}
|
|
383
398
|
|
|
399
|
+
/** One line: the families this diff has, then the unchanged context. */
|
|
400
|
+
#renderMiniLegend(tr: Translator): void {
|
|
401
|
+
const scene = this.#ctx.controller.scene;
|
|
402
|
+
const present = new Set<Family>(scene?.hunks.map((x) => x.family));
|
|
403
|
+
if (scene?.nodes.some((n) => n.status === 'unchanged')) present.add('neutral');
|
|
404
|
+
attr(this.#miniLegend, 'aria-label', tr.t('legend.title'));
|
|
405
|
+
replace(
|
|
406
|
+
this.#miniLegend,
|
|
407
|
+
[...FAMILIES, 'neutral' as const]
|
|
408
|
+
.filter((f) => present.has(f))
|
|
409
|
+
.map((f) =>
|
|
410
|
+
h(
|
|
411
|
+
'span',
|
|
412
|
+
{
|
|
413
|
+
class: 'mini-item',
|
|
414
|
+
role: 'listitem',
|
|
415
|
+
'data-family': f,
|
|
416
|
+
title: tr.t(`familyHint.${f}`),
|
|
417
|
+
},
|
|
418
|
+
h('span', { class: 'dot', 'aria-hidden': 'true' }),
|
|
419
|
+
tr.t(`family.${f}`),
|
|
420
|
+
),
|
|
421
|
+
),
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
384
425
|
#renderCanvasLabel(tr: Translator): void {
|
|
385
426
|
const stats = this.#ctx.controller.diff?.stats;
|
|
386
427
|
const canvas = this.canvasHost.querySelector('canvas');
|