@north-light/crouter 0.3.192 → 0.3.194
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/dist/api/client.d.ts +31 -10
- package/dist/api/client.js +83 -24
- package/dist/api/dto/bash-jobs.d.ts +24 -0
- package/dist/api/dto/bash-jobs.js +9 -0
- package/dist/api/dto/human.d.ts +3 -4
- package/dist/api/dto/inbox.d.ts +106 -65
- package/dist/api/dto/inbox.js +2 -8
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.js +1 -0
- package/dist/api/routes.d.ts +7 -0
- package/dist/api/routes.js +7 -0
- package/dist/builtin-memory/02-lifecycle/01-resident.md +1 -1
- package/dist/builtin-memory/internal/examples/imessage-assistant.md +1 -1
- package/dist/builtin-memory/internal/nodes-and-canvas.md +2 -2
- package/dist/builtin-memory/internal/storage-tiers.md +1 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/serial/provider-rotation.test.ts +101 -3
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.d.ts +6 -2
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +68 -14
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +87 -13
- package/dist/clients/attach/__tests__/chat-view-snapshot-ordering.test.js +2 -2
- package/dist/clients/attach/__tests__/group-activity.test.js +2 -2
- package/dist/clients/attach/chrome/canvas-panels.d.ts +1 -1
- package/dist/clients/attach/chrome/canvas-panels.js +1 -1
- package/dist/clients/attach/input/overlay-owner.js +1 -1
- package/dist/clients/attach/overlays/auth.js +34 -8
- package/dist/clients/attach/overlays/pickers.d.ts +5 -8
- package/dist/clients/attach/overlays/pickers.js +37 -43
- package/dist/clients/attach/viewer.js +643 -620
- package/dist/clients/inbox/__tests__/serial/inbox-controller.test.js +38 -128
- package/dist/clients/inbox/__tests__/serial/mount-panel.test.js +72 -148
- package/dist/clients/inbox/controller.d.ts +2 -3
- package/dist/clients/inbox/controller.js +41 -42
- package/dist/clients/inbox/page-adapter.d.ts +33 -0
- package/dist/clients/inbox/page-adapter.js +54 -0
- package/dist/clients/inbox/resolve.d.ts +2 -2
- package/dist/clients/inbox/resolve.js +1 -1
- package/dist/clients/inbox/review-visibility.d.ts +1 -1
- package/dist/clients/inbox/review-visibility.js +1 -1
- package/dist/clients/inbox/tui/ansi.d.ts +1 -1
- package/dist/clients/inbox/tui/ansi.js +2 -2
- package/dist/clients/inbox/tui/input.d.ts +1 -3
- package/dist/clients/inbox/tui/input.js +242 -553
- package/dist/clients/inbox/tui/panel.d.ts +3 -1
- package/dist/clients/inbox/tui/panel.js +59 -187
- package/dist/clients/inbox/tui/render.d.ts +0 -25
- package/dist/clients/inbox/tui/render.js +165 -416
- package/dist/clients/inbox/tui/slots.d.ts +32 -0
- package/dist/clients/inbox/tui/slots.js +156 -0
- package/dist/clients/inbox/tui/types.d.ts +25 -13
- package/dist/clients/inbox/tui.js +5 -3
- package/dist/commands/attention.js +2 -2
- package/dist/commands/human/doc.js +66 -20
- package/dist/commands/human/prompts.d.ts +3 -3
- package/dist/commands/human/prompts.js +158 -112
- package/dist/commands/human/queue.d.ts +0 -1
- package/dist/commands/human/queue.js +34 -94
- package/dist/commands/human/shared.d.ts +10 -4
- package/dist/commands/human/shared.js +62 -13
- package/dist/commands/human.js +8 -4
- package/dist/commands/memory/read.js +13 -5
- package/dist/commands/node/bash.js +18 -30
- package/dist/commands/node/lifecycle.js +2 -2
- package/dist/commands/surface/node/focus.js +1 -1
- package/dist/core/__tests__/human-deliver.test.js +40 -46
- package/dist/core/__tests__/seam/broker-attach-stream.test.js +7 -1
- package/dist/core/__tests__/seam/dormancy-release.test.js +64 -10
- package/dist/core/__tests__/serial/human-deliver-e2e.test.js +40 -31
- package/dist/core/__tests__/serial/spawn-root.test.js +3 -1
- package/dist/core/__tests__/spawn-worktree-id-conflict.test.js +3 -1
- package/dist/core/auth-interaction.d.ts +2 -2
- package/dist/core/bash-jobs.d.ts +43 -0
- package/dist/core/bash-jobs.js +96 -2
- package/dist/core/canvas/__tests__/attention.test.js +16 -10
- package/dist/core/canvas/__tests__/render-remote.test.js +9 -3
- package/dist/core/canvas/attention.js +1 -1
- package/dist/core/canvas/browse/app.js +1 -1
- package/dist/core/canvas/nav-model.d.ts +1 -1
- package/dist/core/canvas/nav-model.js +1 -1
- package/dist/core/canvas/remote-canvas-source.js +1 -1
- package/dist/core/canvas/types.d.ts +2 -1
- package/dist/core/config.js +4 -1
- package/dist/core/human/__tests__/page-render.test.d.ts +1 -0
- package/dist/core/human/__tests__/page-render.test.js +49 -0
- package/dist/core/human/__tests__/page-tickets.test.d.ts +1 -0
- package/dist/core/human/__tests__/page-tickets.test.js +112 -0
- package/dist/core/human/__tests__/page.test.d.ts +1 -0
- package/dist/core/human/__tests__/page.test.js +139 -0
- package/dist/core/human/__tests__/serial/inbox-core.test.js +42 -32
- package/dist/core/human/claim.js +2 -2
- package/dist/core/human/convention.d.ts +4 -4
- package/dist/core/human/convention.js +6 -13
- package/dist/core/human/markdown-html.d.ts +8 -0
- package/dist/core/human/markdown-html.js +581 -0
- package/dist/core/human/page-catalog.d.ts +6 -0
- package/dist/core/human/page-catalog.js +43 -0
- package/dist/core/human/page-render.d.ts +3 -0
- package/dist/core/human/page-render.js +131 -0
- package/dist/core/human/page-schema.d.ts +312 -0
- package/dist/core/human/page-schema.js +291 -0
- package/dist/core/human/page-synth.d.ts +9 -0
- package/dist/core/human/page-synth.js +17 -0
- package/dist/core/human/page.d.ts +22 -0
- package/dist/core/human/page.js +427 -0
- package/dist/core/human/review-schema.d.ts +19 -0
- package/dist/core/human/review-schema.js +34 -0
- package/dist/core/human/scan.d.ts +12 -3
- package/dist/core/human/scan.js +69 -26
- package/dist/core/human/summary.d.ts +5 -6
- package/dist/core/human/summary.js +54 -37
- package/dist/core/human/tickets.d.ts +61 -11
- package/dist/core/human/tickets.js +100 -57
- package/dist/core/human/types.d.ts +21 -66
- package/dist/core/human/types.js +1 -5
- package/dist/core/inspector/model.d.ts +5 -0
- package/dist/core/inspector/model.js +5 -0
- package/dist/core/inspector/text.js +2 -2
- package/dist/core/inspector/tui.js +10 -3
- package/dist/core/keybindings/catalog.d.ts +1 -1
- package/dist/core/keybindings/catalog.js +4 -2
- package/dist/core/preview-registry.js +0 -1
- package/dist/core/review/ticket-filter.d.ts +1 -1
- package/dist/core/review/ticket-filter.js +2 -8
- package/dist/core/runtime/broker/read-ops.js +3 -0
- package/dist/core/runtime/broker.js +19 -10
- package/dist/core/runtime/package-health.js +1 -1
- package/dist/core/runtime/persona.js +2 -1
- package/dist/core/runtime/tmux-bindings.js +8 -1
- package/dist/core/termrender/termrender.d.ts +1 -1
- package/dist/core/termrender/termrender.js +2 -2
- package/dist/core/termrender/version.d.ts +1 -1
- package/dist/core/termrender/version.js +1 -1
- package/dist/core/tui/page-host.d.ts +1 -1
- package/dist/core/tui/page-host.js +2 -2
- package/dist/core/user-settings.d.ts +19 -0
- package/dist/core/user-settings.js +22 -0
- package/dist/daemon/api/handlers/bash-jobs.d.ts +2 -0
- package/dist/daemon/api/handlers/bash-jobs.js +90 -0
- package/dist/daemon/api/handlers/human.js +16 -14
- package/dist/daemon/api/handlers/inbox.js +311 -227
- package/dist/daemon/api/router.d.ts +5 -1
- package/dist/daemon/api/router.js +5 -0
- package/dist/daemon/api/server.js +2 -0
- package/dist/daemon/cron-run.js +23 -10
- package/dist/daemon/human/finish.d.ts +8 -3
- package/dist/daemon/human/finish.js +35 -15
- package/dist/daemon/reconcilers/broker-supervision.js +7 -1
- package/dist/daemon/reconcilers/dormant-inbox.js +8 -0
- package/dist/daemon/reconcilers/live-obligation.d.ts +2 -0
- package/dist/daemon/reconcilers/live-obligation.js +11 -0
- package/dist/pages/bundle.css +1 -0
- package/dist/pages/bundle.js +1347 -0
- package/dist/pages/comments.d.ts +34 -0
- package/dist/pages/comments.js +72 -0
- package/dist/pages/elements/cards.d.ts +17 -0
- package/dist/pages/elements/cards.js +785 -0
- package/dist/pages/elements/chart.d.ts +1 -0
- package/dist/pages/elements/chart.js +648 -0
- package/dist/pages/elements/options.d.ts +21 -0
- package/dist/pages/elements/options.js +604 -0
- package/dist/pages/elements/pages.d.ts +24 -0
- package/dist/pages/elements/pages.js +355 -0
- package/dist/pages/elements/slot.d.ts +21 -0
- package/dist/pages/elements/slot.js +105 -0
- package/dist/pages/elements/table.d.ts +19 -0
- package/dist/pages/elements/table.js +906 -0
- package/dist/pages/elements/text.d.ts +25 -0
- package/dist/pages/elements/text.js +951 -0
- package/dist/pages/entry.d.ts +8 -0
- package/dist/pages/entry.js +8 -0
- package/dist/pages/host.d.ts +132 -0
- package/dist/pages/host.js +149 -0
- package/dist/pages/register.d.ts +2 -0
- package/dist/pages/register.js +3 -0
- package/dist/pages/slot-config.d.ts +36 -0
- package/dist/pages/slot-config.js +50 -0
- package/dist/pages/types.d.ts +98 -0
- package/dist/pages/types.js +23 -0
- package/dist/pi-extensions/canvas-bash-valve.d.ts +4 -2
- package/dist/pi-extensions/canvas-bash-valve.js +25 -7
- package/dist/types.d.ts +4 -0
- package/dist/types.js +2 -0
- package/package.json +8 -8
- package/runtime.lock.json +156 -101
- package/scripts/install-runtime.mjs +172 -5
- package/dist/clients/inbox/deck-adapter.d.ts +0 -29
- package/dist/clients/inbox/deck-adapter.js +0 -63
- package/dist/core/human/deck-factories.d.ts +0 -8
- package/dist/core/human/deck-factories.js +0 -15
- package/dist/core/human/deck-schema.d.ts +0 -70
- package/dist/core/human/deck-schema.js +0 -92
|
@@ -0,0 +1,951 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<crtr-text>` — the universal primitive: prose the agent puts in front of the user, which
|
|
3
|
+
* the user can rewrite, annotate, or both.
|
|
4
|
+
*
|
|
5
|
+
* The three shapes in the catalog are all this one element:
|
|
6
|
+
*
|
|
7
|
+
* - a **draft** the user edits in place — `editable`, pre-filled;
|
|
8
|
+
* - **read** text the user leaves anchored comments on, review-style — select a range and
|
|
9
|
+
* the comment affordance appears at the selection;
|
|
10
|
+
* - an **empty input** — `editable` with no starting text, which opens already editing.
|
|
11
|
+
*
|
|
12
|
+
* Text is always commentable; that is not a capability switch, and edit and comment coexist.
|
|
13
|
+
* Reading is the resting state, because that is where anchored commenting lives; the edit
|
|
14
|
+
* toggle swaps the block for a plain textarea. Comments survive the switch, but a range
|
|
15
|
+
* anchor written before an edit can end up pointing at moved text — which is why every range
|
|
16
|
+
* carries the `quote` it was written against.
|
|
17
|
+
*
|
|
18
|
+
* `edited` means *differs*, not *was touched*: returning the text to what the agent wrote
|
|
19
|
+
* un-sets the flag.
|
|
20
|
+
*
|
|
21
|
+
* Ported from the reviewed interaction kit (`apps/gateway/components/agent-ui/agent-text.tsx`
|
|
22
|
+
* and `comment-affordance.tsx` @ `3705fe9e2`), framework-free and styled only through the
|
|
23
|
+
* bundle's `--crtr-*` tokens.
|
|
24
|
+
*/
|
|
25
|
+
import { registerElement } from '../register.js';
|
|
26
|
+
import { readSlotConfig, readSlotId } from '../slot-config.js';
|
|
27
|
+
import { respond } from '../host.js';
|
|
28
|
+
import { findComment, setComment } from '../comments.js';
|
|
29
|
+
const WHOLE = { kind: 'whole' };
|
|
30
|
+
const WHOLE_TARGET = 'the whole passage';
|
|
31
|
+
const RANGE_TARGET = 'the selected text';
|
|
32
|
+
const POPOVER_GAP = 6;
|
|
33
|
+
const POPOVER_ID = 'comment-popover';
|
|
34
|
+
const STYLE = `
|
|
35
|
+
:host {
|
|
36
|
+
display: block;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
[part="container"] {
|
|
40
|
+
position: relative;
|
|
41
|
+
display: grid;
|
|
42
|
+
gap: var(--crtr-space-2);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
[part="toolbar"] {
|
|
46
|
+
display: flex;
|
|
47
|
+
flex-wrap: wrap;
|
|
48
|
+
align-items: center;
|
|
49
|
+
justify-content: flex-end;
|
|
50
|
+
gap: var(--crtr-space-2);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
[part="edited"] {
|
|
54
|
+
padding: 0 var(--crtr-space-2);
|
|
55
|
+
color: var(--crtr-color-accent);
|
|
56
|
+
background: color-mix(in srgb, var(--crtr-color-accent) 14%, transparent);
|
|
57
|
+
border-radius: var(--crtr-radius-small);
|
|
58
|
+
font-family: var(--crtr-font-mono);
|
|
59
|
+
font-size: 0.6875rem;
|
|
60
|
+
letter-spacing: 0.06em;
|
|
61
|
+
line-height: 1.7;
|
|
62
|
+
text-transform: uppercase;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
button {
|
|
66
|
+
display: inline-flex;
|
|
67
|
+
align-items: center;
|
|
68
|
+
gap: var(--crtr-space-1);
|
|
69
|
+
max-width: 100%;
|
|
70
|
+
padding: var(--crtr-space-1) var(--crtr-space-2);
|
|
71
|
+
color: var(--crtr-color-text-muted);
|
|
72
|
+
font: inherit;
|
|
73
|
+
background: transparent;
|
|
74
|
+
border: 1px solid transparent;
|
|
75
|
+
border-radius: var(--crtr-radius-small);
|
|
76
|
+
cursor: pointer;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
button:hover {
|
|
80
|
+
color: var(--crtr-color-text);
|
|
81
|
+
background: var(--crtr-color-surface-hover);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
button:focus-visible {
|
|
85
|
+
outline: 2px solid var(--crtr-color-focus);
|
|
86
|
+
outline-offset: 2px;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
button[data-state="on"] {
|
|
90
|
+
color: var(--crtr-color-accent);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
svg {
|
|
94
|
+
width: 0.875rem;
|
|
95
|
+
height: 0.875rem;
|
|
96
|
+
flex: none;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
[part="editor"] {
|
|
100
|
+
display: block;
|
|
101
|
+
box-sizing: border-box;
|
|
102
|
+
width: 100%;
|
|
103
|
+
min-height: 10rem;
|
|
104
|
+
padding: var(--crtr-space-3) var(--crtr-space-4);
|
|
105
|
+
color: var(--crtr-color-text);
|
|
106
|
+
font: inherit;
|
|
107
|
+
line-height: var(--crtr-line-height);
|
|
108
|
+
background: var(--crtr-color-background);
|
|
109
|
+
border: 1px solid var(--crtr-color-border);
|
|
110
|
+
border-radius: var(--crtr-radius-medium);
|
|
111
|
+
resize: vertical;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
[part="editor"]:focus-visible,
|
|
115
|
+
[part="popover-input"]:focus-visible {
|
|
116
|
+
outline: 2px solid var(--crtr-color-focus);
|
|
117
|
+
outline-offset: -1px;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
[part="body"] {
|
|
121
|
+
position: relative;
|
|
122
|
+
padding: var(--crtr-space-3) var(--crtr-space-4);
|
|
123
|
+
background: var(--crtr-color-surface);
|
|
124
|
+
border: 1px solid var(--crtr-color-border);
|
|
125
|
+
border-radius: var(--crtr-radius-medium);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
[part="body"]:focus-visible {
|
|
129
|
+
outline: 2px solid var(--crtr-color-focus);
|
|
130
|
+
outline-offset: 2px;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
[part="prose"] {
|
|
134
|
+
display: grid;
|
|
135
|
+
gap: var(--crtr-space-3);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
[part="prose"] p {
|
|
139
|
+
margin: 0;
|
|
140
|
+
white-space: pre-wrap;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
[part="placeholder"] {
|
|
144
|
+
color: var(--crtr-color-text-muted);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
[part~="commented"] {
|
|
148
|
+
position: relative;
|
|
149
|
+
border-radius: var(--crtr-radius-small);
|
|
150
|
+
background: color-mix(in srgb, var(--crtr-color-accent) 18%, transparent);
|
|
151
|
+
text-decoration: underline dotted;
|
|
152
|
+
text-decoration-color: var(--crtr-color-accent);
|
|
153
|
+
text-underline-offset: 4px;
|
|
154
|
+
cursor: help;
|
|
155
|
+
outline: none;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
[part~="commented"]:focus-visible {
|
|
159
|
+
outline: 2px solid var(--crtr-color-focus);
|
|
160
|
+
outline-offset: 2px;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
[part="comment-bubble"] {
|
|
164
|
+
position: absolute;
|
|
165
|
+
bottom: calc(100% + var(--crtr-space-1));
|
|
166
|
+
left: 0;
|
|
167
|
+
z-index: 2;
|
|
168
|
+
display: grid;
|
|
169
|
+
gap: var(--crtr-space-1);
|
|
170
|
+
width: max-content;
|
|
171
|
+
max-width: 18rem;
|
|
172
|
+
padding: var(--crtr-space-2) var(--crtr-space-3);
|
|
173
|
+
color: var(--crtr-color-text);
|
|
174
|
+
background: var(--crtr-color-background);
|
|
175
|
+
border: 1px solid var(--crtr-color-border);
|
|
176
|
+
border-radius: var(--crtr-radius-medium);
|
|
177
|
+
box-shadow: var(--crtr-shadow);
|
|
178
|
+
text-decoration: none;
|
|
179
|
+
white-space: normal;
|
|
180
|
+
user-select: none;
|
|
181
|
+
opacity: 0;
|
|
182
|
+
visibility: hidden;
|
|
183
|
+
transition: opacity 120ms ease 120ms, visibility 0s linear 240ms;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
[part~="commented"]:hover > [part="comment-bubble"],
|
|
187
|
+
[part~="commented"]:focus > [part="comment-bubble"] {
|
|
188
|
+
opacity: 1;
|
|
189
|
+
visibility: visible;
|
|
190
|
+
transition: opacity 120ms ease 220ms, visibility 0s linear 220ms;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
[part="bubble-label"],
|
|
194
|
+
[part="comment-count"],
|
|
195
|
+
[part="popover-subject"],
|
|
196
|
+
[part="popover-hint"] {
|
|
197
|
+
color: var(--crtr-color-text-muted);
|
|
198
|
+
font-family: var(--crtr-font-mono);
|
|
199
|
+
font-size: 0.6875rem;
|
|
200
|
+
letter-spacing: 0.06em;
|
|
201
|
+
text-transform: uppercase;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
[part="bubble-label"] {
|
|
205
|
+
color: var(--crtr-color-accent);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
[part~="selection-comment"] {
|
|
209
|
+
position: absolute;
|
|
210
|
+
z-index: 3;
|
|
211
|
+
color: var(--crtr-color-accent);
|
|
212
|
+
background: var(--crtr-color-background);
|
|
213
|
+
border-color: color-mix(in srgb, var(--crtr-color-accent) 40%, transparent);
|
|
214
|
+
box-shadow: var(--crtr-shadow);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
[part="comment-rail"] {
|
|
218
|
+
display: grid;
|
|
219
|
+
gap: var(--crtr-space-1);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
[part="comment-chips"] {
|
|
223
|
+
display: flex;
|
|
224
|
+
flex-wrap: wrap;
|
|
225
|
+
gap: var(--crtr-space-2);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
[part~="comment-chip"] {
|
|
229
|
+
color: var(--crtr-color-text);
|
|
230
|
+
border-color: var(--crtr-color-border);
|
|
231
|
+
text-align: left;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
[part~="comment-chip"] > span {
|
|
235
|
+
overflow: hidden;
|
|
236
|
+
white-space: nowrap;
|
|
237
|
+
text-overflow: ellipsis;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
[part="popover"] {
|
|
241
|
+
position: absolute;
|
|
242
|
+
z-index: 5;
|
|
243
|
+
box-sizing: border-box;
|
|
244
|
+
display: grid;
|
|
245
|
+
gap: var(--crtr-space-2);
|
|
246
|
+
width: 20rem;
|
|
247
|
+
max-width: 100%;
|
|
248
|
+
padding: var(--crtr-space-3);
|
|
249
|
+
background: var(--crtr-color-background);
|
|
250
|
+
border: 1px solid var(--crtr-color-border);
|
|
251
|
+
border-radius: var(--crtr-radius-medium);
|
|
252
|
+
box-shadow: var(--crtr-shadow);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
[part="popover"][hidden],
|
|
256
|
+
[part="edited"][hidden],
|
|
257
|
+
[part="comment-rail"][hidden],
|
|
258
|
+
[part~="selection-comment"][hidden],
|
|
259
|
+
[part="popover-quote"][hidden],
|
|
260
|
+
[part="edit-toggle"][hidden] {
|
|
261
|
+
display: none;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
[part="popover-subject"],
|
|
265
|
+
[part="popover-quote"] {
|
|
266
|
+
margin: 0;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
[part="popover-quote"] {
|
|
270
|
+
padding: var(--crtr-space-1) var(--crtr-space-2);
|
|
271
|
+
color: var(--crtr-color-text-muted);
|
|
272
|
+
background: color-mix(in srgb, var(--crtr-color-accent) 8%, transparent);
|
|
273
|
+
border-left: 2px solid color-mix(in srgb, var(--crtr-color-accent) 40%, transparent);
|
|
274
|
+
white-space: pre-wrap;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
[part="popover-input"] {
|
|
278
|
+
box-sizing: border-box;
|
|
279
|
+
width: 100%;
|
|
280
|
+
min-height: 5rem;
|
|
281
|
+
padding: var(--crtr-space-2);
|
|
282
|
+
color: var(--crtr-color-text);
|
|
283
|
+
font: inherit;
|
|
284
|
+
background: var(--crtr-color-surface);
|
|
285
|
+
border: 1px solid var(--crtr-color-border);
|
|
286
|
+
border-radius: var(--crtr-radius-small);
|
|
287
|
+
resize: vertical;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
[part="popover-actions"] {
|
|
291
|
+
display: flex;
|
|
292
|
+
align-items: center;
|
|
293
|
+
justify-content: space-between;
|
|
294
|
+
gap: var(--crtr-space-2);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
[part="popover-buttons"] {
|
|
298
|
+
display: flex;
|
|
299
|
+
gap: var(--crtr-space-2);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
[part="popover-save"] {
|
|
303
|
+
color: var(--crtr-color-accent-contrast);
|
|
304
|
+
background: var(--crtr-color-accent);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
[part="popover-save"]:hover {
|
|
308
|
+
color: var(--crtr-color-accent-contrast);
|
|
309
|
+
background: var(--crtr-color-accent);
|
|
310
|
+
opacity: 0.9;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
[part="popover-cancel"] {
|
|
314
|
+
border-color: var(--crtr-color-border);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
[part="unavailable"] {
|
|
318
|
+
padding: var(--crtr-space-3);
|
|
319
|
+
color: var(--crtr-color-text-muted);
|
|
320
|
+
background: var(--crtr-color-surface);
|
|
321
|
+
border: 1px solid var(--crtr-color-border);
|
|
322
|
+
border-radius: var(--crtr-radius-medium);
|
|
323
|
+
box-shadow: var(--crtr-shadow);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
[part="reason"] {
|
|
327
|
+
color: var(--crtr-color-text);
|
|
328
|
+
font-family: var(--crtr-font-mono);
|
|
329
|
+
}
|
|
330
|
+
`;
|
|
331
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
332
|
+
const BUBBLE = 'M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z';
|
|
333
|
+
const ICON_COMMENT_ADD = [BUBBLE, 'M12 7v6', 'M9 10h6'];
|
|
334
|
+
const ICON_COMMENT = [BUBBLE, 'M13 8H7', 'M17 12H7'];
|
|
335
|
+
const ICON_EDIT = [
|
|
336
|
+
'M12 20h9',
|
|
337
|
+
'M16.376 3.622a1 1 0 0 1 3.002 3.002L7.368 18.635a2 2 0 0 1-.855.506l-2.872.838a.5.5 0 0 1-.62-.62l.838-2.872a2 2 0 0 1 .506-.854z',
|
|
338
|
+
'm15 5 3 3',
|
|
339
|
+
];
|
|
340
|
+
const ICON_DONE = ['M20 6 9 17l-5-5'];
|
|
341
|
+
function icon(paths) {
|
|
342
|
+
const svg = document.createElementNS(SVG_NS, 'svg');
|
|
343
|
+
svg.setAttribute('viewBox', '0 0 24 24');
|
|
344
|
+
svg.setAttribute('fill', 'none');
|
|
345
|
+
svg.setAttribute('stroke', 'currentColor');
|
|
346
|
+
svg.setAttribute('stroke-width', '2');
|
|
347
|
+
svg.setAttribute('stroke-linecap', 'round');
|
|
348
|
+
svg.setAttribute('stroke-linejoin', 'round');
|
|
349
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
350
|
+
for (const d of paths) {
|
|
351
|
+
const path = document.createElementNS(SVG_NS, 'path');
|
|
352
|
+
path.setAttribute('d', d);
|
|
353
|
+
svg.append(path);
|
|
354
|
+
}
|
|
355
|
+
return svg;
|
|
356
|
+
}
|
|
357
|
+
/** Paragraphs with their character offsets into the whole text preserved. */
|
|
358
|
+
function splitParagraphs(text) {
|
|
359
|
+
const out = [];
|
|
360
|
+
const separator = /\n{2,}/g;
|
|
361
|
+
let cursor = 0;
|
|
362
|
+
let match;
|
|
363
|
+
while ((match = separator.exec(text)) !== null) {
|
|
364
|
+
out.push({ start: cursor, text: text.slice(cursor, match.index) });
|
|
365
|
+
cursor = match.index + match[0].length;
|
|
366
|
+
}
|
|
367
|
+
out.push({ start: cursor, text: text.slice(cursor) });
|
|
368
|
+
return out.filter((paragraph) => paragraph.text.trim().length > 0);
|
|
369
|
+
}
|
|
370
|
+
/** Splits one paragraph at its commented ranges so they can be painted. */
|
|
371
|
+
function segmentParagraph(paragraph, comments) {
|
|
372
|
+
const anchored = comments
|
|
373
|
+
.filter((comment) => comment.anchor.kind === 'range')
|
|
374
|
+
.filter((comment) => comment.anchor.end > paragraph.start && comment.anchor.start < paragraph.start + paragraph.text.length)
|
|
375
|
+
.sort((a, b) => a.anchor.start - b.anchor.start);
|
|
376
|
+
const segments = [];
|
|
377
|
+
let cursor = 0;
|
|
378
|
+
for (const comment of anchored) {
|
|
379
|
+
const start = Math.max(cursor, comment.anchor.start - paragraph.start);
|
|
380
|
+
const end = Math.min(paragraph.text.length, comment.anchor.end - paragraph.start);
|
|
381
|
+
if (end <= start)
|
|
382
|
+
continue; // fully overlapped by an earlier comment
|
|
383
|
+
if (start > cursor)
|
|
384
|
+
segments.push({ text: paragraph.text.slice(cursor, start) });
|
|
385
|
+
segments.push({ text: paragraph.text.slice(start, end), comment });
|
|
386
|
+
cursor = end;
|
|
387
|
+
}
|
|
388
|
+
if (cursor < paragraph.text.length)
|
|
389
|
+
segments.push({ text: paragraph.text.slice(cursor) });
|
|
390
|
+
return segments;
|
|
391
|
+
}
|
|
392
|
+
function closestParagraph(node) {
|
|
393
|
+
const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
|
|
394
|
+
return element?.closest('[data-paragraph]') ?? null;
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Characters of `root` that precede the (node, offset) boundary. Comment bubbles live inside
|
|
398
|
+
* their highlighted range for positioning, but are UI rather than passage text, so cloned
|
|
399
|
+
* bubble nodes are removed before measuring. This keeps later anchors aligned to `value.text`
|
|
400
|
+
* after an earlier range has already been commented.
|
|
401
|
+
*/
|
|
402
|
+
function offsetWithin(root, node, offset) {
|
|
403
|
+
const range = document.createRange();
|
|
404
|
+
range.selectNodeContents(root);
|
|
405
|
+
range.setEnd(node, offset);
|
|
406
|
+
const contents = range.cloneContents();
|
|
407
|
+
for (const bubble of contents.querySelectorAll('[part="comment-bubble"]'))
|
|
408
|
+
bubble.remove();
|
|
409
|
+
return contents.textContent?.length ?? 0;
|
|
410
|
+
}
|
|
411
|
+
function passageLength(root) {
|
|
412
|
+
const contents = root.cloneNode(true);
|
|
413
|
+
for (const bubble of contents.querySelectorAll('[part="comment-bubble"]'))
|
|
414
|
+
bubble.remove();
|
|
415
|
+
return contents.textContent?.length ?? 0;
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Normalizes a browser Selection boundary to one of the rendered paragraph text ranges.
|
|
419
|
+
* A whole-paragraph selection commonly ends at character 0 of the NEXT `<p>` (or at the next
|
|
420
|
+
* child boundary of their shared wrapper), despite selecting no text there. For an end
|
|
421
|
+
* boundary, that belongs to the previous paragraph.
|
|
422
|
+
*/
|
|
423
|
+
function paragraphBoundary(body, node, offset, edge) {
|
|
424
|
+
const paragraphs = Array.from(body.querySelectorAll('[data-paragraph]'));
|
|
425
|
+
const direct = closestParagraph(node);
|
|
426
|
+
if (direct) {
|
|
427
|
+
if (edge === 'end' && offset === 0) {
|
|
428
|
+
const index = paragraphs.indexOf(direct);
|
|
429
|
+
const previous = paragraphs[index - 1];
|
|
430
|
+
if (previous)
|
|
431
|
+
return { paragraph: previous, offset: passageLength(previous) };
|
|
432
|
+
}
|
|
433
|
+
return { paragraph: direct, offset: offsetWithin(direct, node, offset) };
|
|
434
|
+
}
|
|
435
|
+
// The range can also land on the wrapper between paragraph elements. Locate the first
|
|
436
|
+
// paragraph at/after that caret for a start, or the last paragraph before it for an end.
|
|
437
|
+
const boundary = document.createRange();
|
|
438
|
+
boundary.setStart(node, offset);
|
|
439
|
+
boundary.collapse(true);
|
|
440
|
+
const ordered = edge === 'start' ? paragraphs : [...paragraphs].reverse();
|
|
441
|
+
for (const paragraph of ordered) {
|
|
442
|
+
const contents = document.createRange();
|
|
443
|
+
contents.selectNodeContents(paragraph);
|
|
444
|
+
const relation = boundary.compareBoundaryPoints(edge === 'start' ? Range.START_TO_START : Range.START_TO_END, contents);
|
|
445
|
+
if ((edge === 'start' && relation <= 0) || (edge === 'end' && relation >= 0)) {
|
|
446
|
+
return { paragraph, offset: edge === 'start' ? 0 : passageLength(paragraph) };
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return null;
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* The live selection that covers this element's prose. A selection made inside a shadow root
|
|
453
|
+
* is reported by `ShadowRoot.getSelection()` where that exists and by the document selection
|
|
454
|
+
* elsewhere, so both are consulted and the containment check decides.
|
|
455
|
+
*/
|
|
456
|
+
function selectionRange(root, body) {
|
|
457
|
+
const scoped = root;
|
|
458
|
+
const candidates = [
|
|
459
|
+
typeof scoped.getSelection === 'function' ? scoped.getSelection() : null,
|
|
460
|
+
document.getSelection(),
|
|
461
|
+
];
|
|
462
|
+
for (const selection of candidates) {
|
|
463
|
+
if (!selection || selection.isCollapsed || selection.rangeCount === 0)
|
|
464
|
+
continue;
|
|
465
|
+
const range = selection.getRangeAt(0);
|
|
466
|
+
if (body.contains(range.startContainer) && body.contains(range.endContainer))
|
|
467
|
+
return range;
|
|
468
|
+
}
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Every node that opens the comment popover carries the popover's own semantics: the kit's
|
|
473
|
+
* Radix trigger supplied these, and a port that drops them leaves a dialog no assistive
|
|
474
|
+
* technology can find from the control that opens it.
|
|
475
|
+
*/
|
|
476
|
+
function markTrigger(element) {
|
|
477
|
+
element.setAttribute('aria-haspopup', 'dialog');
|
|
478
|
+
element.setAttribute('aria-controls', POPOVER_ID);
|
|
479
|
+
element.setAttribute('aria-expanded', 'false');
|
|
480
|
+
}
|
|
481
|
+
class CrtrTextElement extends HTMLElement {
|
|
482
|
+
#root;
|
|
483
|
+
#container = document.createElement('div');
|
|
484
|
+
#toolbar = document.createElement('div');
|
|
485
|
+
#editedBadge = document.createElement('span');
|
|
486
|
+
#wholeComment = document.createElement('button');
|
|
487
|
+
#editToggle = document.createElement('button');
|
|
488
|
+
#editLabel = document.createElement('span');
|
|
489
|
+
#surface = document.createElement('div');
|
|
490
|
+
#editor = document.createElement('textarea');
|
|
491
|
+
#body = document.createElement('div');
|
|
492
|
+
#prose = document.createElement('div');
|
|
493
|
+
#selectionComment = document.createElement('button');
|
|
494
|
+
#rail = document.createElement('div');
|
|
495
|
+
#railCount = document.createElement('span');
|
|
496
|
+
#railChips = document.createElement('div');
|
|
497
|
+
#popover = document.createElement('div');
|
|
498
|
+
#popoverSubject = document.createElement('p');
|
|
499
|
+
#popoverQuote = document.createElement('blockquote');
|
|
500
|
+
#popoverInput = document.createElement('textarea');
|
|
501
|
+
#popoverHint = document.createElement('span');
|
|
502
|
+
#popoverSave = document.createElement('button');
|
|
503
|
+
#popoverCancel = document.createElement('button');
|
|
504
|
+
#config = { initialText: '' };
|
|
505
|
+
#slotId;
|
|
506
|
+
#text = '';
|
|
507
|
+
#comments = [];
|
|
508
|
+
#editing = false;
|
|
509
|
+
#live = false;
|
|
510
|
+
#draft = null;
|
|
511
|
+
#open = null;
|
|
512
|
+
constructor() {
|
|
513
|
+
super();
|
|
514
|
+
this.#root = this.attachShadow({ mode: 'open' });
|
|
515
|
+
const style = document.createElement('style');
|
|
516
|
+
style.textContent = STYLE;
|
|
517
|
+
this.#container.part.add('container');
|
|
518
|
+
this.#toolbar.part.add('toolbar');
|
|
519
|
+
this.#editedBadge.part.add('edited');
|
|
520
|
+
this.#editedBadge.textContent = 'edited';
|
|
521
|
+
this.#editedBadge.hidden = true;
|
|
522
|
+
this.#wholeComment.type = 'button';
|
|
523
|
+
this.#wholeComment.part.add('comment-trigger');
|
|
524
|
+
this.#wholeComment.part.add('whole-comment');
|
|
525
|
+
markTrigger(this.#wholeComment);
|
|
526
|
+
this.#wholeComment.addEventListener('click', () => {
|
|
527
|
+
this.#openComment(WHOLE, WHOLE_TARGET, this.#wholeComment, 'end');
|
|
528
|
+
});
|
|
529
|
+
this.#editToggle.type = 'button';
|
|
530
|
+
this.#editToggle.part.add('edit-toggle');
|
|
531
|
+
this.#editToggle.hidden = true;
|
|
532
|
+
this.#editToggle.append(this.#editLabel);
|
|
533
|
+
this.#editToggle.addEventListener('click', () => {
|
|
534
|
+
this.#editing = !this.#editing;
|
|
535
|
+
this.#clearDraft();
|
|
536
|
+
this.#renderSurface();
|
|
537
|
+
this.#renderToolbar();
|
|
538
|
+
if (this.#editing)
|
|
539
|
+
this.#editor.focus();
|
|
540
|
+
else
|
|
541
|
+
this.#body.focus();
|
|
542
|
+
});
|
|
543
|
+
this.#toolbar.append(this.#editedBadge, this.#wholeComment, this.#editToggle);
|
|
544
|
+
this.#surface.part.add('surface');
|
|
545
|
+
this.#editor.part.add('editor');
|
|
546
|
+
this.#editor.addEventListener('input', () => {
|
|
547
|
+
this.#text = this.#editor.value;
|
|
548
|
+
this.#renderToolbar();
|
|
549
|
+
this.#emit();
|
|
550
|
+
});
|
|
551
|
+
this.#body.part.add('body');
|
|
552
|
+
this.#body.tabIndex = 0;
|
|
553
|
+
this.#prose.part.add('prose');
|
|
554
|
+
this.#selectionComment.type = 'button';
|
|
555
|
+
this.#selectionComment.part.add('comment-trigger');
|
|
556
|
+
this.#selectionComment.part.add('selection-comment');
|
|
557
|
+
this.#selectionComment.hidden = true;
|
|
558
|
+
markTrigger(this.#selectionComment);
|
|
559
|
+
this.#selectionComment.append(icon(ICON_COMMENT_ADD), document.createTextNode('Comment'));
|
|
560
|
+
this.#selectionComment.addEventListener('mousedown', (event) => event.preventDefault());
|
|
561
|
+
this.#selectionComment.addEventListener('click', () => {
|
|
562
|
+
const draft = this.#draft;
|
|
563
|
+
if (draft === null)
|
|
564
|
+
return;
|
|
565
|
+
this.#openComment(draft.anchor, RANGE_TARGET, this.#selectionComment, 'start', draft.anchor.quote);
|
|
566
|
+
});
|
|
567
|
+
this.#body.append(this.#prose, this.#selectionComment);
|
|
568
|
+
this.#body.addEventListener('mouseup', () => this.#captureSelection());
|
|
569
|
+
this.#rail.part.add('comment-rail');
|
|
570
|
+
this.#rail.hidden = true;
|
|
571
|
+
this.#railCount.part.add('comment-count');
|
|
572
|
+
this.#railChips.part.add('comment-chips');
|
|
573
|
+
this.#rail.append(this.#railCount, this.#railChips);
|
|
574
|
+
this.#popover.part.add('popover');
|
|
575
|
+
this.#popover.hidden = true;
|
|
576
|
+
this.#popover.id = POPOVER_ID;
|
|
577
|
+
this.#popover.setAttribute('role', 'dialog');
|
|
578
|
+
this.#popoverSubject.part.add('popover-subject');
|
|
579
|
+
this.#popoverQuote.part.add('popover-quote');
|
|
580
|
+
this.#popoverQuote.hidden = true;
|
|
581
|
+
this.#popoverInput.part.add('popover-input');
|
|
582
|
+
this.#popoverInput.placeholder = 'What should the agent know about this?';
|
|
583
|
+
this.#popoverInput.addEventListener('keydown', (event) => {
|
|
584
|
+
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
|
|
585
|
+
event.preventDefault();
|
|
586
|
+
this.#commitComment();
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
this.#popoverHint.part.add('popover-hint');
|
|
590
|
+
this.#popoverCancel.type = 'button';
|
|
591
|
+
this.#popoverCancel.part.add('popover-cancel');
|
|
592
|
+
this.#popoverCancel.textContent = 'Cancel';
|
|
593
|
+
this.#popoverCancel.addEventListener('click', () => this.#closeComment());
|
|
594
|
+
this.#popoverSave.type = 'button';
|
|
595
|
+
this.#popoverSave.part.add('popover-save');
|
|
596
|
+
this.#popoverSave.textContent = 'Save';
|
|
597
|
+
this.#popoverSave.addEventListener('click', () => this.#commitComment());
|
|
598
|
+
const actions = document.createElement('div');
|
|
599
|
+
actions.part.add('popover-actions');
|
|
600
|
+
const buttons = document.createElement('div');
|
|
601
|
+
buttons.part.add('popover-buttons');
|
|
602
|
+
buttons.append(this.#popoverCancel, this.#popoverSave);
|
|
603
|
+
actions.append(this.#popoverHint, buttons);
|
|
604
|
+
this.#popover.append(this.#popoverSubject, this.#popoverQuote, this.#popoverInput, actions);
|
|
605
|
+
this.#container.append(this.#toolbar, this.#surface, this.#rail, this.#popover);
|
|
606
|
+
this.#root.append(style, this.#container);
|
|
607
|
+
this.addEventListener('keydown', this.#onKeyDown);
|
|
608
|
+
}
|
|
609
|
+
connectedCallback() {
|
|
610
|
+
if (this.#live)
|
|
611
|
+
return;
|
|
612
|
+
const read = readSlotConfig(this);
|
|
613
|
+
if (read.status !== 'ok') {
|
|
614
|
+
this.#renderUnavailable(read.reason);
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
if (typeof read.config.initialText !== 'string') {
|
|
618
|
+
this.#renderUnavailable('text config has no initialText string');
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
this.#live = true;
|
|
622
|
+
this.#config = read.config;
|
|
623
|
+
this.#slotId = readSlotId(this);
|
|
624
|
+
this.#text = read.config.initialText;
|
|
625
|
+
this.#editing = read.config.editable === true && this.#text.length === 0;
|
|
626
|
+
this.#renderToolbar();
|
|
627
|
+
this.#renderSurface();
|
|
628
|
+
this.#renderRail();
|
|
629
|
+
}
|
|
630
|
+
disconnectedCallback() {
|
|
631
|
+
document.removeEventListener('pointerdown', this.#onDocumentPointerDown, true);
|
|
632
|
+
}
|
|
633
|
+
/** The complete current response — what every change hands `respond()`. */
|
|
634
|
+
#response() {
|
|
635
|
+
return { text: this.#text, edited: this.#text !== this.#config.initialText, comments: this.#comments };
|
|
636
|
+
}
|
|
637
|
+
#emit() {
|
|
638
|
+
if (this.#slotId === undefined)
|
|
639
|
+
return;
|
|
640
|
+
respond(this.#slotId, this.#response());
|
|
641
|
+
}
|
|
642
|
+
// — rendering —————————————————————————————————————————————————————————————
|
|
643
|
+
#renderUnavailable(reason) {
|
|
644
|
+
const box = document.createElement('div');
|
|
645
|
+
box.part.add('unavailable');
|
|
646
|
+
box.setAttribute('role', 'status');
|
|
647
|
+
const detail = document.createElement('span');
|
|
648
|
+
detail.part.add('reason');
|
|
649
|
+
detail.textContent = reason;
|
|
650
|
+
box.append('Text unavailable: ', detail);
|
|
651
|
+
this.#container.replaceChildren(box);
|
|
652
|
+
}
|
|
653
|
+
#renderToolbar() {
|
|
654
|
+
this.#editedBadge.hidden = this.#text === this.#config.initialText;
|
|
655
|
+
const whole = findComment(this.#comments, WHOLE);
|
|
656
|
+
this.#wholeComment.replaceChildren(icon(whole ? ICON_COMMENT : ICON_COMMENT_ADD));
|
|
657
|
+
this.#wholeComment.dataset.state = whole ? 'on' : 'off';
|
|
658
|
+
this.#wholeComment.setAttribute('aria-label', `${whole ? 'Edit comment on' : 'Comment on'} ${WHOLE_TARGET}`);
|
|
659
|
+
this.#editToggle.hidden = this.#config.editable !== true;
|
|
660
|
+
this.#editToggle.dataset.state = this.#editing ? 'on' : 'off';
|
|
661
|
+
this.#editLabel.textContent = this.#editing ? 'Done' : 'Edit';
|
|
662
|
+
this.#editToggle.replaceChildren(icon(this.#editing ? ICON_DONE : ICON_EDIT), this.#editLabel);
|
|
663
|
+
}
|
|
664
|
+
#renderSurface() {
|
|
665
|
+
if (this.#editing) {
|
|
666
|
+
if (this.#editor.value !== this.#text)
|
|
667
|
+
this.#editor.value = this.#text;
|
|
668
|
+
this.#editor.placeholder = this.#config.placeholder ?? 'Write your answer\u2026';
|
|
669
|
+
if (this.#surface.firstChild !== this.#editor)
|
|
670
|
+
this.#surface.replaceChildren(this.#editor);
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
if (this.#surface.firstChild !== this.#body)
|
|
674
|
+
this.#surface.replaceChildren(this.#body);
|
|
675
|
+
this.#renderProse();
|
|
676
|
+
}
|
|
677
|
+
#renderProse() {
|
|
678
|
+
const paragraphs = splitParagraphs(this.#text);
|
|
679
|
+
if (paragraphs.length === 0) {
|
|
680
|
+
const empty = document.createElement('p');
|
|
681
|
+
empty.part.add('placeholder');
|
|
682
|
+
empty.textContent = this.#config.placeholder ?? 'Nothing yet.';
|
|
683
|
+
this.#prose.replaceChildren(empty);
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
const rendered = paragraphs.map((paragraph) => {
|
|
687
|
+
const element = document.createElement('p');
|
|
688
|
+
element.dataset.paragraph = '';
|
|
689
|
+
element.dataset.start = String(paragraph.start);
|
|
690
|
+
for (const segment of segmentParagraph(paragraph, this.#comments)) {
|
|
691
|
+
element.append(segment.comment ? this.#renderCommented(segment.text, segment.comment) : segment.text);
|
|
692
|
+
}
|
|
693
|
+
return element;
|
|
694
|
+
});
|
|
695
|
+
this.#prose.replaceChildren(...rendered);
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* A saved range comment in place. The highlight is both the durable anchor and the hover
|
|
699
|
+
* target: the comment opens over the words it refers to, while the chip rail below remains
|
|
700
|
+
* the editing path.
|
|
701
|
+
*/
|
|
702
|
+
#renderCommented(text, comment) {
|
|
703
|
+
const span = document.createElement('span');
|
|
704
|
+
span.part.add('commented');
|
|
705
|
+
span.tabIndex = 0;
|
|
706
|
+
span.dataset.commentId = comment.id;
|
|
707
|
+
markTrigger(span);
|
|
708
|
+
const bubble = document.createElement('span');
|
|
709
|
+
bubble.part.add('comment-bubble');
|
|
710
|
+
bubble.setAttribute('role', 'note');
|
|
711
|
+
const label = document.createElement('span');
|
|
712
|
+
label.part.add('bubble-label');
|
|
713
|
+
label.textContent = 'Comment';
|
|
714
|
+
const body = document.createElement('span');
|
|
715
|
+
body.textContent = comment.text;
|
|
716
|
+
bubble.append(label, body);
|
|
717
|
+
span.append(text, bubble);
|
|
718
|
+
return span;
|
|
719
|
+
}
|
|
720
|
+
#renderRail() {
|
|
721
|
+
const count = this.#comments.length;
|
|
722
|
+
this.#rail.hidden = count === 0;
|
|
723
|
+
if (count === 0) {
|
|
724
|
+
this.#railChips.replaceChildren();
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
this.#railCount.textContent = `${count} comment${count === 1 ? '' : 's'}`;
|
|
728
|
+
const chips = this.#comments.map((comment) => {
|
|
729
|
+
const chip = document.createElement('button');
|
|
730
|
+
chip.type = 'button';
|
|
731
|
+
chip.part.add('comment-trigger');
|
|
732
|
+
chip.part.add('comment-chip');
|
|
733
|
+
chip.dataset.commentId = comment.id;
|
|
734
|
+
markTrigger(chip);
|
|
735
|
+
const label = document.createElement('span');
|
|
736
|
+
label.textContent = comment.text;
|
|
737
|
+
chip.append(icon(ICON_COMMENT), label);
|
|
738
|
+
const range = comment.anchor.kind === 'range' ? comment.anchor : undefined;
|
|
739
|
+
chip.setAttribute('aria-label', `Edit comment on ${range ? RANGE_TARGET : WHOLE_TARGET}`);
|
|
740
|
+
chip.addEventListener('click', () => {
|
|
741
|
+
this.#openComment(comment.anchor, range ? RANGE_TARGET : WHOLE_TARGET, chip, 'start', range?.quote);
|
|
742
|
+
});
|
|
743
|
+
return chip;
|
|
744
|
+
});
|
|
745
|
+
this.#railChips.replaceChildren(...chips);
|
|
746
|
+
}
|
|
747
|
+
// — selection ——————————————————————————————————————————————————————————————
|
|
748
|
+
/**
|
|
749
|
+
* Reads the live selection as a range anchor. One anchor is one paragraph: a comment
|
|
750
|
+
* spanning a paragraph break has no readable quote, and the review UIs worth copying all
|
|
751
|
+
* draw the same line. The boundary resolver deliberately treats an end at offset 0 of the
|
|
752
|
+
* next paragraph as the end of the previous one — that is the DOM range browsers produce
|
|
753
|
+
* for a normal whole-paragraph selection.
|
|
754
|
+
*/
|
|
755
|
+
#readSelection() {
|
|
756
|
+
const range = selectionRange(this.#root, this.#body);
|
|
757
|
+
if (range === null)
|
|
758
|
+
return null;
|
|
759
|
+
const start = paragraphBoundary(this.#body, range.startContainer, range.startOffset, 'start');
|
|
760
|
+
const end = paragraphBoundary(this.#body, range.endContainer, range.endOffset, 'end');
|
|
761
|
+
if (start === null || end === null || start.paragraph !== end.paragraph)
|
|
762
|
+
return null;
|
|
763
|
+
const paragraphStart = Number(start.paragraph.dataset.start ?? '0');
|
|
764
|
+
const from = paragraphStart + start.offset;
|
|
765
|
+
const to = paragraphStart + end.offset;
|
|
766
|
+
if (to <= from)
|
|
767
|
+
return null;
|
|
768
|
+
const rect = range.getBoundingClientRect();
|
|
769
|
+
const bounds = this.#body.getBoundingClientRect();
|
|
770
|
+
return {
|
|
771
|
+
anchor: { kind: 'range', start: from, end: to, quote: this.#text.slice(from, to) },
|
|
772
|
+
left: rect.left - bounds.left,
|
|
773
|
+
top: rect.bottom - bounds.top + 4,
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* The affordance is anchored to the selection rather than to a toolbar, so commenting
|
|
778
|
+
* starts where the user is looking.
|
|
779
|
+
*/
|
|
780
|
+
#captureSelection() {
|
|
781
|
+
if (this.#editing || this.#open !== null)
|
|
782
|
+
return;
|
|
783
|
+
const draft = this.#readSelection();
|
|
784
|
+
if (draft === null) {
|
|
785
|
+
this.#clearDraft();
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
this.#draft = draft;
|
|
789
|
+
this.#selectionComment.hidden = false;
|
|
790
|
+
this.#selectionComment.style.left = `${draft.left}px`;
|
|
791
|
+
this.#selectionComment.style.top = `${draft.top}px`;
|
|
792
|
+
}
|
|
793
|
+
#clearDraft() {
|
|
794
|
+
this.#draft = null;
|
|
795
|
+
this.#selectionComment.hidden = true;
|
|
796
|
+
}
|
|
797
|
+
// — the comment popover ————————————————————————————————————————————————————
|
|
798
|
+
#openComment(anchor, target, trigger, align, quote) {
|
|
799
|
+
// Invoking the trigger of the open popover closes it, and moving to another trigger
|
|
800
|
+
// hands the open state over — the popover is one dialog, and exactly one control at a
|
|
801
|
+
// time reports itself as expanded.
|
|
802
|
+
const previous = this.#open;
|
|
803
|
+
if (previous !== null) {
|
|
804
|
+
if (previous.trigger === trigger) {
|
|
805
|
+
this.#closeComment();
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
previous.trigger.setAttribute('aria-expanded', 'false');
|
|
809
|
+
}
|
|
810
|
+
const existing = findComment(this.#comments, anchor);
|
|
811
|
+
this.#open = { anchor, trigger };
|
|
812
|
+
trigger.setAttribute('aria-expanded', 'true');
|
|
813
|
+
this.#popoverSubject.textContent = `Comment on ${target}`;
|
|
814
|
+
this.#popover.setAttribute('aria-label', `Comment on ${target}`);
|
|
815
|
+
this.#popoverQuote.hidden = quote === undefined || quote === '';
|
|
816
|
+
this.#popoverQuote.textContent = quote ?? '';
|
|
817
|
+
// The box is seeded from the saved comment every time it opens, so an abandoned edit
|
|
818
|
+
// never leaks into the next one.
|
|
819
|
+
this.#popoverInput.value = existing?.text ?? '';
|
|
820
|
+
this.#popoverHint.textContent = existing ? 'empty to delete' : '\u2318\u21b5 to save';
|
|
821
|
+
this.#popover.hidden = false;
|
|
822
|
+
this.#positionPopover(trigger, align);
|
|
823
|
+
this.#popoverInput.focus();
|
|
824
|
+
document.addEventListener('pointerdown', this.#onDocumentPointerDown, true);
|
|
825
|
+
}
|
|
826
|
+
#positionPopover(trigger, align) {
|
|
827
|
+
const bounds = this.#container.getBoundingClientRect();
|
|
828
|
+
const rect = trigger.getBoundingClientRect();
|
|
829
|
+
const width = this.#popover.offsetWidth;
|
|
830
|
+
const raw = align === 'end' ? rect.right - bounds.left - width : rect.left - bounds.left;
|
|
831
|
+
const left = Math.min(Math.max(raw, 0), Math.max(0, bounds.width - width));
|
|
832
|
+
this.#popover.style.left = `${left}px`;
|
|
833
|
+
this.#popover.style.top = `${rect.bottom - bounds.top + POPOVER_GAP}px`;
|
|
834
|
+
}
|
|
835
|
+
#closeComment(restoreFocus = true) {
|
|
836
|
+
const open = this.#open;
|
|
837
|
+
this.#open = null;
|
|
838
|
+
this.#popover.hidden = true;
|
|
839
|
+
open?.trigger.setAttribute('aria-expanded', 'false');
|
|
840
|
+
document.removeEventListener('pointerdown', this.#onDocumentPointerDown, true);
|
|
841
|
+
const returnFocus = open !== null && open.trigger !== this.#selectionComment ? open.trigger : this.#body;
|
|
842
|
+
this.#clearDraft();
|
|
843
|
+
if (restoreFocus && this.#root.contains(returnFocus))
|
|
844
|
+
returnFocus.focus();
|
|
845
|
+
}
|
|
846
|
+
/** Clearing the box and saving deletes the comment; `setComment` is where that lives. */
|
|
847
|
+
#commitComment() {
|
|
848
|
+
const open = this.#open;
|
|
849
|
+
if (open === null)
|
|
850
|
+
return;
|
|
851
|
+
// Saving replaces every highlight and every chip, so a trigger living in either is
|
|
852
|
+
// about to be detached: close without restoring focus to that doomed node, then hand
|
|
853
|
+
// focus to the node that now stands for the same anchor so j/k/c keep working.
|
|
854
|
+
const anchored = this.#prose.contains(open.trigger) || this.#railChips.contains(open.trigger);
|
|
855
|
+
this.#comments = setComment(this.#comments, open.anchor, this.#popoverInput.value);
|
|
856
|
+
this.#closeComment(!anchored);
|
|
857
|
+
this.#renderToolbar();
|
|
858
|
+
if (!this.#editing)
|
|
859
|
+
this.#renderProse();
|
|
860
|
+
this.#renderRail();
|
|
861
|
+
if (anchored)
|
|
862
|
+
this.#refocusAnchor(open.anchor);
|
|
863
|
+
this.#emit();
|
|
864
|
+
}
|
|
865
|
+
/** Focus the freshly rendered highlight or chip for an anchor; the body when it is gone. */
|
|
866
|
+
#refocusAnchor(anchor) {
|
|
867
|
+
const id = findComment(this.#comments, anchor)?.id;
|
|
868
|
+
const selector = id === undefined ? null : `[data-comment-id="${CSS.escape(id)}"]`;
|
|
869
|
+
const replacement = selector === null
|
|
870
|
+
? null
|
|
871
|
+
: (this.#prose.querySelector(selector) ??
|
|
872
|
+
this.#railChips.querySelector(selector));
|
|
873
|
+
const target = replacement ?? this.#body;
|
|
874
|
+
if (this.#root.contains(target))
|
|
875
|
+
target.focus();
|
|
876
|
+
}
|
|
877
|
+
#onDocumentPointerDown = (event) => {
|
|
878
|
+
if (this.#open === null)
|
|
879
|
+
return;
|
|
880
|
+
const path = event.composedPath();
|
|
881
|
+
if (path.includes(this.#popover) || path.includes(this.#open.trigger))
|
|
882
|
+
return;
|
|
883
|
+
this.#closeComment();
|
|
884
|
+
};
|
|
885
|
+
// — the kit's keyboard grammar ——————————————————————————————————————————————
|
|
886
|
+
#onKeyDown = (event) => {
|
|
887
|
+
if (event.defaultPrevented)
|
|
888
|
+
return;
|
|
889
|
+
if (event.key === 'Escape' && this.#open !== null) {
|
|
890
|
+
event.preventDefault();
|
|
891
|
+
this.#closeComment();
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
if (this.#open !== null || this.#editing)
|
|
895
|
+
return;
|
|
896
|
+
const focus = event.composedPath()[0];
|
|
897
|
+
if (focus instanceof HTMLElement && (focus.tagName === 'TEXTAREA' || focus.tagName === 'INPUT'))
|
|
898
|
+
return;
|
|
899
|
+
if (event.metaKey || event.ctrlKey || event.altKey)
|
|
900
|
+
return;
|
|
901
|
+
if (event.key === 'j' || event.key === 'k') {
|
|
902
|
+
event.preventDefault();
|
|
903
|
+
this.#moveHighlight(event.key === 'j' ? 1 : -1);
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
if (event.key === 'c') {
|
|
907
|
+
event.preventDefault();
|
|
908
|
+
this.#commentFocusedAnchor();
|
|
909
|
+
}
|
|
910
|
+
};
|
|
911
|
+
#highlights() {
|
|
912
|
+
return Array.from(this.#prose.querySelectorAll('[part~="commented"]'));
|
|
913
|
+
}
|
|
914
|
+
#moveHighlight(delta) {
|
|
915
|
+
const spans = this.#highlights();
|
|
916
|
+
if (spans.length === 0)
|
|
917
|
+
return;
|
|
918
|
+
const active = this.#root.activeElement;
|
|
919
|
+
const current = active instanceof HTMLElement ? spans.indexOf(active) : -1;
|
|
920
|
+
const next = current === -1 ? (delta > 0 ? 0 : spans.length - 1) : current + delta;
|
|
921
|
+
const target = spans[Math.min(Math.max(next, 0), spans.length - 1)];
|
|
922
|
+
target?.focus();
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* `c` comments what the user is on: a live selection first, then a focused highlight, and
|
|
926
|
+
* the whole passage when neither says otherwise.
|
|
927
|
+
*/
|
|
928
|
+
#commentFocusedAnchor() {
|
|
929
|
+
const draft = this.#draft ?? this.#readSelection();
|
|
930
|
+
if (draft !== null) {
|
|
931
|
+
this.#draft = draft;
|
|
932
|
+
this.#selectionComment.hidden = false;
|
|
933
|
+
this.#selectionComment.style.left = `${draft.left}px`;
|
|
934
|
+
this.#selectionComment.style.top = `${draft.top}px`;
|
|
935
|
+
this.#openComment(draft.anchor, RANGE_TARGET, this.#selectionComment, 'start', draft.anchor.quote);
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
const active = this.#root.activeElement;
|
|
939
|
+
if (active instanceof HTMLElement && active.dataset.commentId !== undefined) {
|
|
940
|
+
const comment = this.#comments.find((entry) => entry.id === active.dataset.commentId);
|
|
941
|
+
if (comment !== undefined) {
|
|
942
|
+
const range = comment.anchor.kind === 'range' ? comment.anchor : undefined;
|
|
943
|
+
const target = range === undefined ? WHOLE_TARGET : RANGE_TARGET;
|
|
944
|
+
this.#openComment(comment.anchor, target, active, 'start', range?.quote);
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
this.#openComment(WHOLE, WHOLE_TARGET, this.#wholeComment, 'end');
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
registerElement('crtr-text', CrtrTextElement);
|