dsh-model-everything 0.5.1 → 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.
- package/lib/annotation.js +323 -0
- package/lib/client.js +2138 -184
- package/lib/diagram-render-tool.js +13 -1
- package/lib/diagram-review-tool.js +1 -1
- package/lib/diagram.js +33 -0
- package/lib/gate.js +8 -6
- package/lib/render/paint.js +671 -207
- package/lib/render/svg.js +11 -2
- package/lib/render/trunk.js +213 -0
- package/lib/routing.js +12 -0
- package/lib/transcript.js +327 -0
- package/lib/types/annotation.d.ts +128 -0
- package/lib/types/client.d.ts +89 -6
- package/lib/types/diagram.d.ts +7 -0
- package/lib/types/gate.d.ts +8 -6
- package/lib/types/render/paint.d.ts +122 -6
- package/lib/types/render/place.d.ts +8 -3
- package/lib/types/render/trunk.d.ts +104 -0
- package/lib/types/transcript.d.ts +82 -0
- package/package.json +149 -148
- package/skills/model-everything/SKILL.md +2 -1
- package/skills/model-everything-diagram/SKILL.md +22 -1
- package/src/annotation.ts +333 -0
- package/src/client.ts +1841 -307
- package/src/diagram-render-tool.ts +13 -1
- package/src/diagram-review-tool.ts +1 -1
- package/src/diagram.ts +31 -0
- package/src/gate.ts +8 -6
- package/src/render/paint.ts +774 -205
- package/src/render/place.ts +8 -3
- package/src/render/svg.ts +12 -2
- package/src/render/trunk.ts +275 -0
- package/src/routing.ts +12 -0
- package/src/transcript.ts +349 -0
package/lib/render/svg.js
CHANGED
|
@@ -104,6 +104,9 @@ const writers = {
|
|
|
104
104
|
edgeMarks(first, after, before, last, shape.arrow, stroke, colours.background) +
|
|
105
105
|
edgeLabel(shape.label, shape.labelX, shape.labelY, colours));
|
|
106
106
|
},
|
|
107
|
+
// A junction dot: the visible answer to "is this a join or a crossing?". Filled in the line's own colour, so it
|
|
108
|
+
// reads as the line thickening where a branch meets it rather than as a new mark on the page.
|
|
109
|
+
junction: (shape, colours) => '<circle cx="' + n(shape.x) + '" cy="' + n(shape.y) + '" r="2.5" fill="' + colours[shape.colour] + '" />',
|
|
107
110
|
title: (shape, colours) => {
|
|
108
111
|
return '<text x="' + n(shape.x) + '" y="' + n(shape.y) + '" font-size="12" fill="' + colours[shape.colour] + '">' + escape(shape.text) + '</text>';
|
|
109
112
|
},
|
|
@@ -112,7 +115,9 @@ const writers = {
|
|
|
112
115
|
const lines = shape.lines
|
|
113
116
|
.map((line, index) => '<tspan x="' + n(shape.x) + '" dy="' + (index === 0 ? 0 : 15) + '">' + escape(line) + '</tspan>')
|
|
114
117
|
.join('');
|
|
115
|
-
|
|
118
|
+
// Start-anchored: the caption begins at the margin the title uses, so a long one grows right into the space
|
|
119
|
+
// the canvas has already reserved for it instead of hanging off the left edge.
|
|
120
|
+
return '<text x="' + n(shape.x) + '" y="' + n(shape.y + 11) + '" font-size="10" fill="' + colour + '">' + lines + '</text>';
|
|
116
121
|
},
|
|
117
122
|
};
|
|
118
123
|
/** The dash pattern a route's source stated, or nothing for a solid line. */
|
|
@@ -215,7 +220,11 @@ export function toSvg(canvas, colours, label) {
|
|
|
215
220
|
dropped += 1;
|
|
216
221
|
continue;
|
|
217
222
|
}
|
|
218
|
-
|
|
223
|
+
const drawn = writer(shape, colours);
|
|
224
|
+
// A node carries its id in the markup, so a card or a board can target one node by delegation, the way a
|
|
225
|
+
// boundary already carries `data-group-id`, instead of working it out from the click's geometry.
|
|
226
|
+
const nodeId = shape.id;
|
|
227
|
+
body.push(shape.kind === 'box' && typeof nodeId === 'string' ? '<g data-node-id="' + escape(nodeId) + '">' + drawn + '</g>' : drawn);
|
|
219
228
|
}
|
|
220
229
|
const header = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + n(canvas.width) + ' ' + n(canvas.height) +
|
|
221
230
|
'" width="' + n(canvas.width) + '" height="' + n(canvas.height) + '" role="img"' +
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fan-in trunks: a batch of arrows into one box from one side, drawn as one bundle.
|
|
3
|
+
*
|
|
4
|
+
* A node that four relations point at is drawn as four ports along its side and four lines converging on it. Each
|
|
5
|
+
* line is right on its own, and the picture is wrong: the bundle reads as a comb, and a reader counts ports to count
|
|
6
|
+
* arrows. The mature layered implementations solve this by merging the edges - ELK's *Merge Edges* with junction
|
|
7
|
+
* points, Graphviz's `concentrate` - and the shape they produce is one straight run outside the node, with a branch
|
|
8
|
+
* joining it wherever a separate edge arrives, and one shared stub into the node.
|
|
9
|
+
*
|
|
10
|
+
* This module decides only which ends may merge, where the shared run sits and which of the branches becomes the
|
|
11
|
+
* shared entry. It is pure geometry over boxes and ports, so the rules can be asserted without a canvas; the painter
|
|
12
|
+
* draws the answer.
|
|
13
|
+
*
|
|
14
|
+
* The rules, and why each one exists:
|
|
15
|
+
*
|
|
16
|
+
* - **One target, one entry side.** Edges that arrive from different sides are travelling in different directions,
|
|
17
|
+
* and a trunk that changed axis would be two trunks.
|
|
18
|
+
* - **The exit faces the trunk.** A branch arrives at the trunk along the direction its own port leaves by, so the
|
|
19
|
+
* trunk is outside the source as well as outside the target. An end that does not face the trunk is not merged: it
|
|
20
|
+
* would have to turn before it got there, which is the bend the merge was supposed to save.
|
|
21
|
+
* - **Distinct sources, three or more.** Two merged edges read as one thick line. A pair of relations about one pair
|
|
22
|
+
* has its own, older rule - they stay visibly apart - and that rule is the distinct-source rule here: two
|
|
23
|
+
* statements about one pair share their source, so they can never both be a branch of one trunk.
|
|
24
|
+
* - **One arrow semantics.** A composition and an association are different statements about the relation; drawn on
|
|
25
|
+
* one trunk they would be read as one statement. Ends that disagree on their arrow are not merged with each other.
|
|
26
|
+
* - **Clear of every box.** A trunk that crosses a box is the same defect as a route that does, and it is worse here
|
|
27
|
+
* because it is drawn once for the whole bundle.
|
|
28
|
+
* - **One trunk per line.** Two groups whose trunks would be drawn on one line would read as one bundle between two
|
|
29
|
+
* unrelated targets, so the later group is refused.
|
|
30
|
+
*
|
|
31
|
+
* The shared entry is the middle of the target's side. A bundle enters the target through one port, and a side that
|
|
32
|
+
* carries one port is centred on it, so the trunk runs to the middle and every branch joins it there: the branches
|
|
33
|
+
* that are not in the middle bend, which is the price of one entry that reads as centred rather than one that leans
|
|
34
|
+
* towards whichever branch happened to arrive straightest.
|
|
35
|
+
*
|
|
36
|
+
* Merging forces every branch but one to bend twice, so an unmerged drawing of `k` straight branches costs
|
|
37
|
+
* `2 * (n - k)` bends and a merged one costs `2 * (n - 1)`; the branch nearest the middle is the lead because it is
|
|
38
|
+
* the only one that can still reach the shared port without a horizontal leg, and on a target whose middle happens
|
|
39
|
+
* to fall on a branch's own line that branch is free, as before.
|
|
40
|
+
*
|
|
41
|
+
* What this module does not decide: the colour of a branch, the text of its label, and how a branch's own route
|
|
42
|
+
* reaches the trunk. Those belong to the painter.
|
|
43
|
+
*/
|
|
44
|
+
import { crossesBox, normalOf, ROUTE_STUB } from './route.js';
|
|
45
|
+
/** The least a trunk is worth drawing for: two merged edges read as one thick line. */
|
|
46
|
+
const TRUNK_MIN_EDGES = 3;
|
|
47
|
+
/** Does this side move along the page's vertical axis? A run on it is horizontal. */
|
|
48
|
+
function isVerticalSide(side) {
|
|
49
|
+
return side === 'top' || side === 'bottom';
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Where the trunk outside a box sits.
|
|
53
|
+
*
|
|
54
|
+
* The same stub the router leaves before it turns, so the bundle starts exactly where a single route would have
|
|
55
|
+
* turned: about `ROUTE_STUB` outside the target, square to the side it enters.
|
|
56
|
+
*/
|
|
57
|
+
function trunkAt(box, side) {
|
|
58
|
+
if (side === 'top')
|
|
59
|
+
return box.y - ROUTE_STUB;
|
|
60
|
+
if (side === 'bottom')
|
|
61
|
+
return box.y + box.height + ROUTE_STUB;
|
|
62
|
+
if (side === 'left')
|
|
63
|
+
return box.x - ROUTE_STUB;
|
|
64
|
+
return box.x + box.width + ROUTE_STUB;
|
|
65
|
+
}
|
|
66
|
+
/** The coordinate a point takes along the side it sits on: where along that side the port is. */
|
|
67
|
+
function alongSide(point, side) {
|
|
68
|
+
return isVerticalSide(side) ? point.x : point.y;
|
|
69
|
+
}
|
|
70
|
+
/** The coordinate a point takes on its side's outward normal: the axis a trunk outside that side moves on. */
|
|
71
|
+
function alongNormal(point, side) {
|
|
72
|
+
return isVerticalSide(side) ? point.y : point.x;
|
|
73
|
+
}
|
|
74
|
+
/** Does the port on `side` travel toward the trunk at `at`? A branch only merges into a trunk it moves toward. */
|
|
75
|
+
function facesTrunk(point, side, at) {
|
|
76
|
+
const normal = normalOf(side);
|
|
77
|
+
const outward = isVerticalSide(side) ? normal.y : normal.x;
|
|
78
|
+
return (at - alongNormal(point, side)) * outward > 0.5;
|
|
79
|
+
}
|
|
80
|
+
/** The group an edge end belongs to: its target, the side it enters by, and the arrow it means. */
|
|
81
|
+
function keyOf(end) {
|
|
82
|
+
return end.to + '\u0000' + end.entry + '\u0000' + (end.arrow ?? '');
|
|
83
|
+
}
|
|
84
|
+
/** Is this axis-aligned segment clear of every box it does not attach to? */
|
|
85
|
+
function clearSegment(boxes, skip, x1, y1, x2, y2) {
|
|
86
|
+
for (const box of boxes) {
|
|
87
|
+
if (skip.includes(box))
|
|
88
|
+
continue;
|
|
89
|
+
if (crossesBox(x1, y1, x2, y2, box))
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
/** Would this run lie on the line of an accepted trunk and share a stretch of it? */
|
|
95
|
+
function onOneLine(group, side, at, low, high) {
|
|
96
|
+
if (isVerticalSide(group.side) !== isVerticalSide(side))
|
|
97
|
+
return false;
|
|
98
|
+
if (group.at !== at)
|
|
99
|
+
return false;
|
|
100
|
+
return Math.min(group.to, high) - Math.max(group.from, low) > 0.5;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Decide which ends merge, and where each accepted trunk runs.
|
|
104
|
+
*
|
|
105
|
+
* Ends that cannot merge are simply absent from the plan; the painter draws them as it always has. Groups are
|
|
106
|
+
* ordered by the first member the source wrote, so the answer does not depend on map iteration order.
|
|
107
|
+
*/
|
|
108
|
+
export function planTrunks(input) {
|
|
109
|
+
const groups = [];
|
|
110
|
+
const groupOf = new Map();
|
|
111
|
+
// Per edge first: the cheap rules. An end that fails one of them cannot join any group, so it never reaches the
|
|
112
|
+
// count that decides whether a group is worth drawing.
|
|
113
|
+
const buckets = new Map();
|
|
114
|
+
for (const end of input.ends) {
|
|
115
|
+
if (end.runY === undefined)
|
|
116
|
+
continue;
|
|
117
|
+
// The branch approaches the trunk along its own exit normal, so the two ends must move on one axis: a mixed pair
|
|
118
|
+
// would have to turn before it reached the trunk, which is the bend the merge exists to save.
|
|
119
|
+
if (isVerticalSide(end.exit) !== isVerticalSide(end.entry))
|
|
120
|
+
continue;
|
|
121
|
+
const source = input.boxOf(end.from);
|
|
122
|
+
const target = input.boxOf(end.to);
|
|
123
|
+
if (source === undefined || target === undefined)
|
|
124
|
+
continue;
|
|
125
|
+
if (source === target)
|
|
126
|
+
continue;
|
|
127
|
+
const exit = input.exitPortOf(end.index);
|
|
128
|
+
if (exit === undefined)
|
|
129
|
+
continue;
|
|
130
|
+
const at = trunkAt(target, end.entry);
|
|
131
|
+
if (!facesTrunk(exit, end.exit, at))
|
|
132
|
+
continue;
|
|
133
|
+
const candidate = { end, source, target, at, exit, branch: alongSide(exit, end.exit) };
|
|
134
|
+
const key = keyOf(end);
|
|
135
|
+
const list = buckets.get(key);
|
|
136
|
+
if (list === undefined)
|
|
137
|
+
buckets.set(key, [candidate]);
|
|
138
|
+
else
|
|
139
|
+
list.push(candidate);
|
|
140
|
+
}
|
|
141
|
+
for (const list of buckets.values()) {
|
|
142
|
+
// A pair of relations about one node is not two branches: the pair's own rule keeps them visibly apart, and both
|
|
143
|
+
// of them name the same source, so dropping the duplicated source drops the whole pair. Neither joins, and the
|
|
144
|
+
// group keeps the singles.
|
|
145
|
+
const bySource = new Map();
|
|
146
|
+
for (const candidate of list) {
|
|
147
|
+
const seen = bySource.get(candidate.end.from);
|
|
148
|
+
if (seen === undefined)
|
|
149
|
+
bySource.set(candidate.end.from, [candidate]);
|
|
150
|
+
else
|
|
151
|
+
seen.push(candidate);
|
|
152
|
+
}
|
|
153
|
+
const members = list.filter((candidate) => bySource.get(candidate.end.from).length === 1);
|
|
154
|
+
if (members.length < TRUNK_MIN_EDGES)
|
|
155
|
+
continue;
|
|
156
|
+
const target = members[0].target;
|
|
157
|
+
const side = members[0].end.entry;
|
|
158
|
+
const at = members[0].at;
|
|
159
|
+
// The shared entry is the middle of the target's side. A side that carries one port is centred on it, and a bundle
|
|
160
|
+
// enters through one port, so the run reaches the middle rather than whichever branch asked for the middle first.
|
|
161
|
+
const entryAlong = isVerticalSide(side) ? target.x + target.width / 2 : target.y + target.height / 2;
|
|
162
|
+
// The lead is the branch nearest that middle: the only member that can reach the shared port without a lateral
|
|
163
|
+
// leg. The source's order breaks a tie, so the answer does not depend on map iteration.
|
|
164
|
+
let lead = members[0];
|
|
165
|
+
for (const candidate of members) {
|
|
166
|
+
if (Math.abs(candidate.branch - entryAlong) < Math.abs(lead.branch - entryAlong) - 1e-9)
|
|
167
|
+
lead = candidate;
|
|
168
|
+
}
|
|
169
|
+
// The run: the whole stretch the branches and the one port span, so every branch joins a line that reaches it.
|
|
170
|
+
let low = entryAlong;
|
|
171
|
+
let high = entryAlong;
|
|
172
|
+
for (const candidate of members) {
|
|
173
|
+
low = Math.min(low, candidate.branch);
|
|
174
|
+
high = Math.max(high, candidate.branch);
|
|
175
|
+
}
|
|
176
|
+
// Clearance. The run is checked against every box but the target; each branch's own drop is checked against every
|
|
177
|
+
// box but its source and the target. The drop leaves a port square to its side, so those are the only two boxes it
|
|
178
|
+
// can legitimately touch.
|
|
179
|
+
const vertical = isVerticalSide(side);
|
|
180
|
+
const run = vertical ? [low, at, high, at] : [at, low, at, high];
|
|
181
|
+
if (!clearSegment(input.boxes, [target], run[0], run[1], run[2], run[3]))
|
|
182
|
+
continue;
|
|
183
|
+
let blocked = false;
|
|
184
|
+
for (const candidate of members) {
|
|
185
|
+
// The drop: from the branch's own port to the point where it meets the trunk, along its exit normal.
|
|
186
|
+
const drop = vertical
|
|
187
|
+
? [candidate.branch, candidate.exit.y, candidate.branch, at]
|
|
188
|
+
: [candidate.exit.x, candidate.branch, at, candidate.branch];
|
|
189
|
+
if (!clearSegment(input.boxes, [candidate.source, target], drop[0], drop[1], drop[2], drop[3]))
|
|
190
|
+
blocked = true;
|
|
191
|
+
}
|
|
192
|
+
if (blocked)
|
|
193
|
+
continue;
|
|
194
|
+
// Two trunks on one line would be drawn as one bundle. The first group keeps the line; the later one travels alone.
|
|
195
|
+
if (groups.some((group) => onOneLine(group, side, at, low, high)))
|
|
196
|
+
continue;
|
|
197
|
+
const id = groups.length;
|
|
198
|
+
groups.push({
|
|
199
|
+
id,
|
|
200
|
+
target: members[0].end.to,
|
|
201
|
+
side,
|
|
202
|
+
at,
|
|
203
|
+
entryAlong,
|
|
204
|
+
from: low,
|
|
205
|
+
to: high,
|
|
206
|
+
lead: lead.end.index,
|
|
207
|
+
members: members.map((candidate) => candidate.end.index),
|
|
208
|
+
});
|
|
209
|
+
for (const candidate of members)
|
|
210
|
+
groupOf.set(candidate.end.index, id);
|
|
211
|
+
}
|
|
212
|
+
return { groups, groupOf };
|
|
213
|
+
}
|
package/lib/routing.js
CHANGED
|
@@ -70,6 +70,18 @@ export const ROUTES = {
|
|
|
70
70
|
owns: 'a dependency graph as a graph - cycles, layers, dangling edges - from a drawing or a source tree',
|
|
71
71
|
notFor: { when: 'the question is a rule the design declared for itself, not an edge that exists', tool: 'model_design_review' },
|
|
72
72
|
},
|
|
73
|
+
diagram_render: {
|
|
74
|
+
owns: 'one drawing: source text in, a laid-out SVG out, every refused line named',
|
|
75
|
+
notFor: { when: 'the drawing has to fit a budget or a pane before a reader sees it', tool: 'diagram_review' },
|
|
76
|
+
},
|
|
77
|
+
diagram_review: {
|
|
78
|
+
owns: 'the structure of one drawing against budgets: boundary width, nesting depth, node count',
|
|
79
|
+
notFor: { when: 'the diagram has to be read as a model, and its modelling judged', tool: 'uml_review' },
|
|
80
|
+
},
|
|
81
|
+
diagram_diff: {
|
|
82
|
+
owns: 'what moved between two drawings: boundaries added, removed, renamed or re-parented',
|
|
83
|
+
notFor: { when: 'two reports have to be compared rather than two drawings', tool: 'report_diff' },
|
|
84
|
+
},
|
|
73
85
|
};
|
|
74
86
|
/** The route for a tool, or undefined when the tool has none. */
|
|
75
87
|
export function routeFor(tool) {
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an annotation message looks like after it has been sent.
|
|
3
|
+
*
|
|
4
|
+
* The message is one block of plain lines: a fixed opening, numbered items, a question line, then the reader's
|
|
5
|
+
* own words. That is the right thing for the model and the wrong thing to read as a human - the reader wrote one
|
|
6
|
+
* note about one node, and what landed is a page of protocol.
|
|
7
|
+
*
|
|
8
|
+
* The host renders a user message as text inside elements it owns and re-renders. So this never assigns an
|
|
9
|
+
* element's text: that deletes its children, React reconciles from a tree that no longer matches, and whole
|
|
10
|
+
* turns come back mangled. It edits the text NODES instead - trim the run before the cut, remove the runs that
|
|
11
|
+
* held the block - and puts the chip in as a sibling, exactly the way the shipped annotation plugin does it.
|
|
12
|
+
*
|
|
13
|
+
* Everything here is defensive: a page without the elements, an element without text nodes, an element already
|
|
14
|
+
* handled. A decorator that throws would take a render pass with it.
|
|
15
|
+
*/
|
|
16
|
+
/** The first words of every annotation block, used to recognise one in the transcript. */
|
|
17
|
+
export const TRANSCRIPT_MARKER = 'Diagram annotations -';
|
|
18
|
+
/** Where the block hands over to the reader's own question. */
|
|
19
|
+
const QUESTION = 'Question:';
|
|
20
|
+
/** Marks an element the decorator has handled, and remembers the block it came from. */
|
|
21
|
+
const HANDLED = 'data-dme-annotated';
|
|
22
|
+
const ORIGINAL = 'data-dme-annotation-text';
|
|
23
|
+
/** Elements whose text can be the message. Nested containers are excluded by the descendant test below. */
|
|
24
|
+
const SELECTOR = 'div, p, span, li';
|
|
25
|
+
/**
|
|
26
|
+
* Another plugin's territory, which this one must never touch.
|
|
27
|
+
*
|
|
28
|
+
* The shipped annotation plugin marks its own nodes `data-annotation-*` and folds its own blocks by their own
|
|
29
|
+
* markers. Two plugins doing DOM work in the same transcript collide in exactly three ways: they fight over the
|
|
30
|
+
* same message element, they overwrite each other's chip, and they both react to the other's mutations. The
|
|
31
|
+
* rules this file follows, and that a future change here has to keep:
|
|
32
|
+
*
|
|
33
|
+
* 1. only our own marker starts work - `Diagram annotations -`, never a generic word like "annotation";
|
|
34
|
+
* 2. every attribute we add is `data-dme-*`, and anything already carrying `data-annotation-*` is skipped;
|
|
35
|
+
* 3. our inserted nodes carry `data-dme-own`, so a neighbouring plugin can skip them as easily as we skip its;
|
|
36
|
+
* 4. one pass over a message, marked, so a second plugin's re-render cannot make us fold the same block twice.
|
|
37
|
+
*
|
|
38
|
+
* The draft is the fourth surface: both plugins write it through the host's `setDraft`, and a blind overwrite
|
|
39
|
+
* would drop the other's block. `placeBlock` re-reads the draft immediately before writing and prepends,
|
|
40
|
+
* keeping the rest verbatim - never a replace.
|
|
41
|
+
*/
|
|
42
|
+
const FOREIGN = '[data-annotation-for-dsh], [data-annotation-overlay], [data-annotation-chip], [data-annotation-tip-layer]';
|
|
43
|
+
const BORDER = '1px solid var(--dsw-alias-border-l1, rgba(127,127,127,0.35))';
|
|
44
|
+
/** The count in the opening line, for the chip; null when the line says something unexpected. */
|
|
45
|
+
export function annotationCountOf(text) {
|
|
46
|
+
const found = /Diagram annotations - (\d+)/.exec(text);
|
|
47
|
+
return found === null ? null : Number(found[1]);
|
|
48
|
+
}
|
|
49
|
+
/** The reader's own words: what follows the question line, or nothing when the note was the whole message. */
|
|
50
|
+
export function questionOf(text) {
|
|
51
|
+
const at = text.lastIndexOf(QUESTION);
|
|
52
|
+
return at === -1 ? '' : text.slice(at + QUESTION.length).trim();
|
|
53
|
+
}
|
|
54
|
+
/** The notes the block carries, read back out of its lines, so a reader sees what the model saw. */
|
|
55
|
+
export function itemsOf(text) {
|
|
56
|
+
const items = [];
|
|
57
|
+
for (const raw of text.split(String.fromCharCode(10))) {
|
|
58
|
+
const line = raw.trim();
|
|
59
|
+
const head = /^Diagram note \[(\d+)\] node (.+)$/.exec(line);
|
|
60
|
+
if (head !== null) {
|
|
61
|
+
const rest = head[2];
|
|
62
|
+
const dash = rest.lastIndexOf(' - ');
|
|
63
|
+
items.push({ index: Number(head[1]), node: dash === -1 ? rest : rest.slice(0, dash), note: dash === -1 ? '' : rest.slice(dash + 3) });
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const note = /^Note: (.*)$/.exec(line);
|
|
67
|
+
if (note !== null && items.length > 0)
|
|
68
|
+
items[items.length - 1].note = note[1];
|
|
69
|
+
}
|
|
70
|
+
return items;
|
|
71
|
+
}
|
|
72
|
+
/** Every text node inside an element, in document order. */
|
|
73
|
+
function textNodesOf(root) {
|
|
74
|
+
const out = [];
|
|
75
|
+
const visit = (node) => {
|
|
76
|
+
const children = node.childNodes;
|
|
77
|
+
if (children === undefined || children === null || children.length === 0) {
|
|
78
|
+
if (typeof node.nodeValue === 'string')
|
|
79
|
+
out.push(node);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
for (let index = 0; index < children.length; index += 1)
|
|
83
|
+
visit(children[index]);
|
|
84
|
+
};
|
|
85
|
+
visit(root);
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Drop everything before the cut from the text nodes, and leave the reader's own words.
|
|
90
|
+
*
|
|
91
|
+
* The nodes are the only thing written: an element's `textContent` would take its child elements with it.
|
|
92
|
+
*/
|
|
93
|
+
export function cutTextNodes(nodes, cut) {
|
|
94
|
+
let position = 0;
|
|
95
|
+
let done = false;
|
|
96
|
+
for (const node of nodes) {
|
|
97
|
+
const value = typeof node.nodeValue === 'string' ? node.nodeValue : '';
|
|
98
|
+
const length = value.length;
|
|
99
|
+
if (done) {
|
|
100
|
+
position += length;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (cut <= position + length) {
|
|
104
|
+
node.nodeValue = value.slice(cut - position).replace(/^\s+/, '');
|
|
105
|
+
done = true;
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
node.nodeValue = '';
|
|
109
|
+
}
|
|
110
|
+
position += length;
|
|
111
|
+
}
|
|
112
|
+
return done;
|
|
113
|
+
}
|
|
114
|
+
/** Whether another plugin owns this node, or any of its ancestors. */
|
|
115
|
+
/**
|
|
116
|
+
* Whether this element IS another plugin's node.
|
|
117
|
+
*
|
|
118
|
+
* Only the element itself counts. An ancestor test looked safer and was wrong: that plugin marks the message
|
|
119
|
+
* container it has folded, so every message it touched also carried an ancestor marker - and this plugin then
|
|
120
|
+
* skipped the very message it was looking for.
|
|
121
|
+
*/
|
|
122
|
+
function isForeign(node) {
|
|
123
|
+
for (const name of ['data-annotation-for-dsh', 'data-annotation-overlay', 'data-annotation-chip', 'data-annotation-tip-layer']) {
|
|
124
|
+
const value = node.getAttribute?.(name);
|
|
125
|
+
if (value !== undefined && value !== null)
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
/** Kept for the tests that assert the selector exists; the ancestor walk is deliberately not used. */
|
|
131
|
+
export const FOREIGN_SELECTOR = FOREIGN;
|
|
132
|
+
function holdsAnotherBlock(node) {
|
|
133
|
+
const inner = node.querySelectorAll?.(SELECTOR);
|
|
134
|
+
if (inner === undefined || inner === null)
|
|
135
|
+
return false;
|
|
136
|
+
for (let index = 0; index < inner.length; index += 1) {
|
|
137
|
+
const text = inner[index].textContent;
|
|
138
|
+
if (typeof text === 'string' && text.indexOf(TRANSCRIPT_MARKER) !== -1)
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
/** The card listing the notes, styled from the host's tokens so it reads as part of the page. */
|
|
144
|
+
function cardFor(doc, items, title, full) {
|
|
145
|
+
const card = doc.createElement?.('div');
|
|
146
|
+
if (card === undefined)
|
|
147
|
+
return undefined;
|
|
148
|
+
styleOf(card, {
|
|
149
|
+
position: 'fixed', zIndex: '40', display: 'none', maxWidth: '360px', padding: '8px 10px', borderRadius: '10px',
|
|
150
|
+
background: 'var(--dsw-alias-bg-overlay, var(--dsw-alias-bg-layer-1, Canvas))',
|
|
151
|
+
border: BORDER, boxShadow: '0 10px 30px rgba(0,0,0,0.28)',
|
|
152
|
+
color: 'var(--dsw-alias-label-primary, CanvasText)', font: '12px system-ui, sans-serif',
|
|
153
|
+
});
|
|
154
|
+
const head = doc.createElement?.('div');
|
|
155
|
+
if (head !== undefined) {
|
|
156
|
+
head.textContent = title;
|
|
157
|
+
styleOf(head, {
|
|
158
|
+
color: 'var(--dsw-alias-label-secondary, GrayText)', paddingBottom: '6px', marginBottom: '4px',
|
|
159
|
+
borderBottom: BORDER, fontSize: '11px',
|
|
160
|
+
});
|
|
161
|
+
card.appendChild?.(head);
|
|
162
|
+
}
|
|
163
|
+
for (const item of items) {
|
|
164
|
+
const row = doc.createElement?.('div');
|
|
165
|
+
if (row === undefined)
|
|
166
|
+
continue;
|
|
167
|
+
styleOf(row, { display: 'flex', gap: '6px', alignItems: 'flex-start', padding: '3px 0' });
|
|
168
|
+
const badge = doc.createElement?.('span');
|
|
169
|
+
if (badge !== undefined) {
|
|
170
|
+
badge.textContent = String(item.index);
|
|
171
|
+
styleOf(badge, {
|
|
172
|
+
flex: '0 0 auto', width: '16px', height: '16px', lineHeight: '16px', textAlign: 'center',
|
|
173
|
+
borderRadius: '8px', fontSize: '10px',
|
|
174
|
+
background: 'var(--dsw-alias-brand-primary, #2563eb)', color: '#fff',
|
|
175
|
+
});
|
|
176
|
+
row.appendChild?.(badge);
|
|
177
|
+
}
|
|
178
|
+
const body = doc.createElement?.('div');
|
|
179
|
+
if (body !== undefined) {
|
|
180
|
+
body.textContent = item.node + (item.note === '' ? '' : String.fromCharCode(10) + item.note);
|
|
181
|
+
styleOf(body, { flex: '1 1 auto', whiteSpace: 'pre-wrap', wordBreak: 'break-word' });
|
|
182
|
+
row.appendChild?.(body);
|
|
183
|
+
}
|
|
184
|
+
card.appendChild?.(row);
|
|
185
|
+
}
|
|
186
|
+
const more = doc.createElement?.('div');
|
|
187
|
+
if (more !== undefined) {
|
|
188
|
+
more.textContent = full;
|
|
189
|
+
styleOf(more, {
|
|
190
|
+
display: 'none', marginTop: '6px', paddingTop: '6px', borderTop: BORDER, fontSize: '11px',
|
|
191
|
+
whiteSpace: 'pre-wrap', color: 'var(--dsw-alias-label-secondary, GrayText)', maxHeight: '40vh', overflow: 'auto',
|
|
192
|
+
});
|
|
193
|
+
card.appendChild?.(more);
|
|
194
|
+
}
|
|
195
|
+
return card;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Apply a style map to a real element.
|
|
199
|
+
*
|
|
200
|
+
* Assigning an object to element.style applies nothing on a real element - the browser keeps its own
|
|
201
|
+
* CSSStyleDeclaration - which is why a chip built that way rendered as plain words. Each property goes through
|
|
202
|
+
* setProperty; a fake node without one keeps the map so a test can read it back.
|
|
203
|
+
*/
|
|
204
|
+
function styleOf(node, map) {
|
|
205
|
+
const decl = node.style;
|
|
206
|
+
if (typeof decl?.setProperty === 'function') {
|
|
207
|
+
const target = decl;
|
|
208
|
+
for (const key of Object.keys(map)) {
|
|
209
|
+
const value = map[key];
|
|
210
|
+
if (value === undefined)
|
|
211
|
+
continue;
|
|
212
|
+
target.setProperty(key.replace(/[A-Z]/g, (letter) => '-' + letter.toLowerCase()), value);
|
|
213
|
+
}
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
node.style = map;
|
|
217
|
+
}
|
|
218
|
+
function chipFor(doc, label, title, text) {
|
|
219
|
+
const chip = doc.createElement?.('div');
|
|
220
|
+
if (chip === undefined)
|
|
221
|
+
return undefined;
|
|
222
|
+
chip.textContent = label;
|
|
223
|
+
chip.setAttribute?.('role', 'button');
|
|
224
|
+
// A pill that sits in the reader's line, not a block that pushes it: the conversation is theirs, and the chip
|
|
225
|
+
// is a footnote to it.
|
|
226
|
+
styleOf(chip, {
|
|
227
|
+
display: 'inline-flex', alignItems: 'center', height: '20px', padding: '0 8px', margin: '4px 0 0',
|
|
228
|
+
fontSize: '11px', lineHeight: '20px', borderRadius: '10px', verticalAlign: 'middle', userSelect: 'none',
|
|
229
|
+
border: BORDER, background: 'var(--dsw-alias-bg-layer-2, rgba(127,127,127,0.10))',
|
|
230
|
+
color: 'var(--dsw-alias-label-secondary, GrayText)', cursor: 'pointer', maxWidth: '100%',
|
|
231
|
+
});
|
|
232
|
+
const card = cardFor(doc, itemsOf(text), title, text);
|
|
233
|
+
if (card === undefined)
|
|
234
|
+
return chip;
|
|
235
|
+
const place = () => {
|
|
236
|
+
const box = chip.getBoundingClientRect?.();
|
|
237
|
+
styleOf(card, box === undefined
|
|
238
|
+
? { display: 'block' }
|
|
239
|
+
: { display: 'block', left: String(Math.max(8, box.left)) + 'px', top: String(box.top + box.height + 6) + 'px' });
|
|
240
|
+
};
|
|
241
|
+
const hide = () => { styleOf(card, { display: 'none' }); };
|
|
242
|
+
chip.addEventListener?.('mouseenter', place);
|
|
243
|
+
chip.addEventListener?.('mouseleave', hide);
|
|
244
|
+
chip.addEventListener?.('click', () => {
|
|
245
|
+
if ((card.style ?? {}).display === 'block') {
|
|
246
|
+
hide();
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
place();
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
// The card travels with the chip, so a bubble that re-renders cannot leave one behind.
|
|
253
|
+
chip.appendChild?.(card);
|
|
254
|
+
return chip;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Fold every annotation message in the transcript behind its count, and answer how many were folded.
|
|
258
|
+
*
|
|
259
|
+
* Idempotent: an element that already carries the marker is skipped, so the observer can call this on every
|
|
260
|
+
* change without stacking chips. Only the element whose text is the message is touched, and only its text nodes.
|
|
261
|
+
*/
|
|
262
|
+
export function decorateTranscript(doc, label = 'Notes', title = 'Notes in this message') {
|
|
263
|
+
if (doc === undefined || typeof doc.querySelectorAll !== 'function')
|
|
264
|
+
return 0;
|
|
265
|
+
let nodes;
|
|
266
|
+
try {
|
|
267
|
+
nodes = doc.querySelectorAll(SELECTOR);
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
return 0;
|
|
271
|
+
}
|
|
272
|
+
let folded = 0;
|
|
273
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
274
|
+
const element = nodes[index];
|
|
275
|
+
const text = typeof element.textContent === 'string' ? element.textContent : '';
|
|
276
|
+
if (!text.includes(TRANSCRIPT_MARKER))
|
|
277
|
+
continue;
|
|
278
|
+
if (element.getAttribute?.(HANDLED) === '1')
|
|
279
|
+
continue;
|
|
280
|
+
// Another plugin's node: it may be folding its own block in the same message, and two folders would take
|
|
281
|
+
// turns rewriting each other's text.
|
|
282
|
+
if (isForeign(element))
|
|
283
|
+
continue;
|
|
284
|
+
if (holdsAnotherBlock(element))
|
|
285
|
+
continue;
|
|
286
|
+
const texts = textNodesOf(element);
|
|
287
|
+
if (texts.length === 0)
|
|
288
|
+
continue;
|
|
289
|
+
const joined = texts.map((node) => (typeof node.nodeValue === 'string' ? node.nodeValue : '')).join('');
|
|
290
|
+
if (!joined.includes(TRANSCRIPT_MARKER))
|
|
291
|
+
continue;
|
|
292
|
+
const at = joined.lastIndexOf(QUESTION);
|
|
293
|
+
const cut = at === -1 ? joined.length : at + QUESTION.length;
|
|
294
|
+
const count = annotationCountOf(joined);
|
|
295
|
+
const chip = chipFor(doc, label + (count === null ? '' : ' ×' + String(count)), title, joined);
|
|
296
|
+
if (chip === undefined)
|
|
297
|
+
continue;
|
|
298
|
+
try {
|
|
299
|
+
if (!cutTextNodes(texts, cut))
|
|
300
|
+
continue;
|
|
301
|
+
element.setAttribute?.(ORIGINAL, joined);
|
|
302
|
+
element.setAttribute?.(HANDLED, '1');
|
|
303
|
+
const parent = element.parentNode;
|
|
304
|
+
if (parent?.insertBefore !== undefined)
|
|
305
|
+
parent.insertBefore(chip, element.nextSibling ?? null);
|
|
306
|
+
else
|
|
307
|
+
element.appendChild?.(chip);
|
|
308
|
+
folded += 1;
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
// A page that refuses the write keeps the plain message, which reads on its own.
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return folded;
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* One pass, and nothing that keeps running.
|
|
318
|
+
*
|
|
319
|
+
* There is deliberately no observer and no timer here. Both were tried: an observer over the document plus a
|
|
320
|
+
* one-second interval turns every DOM change into a full selector scan, and a streaming conversation changes the
|
|
321
|
+
* DOM many times a second - the container stopped responding. A caller that wants the fold has to decide when
|
|
322
|
+
* to run it, on a container it chose.
|
|
323
|
+
*/
|
|
324
|
+
export function startTranscriptDecoration(doc, label = 'Notes', title = 'Notes in this message') {
|
|
325
|
+
decorateTranscript(doc, label, title);
|
|
326
|
+
return () => { };
|
|
327
|
+
}
|