@north-light/crouter 0.3.198 → 0.3.199
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 +18 -18
- package/dist/api/client.js +29 -37
- package/dist/api/dto/broker-ops.d.ts +3 -1
- package/dist/api/dto/broker.d.ts +13 -2
- package/dist/api/dto/broker.js +4 -4
- package/dist/api/dto/human.d.ts +6 -5
- package/dist/api/dto/inbox.d.ts +81 -34
- package/dist/api/dto/nodes.d.ts +8 -2
- package/dist/api/dto/profiles.d.ts +23 -0
- package/dist/api/dto/profiles.js +2 -2
- package/dist/api/dto/worktree.d.ts +3 -5
- package/dist/api/routes.d.ts +3 -5
- package/dist/api/routes.js +4 -6
- package/dist/builtin-memory/00-runtime-base.md +6 -6
- package/dist/builtin-memory/01-spine/01-no-manager.md +1 -1
- package/dist/builtin-memory/02-lifecycle/00-terminal.md +3 -3
- package/dist/builtin-memory/04-orchestration-kernel.md +6 -6
- package/dist/builtin-memory/05-kinds/design/00-base.md +1 -1
- package/dist/builtin-memory/05-kinds/plan/reviewers/security.md +2 -2
- package/dist/builtin-memory/05-kinds/review/00-base.md +2 -2
- package/dist/builtin-memory/05-kinds/review/01-orchestrator.md +1 -1
- package/dist/builtin-memory/05-kinds/spec/01-orchestrator.md +1 -1
- package/dist/builtin-memory/insights/capture.md +2 -2
- package/dist/builtin-memory/insights/init.md +3 -3
- package/dist/builtin-memory/internal/INDEX.md +1 -1
- package/dist/builtin-memory/internal/memory-loading.md +1 -1
- package/dist/builtin-memory/internal/nodes-and-canvas.md +2 -2
- package/dist/builtin-memory/internal/plugins.md +59 -2
- package/dist/builtin-memory/internal/storage-tiers.md +3 -1
- package/dist/builtin-memory/spec/roadmap.md +2 -2
- package/dist/builtin-memory/testing.md +1 -1
- package/dist/clients/attach/chrome/bash-jobs.js +3 -3
- package/dist/clients/attach/render/chat-view.d.ts +26 -1
- package/dist/clients/attach/render/chat-view.js +61 -11
- package/dist/clients/attach/render/page-block.d.ts +34 -0
- package/dist/clients/attach/render/page-block.js +226 -0
- package/dist/clients/attach/session/whip.d.ts +2 -0
- package/dist/clients/attach/session/whip.js +2 -0
- package/dist/clients/attach/viewer.js +1419 -993
- package/dist/clients/conversation/projection.js +18 -3
- package/dist/clients/inbox/__tests__/serial/inbox-controller.test.js +9 -6
- package/dist/clients/inbox/__tests__/serial/mount-panel.test.js +13 -21
- package/dist/clients/inbox/controller.js +10 -16
- package/dist/clients/inbox/page-adapter.d.ts +1 -3
- package/dist/clients/inbox/page-adapter.js +3 -4
- package/dist/clients/inbox/resolve.d.ts +1 -1
- package/dist/clients/inbox/resolve.js +3 -3
- package/dist/clients/inbox/review/document-surface.js +3 -1
- package/dist/clients/inbox/review/state.d.ts +2 -1
- package/dist/clients/inbox/review/state.js +5 -16
- package/dist/clients/inbox/tui/input.js +37 -19
- package/dist/clients/inbox/tui/panel.js +5 -31
- package/dist/clients/inbox/tui/render.d.ts +11 -0
- package/dist/clients/inbox/tui/render.js +71 -27
- package/dist/clients/inbox/tui/slots.d.ts +3 -1
- package/dist/clients/inbox/tui/slots.js +16 -12
- package/dist/clients/inbox/tui/types.d.ts +2 -7
- package/dist/clients/inbox/tui.js +2 -2
- package/dist/commands/__tests__/human.test.js +71 -114
- package/dist/commands/__tests__/node-message.test.js +14 -0
- package/dist/commands/attention.js +4 -4
- package/dist/commands/canvas-snapshot.js +1 -1
- package/dist/commands/canvas.js +1 -1
- package/dist/commands/dashboard.js +1 -1
- package/dist/commands/human/{html.d.ts → components.d.ts} +1 -1
- package/dist/commands/human/components.js +77 -0
- package/dist/commands/human/feedback.d.ts +2 -0
- package/dist/commands/human/feedback.js +108 -0
- package/dist/commands/human/prompts.d.ts +13 -5
- package/dist/commands/human/prompts.js +160 -97
- package/dist/commands/human/queue.js +57 -88
- package/dist/commands/human/shared.d.ts +2 -12
- package/dist/commands/human/shared.js +17 -11
- package/dist/commands/human.js +29 -21
- package/dist/commands/memory/origin.js +2 -2
- package/dist/commands/node/bash.js +4 -4
- package/dist/commands/node/inspect.js +3 -3
- package/dist/commands/node/lifecycle.js +1 -1
- package/dist/commands/node/message.js +2 -2
- package/dist/commands/node-worktree.js +8 -8
- package/dist/commands/pkg/browse.js +1 -1
- package/dist/commands/pkg/plugin-manage.js +129 -10
- package/dist/commands/profile/delete.js +41 -15
- package/dist/commands/profile.js +1 -1
- package/dist/commands/push.js +1 -1
- package/dist/commands/surface/node/focus.js +29 -14
- package/dist/commands/surface-edit.js +3 -3
- package/dist/commands/surface-inbox.d.ts +2 -0
- package/dist/commands/{human/inbox.js → surface-inbox.js} +14 -18
- package/dist/commands/surface.js +2 -1
- package/dist/commands/sys/doctor.js +60 -3
- package/dist/commands/sys/feedback.js +79 -25
- package/dist/commands/sys/settings.js +1 -1
- package/dist/core/__tests__/cron-broker-capacity.test.js +1 -1
- package/dist/core/__tests__/extension-abort.test.js +5 -2
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +13 -2
- package/dist/core/__tests__/fixtures/fake-engine.js +47 -3
- package/dist/core/__tests__/human-cancel-guard.test.js +31 -10
- package/dist/core/__tests__/human-deliver.test.js +31 -26
- package/dist/core/__tests__/human-node-not-supervised.test.js +1 -1
- package/dist/core/__tests__/plugin-page-components.test.js +157 -0
- package/dist/core/__tests__/seam/dormancy-release.test.js +40 -0
- package/dist/core/__tests__/serial/flagship-lifecycle.test.js +2 -2
- package/dist/core/__tests__/serial/human-deliver-e2e.test.js +12 -6
- package/dist/core/__tests__/serial/live-mutation.test.js +1 -1
- package/dist/core/__tests__/serial/tmux-surface.test.js +4 -2
- package/dist/core/__tests__/serial/worktree.test.js +24 -198
- package/dist/core/__tests__/session-cycles.test.js +22 -0
- package/dist/core/__tests__/stop-guard.test.js +4 -4
- package/dist/core/__tests__/watchdog-abort-arms-retry.test.js +19 -4
- package/dist/core/bash-jobs.d.ts +6 -0
- package/dist/core/bash-jobs.js +10 -0
- package/dist/core/canvas/__tests__/attention.test.js +8 -5
- package/dist/core/canvas/__tests__/render-remote.test.js +5 -5
- package/dist/core/canvas/attention.js +8 -6
- package/dist/core/canvas/browse/model.d.ts +8 -2
- package/dist/core/canvas/browse/model.js +11 -4
- package/dist/core/canvas/canvas.d.ts +7 -0
- package/dist/core/canvas/canvas.js +37 -0
- package/dist/core/canvas/crons.d.ts +9 -0
- package/dist/core/canvas/crons.js +51 -0
- package/dist/core/canvas/node-order.d.ts +22 -1
- package/dist/core/canvas/node-order.js +26 -1
- package/dist/core/canvas/render-source.d.ts +5 -0
- package/dist/core/canvas/render-source.js +1 -0
- package/dist/core/canvas/types.d.ts +9 -3
- package/dist/core/command-plugins/bundle.d.ts +6 -1
- package/dist/core/command-plugins/bundle.js +26 -13
- package/dist/core/command.js +2 -1
- package/dist/core/config.d.ts +47 -1
- package/dist/core/config.js +137 -4
- package/dist/core/help.js +1 -1
- package/dist/core/human/__tests__/page-catalog.test.js +4 -0
- package/dist/core/human/__tests__/page-tickets.test.js +65 -52
- package/dist/core/human/__tests__/page.test.js +56 -98
- package/dist/core/human/__tests__/serial/inbox-core.test.js +6 -6
- package/dist/core/human/answer-text.js +3 -3
- package/dist/core/human/answer.d.ts +6 -4
- package/dist/core/human/answer.js +4 -10
- package/dist/core/human/component-docs.d.ts +11 -7
- package/dist/core/human/component-docs.js +361 -88
- package/dist/core/human/convention.d.ts +17 -3
- package/dist/core/human/convention.js +36 -5
- package/dist/core/human/feedback-companion.d.ts +20 -0
- package/dist/core/human/feedback-companion.js +98 -0
- package/dist/core/human/feedback.d.ts +65 -0
- package/dist/core/human/feedback.js +116 -0
- package/dist/core/human/page-catalog.d.ts +40 -5
- package/dist/core/human/page-catalog.js +102 -30
- package/dist/core/human/page-errors.d.ts +4 -0
- package/dist/core/human/page-errors.js +7 -0
- package/dist/core/human/page-eval.d.ts +20 -0
- package/dist/core/human/page-eval.js +123 -0
- package/dist/core/human/page-schema.d.ts +23 -191
- package/dist/core/human/page-schema.js +60 -89
- package/dist/core/human/page-synth.d.ts +1 -1
- package/dist/core/human/page-synth.js +15 -18
- package/dist/core/human/page.d.ts +12 -27
- package/dist/core/human/page.js +142 -474
- package/dist/core/human/root.d.ts +6 -2
- package/dist/core/human/root.js +12 -4
- package/dist/core/human/scan.d.ts +3 -4
- package/dist/core/human/scan.js +16 -15
- package/dist/core/human/summary.d.ts +1 -2
- package/dist/core/human/summary.js +2 -2
- package/dist/core/human/tickets.d.ts +20 -14
- package/dist/core/human/tickets.js +91 -31
- package/dist/core/human/types.d.ts +10 -2
- package/dist/core/preview-registry.js +8 -10
- package/dist/core/profiles/default-binding.d.ts +4 -0
- package/dist/core/profiles/default-binding.js +24 -0
- package/dist/core/profiles/deletion-reservation.d.ts +8 -0
- package/dist/core/profiles/deletion-reservation.js +38 -0
- package/dist/core/profiles/manifest.d.ts +3 -0
- package/dist/core/profiles/manifest.js +12 -0
- package/dist/core/profiles/select.js +2 -2
- package/dist/core/review/realize.js +3 -3
- package/dist/core/review/store.d.ts +6 -0
- package/dist/core/review/store.js +31 -0
- package/dist/core/runtime/bearings.d.ts +0 -4
- package/dist/core/runtime/bearings.js +4 -62
- package/dist/core/runtime/bin-contributions.d.ts +44 -0
- package/dist/core/runtime/bin-contributions.js +186 -0
- package/dist/core/runtime/broker/client-registry.d.ts +4 -0
- package/dist/core/runtime/broker/client-registry.js +13 -1
- package/dist/core/runtime/broker/extension-abort.d.ts +1 -1
- package/dist/core/runtime/broker/extension-abort.js +4 -2
- package/dist/core/runtime/broker/fault-retry.js +4 -0
- package/dist/core/runtime/broker/frame-dispatch.d.ts +3 -1
- package/dist/core/runtime/broker/frame-dispatch.js +5 -6
- package/dist/core/runtime/broker/read-ops.d.ts +1 -1
- package/dist/core/runtime/broker/read-ops.js +2 -2
- package/dist/core/runtime/broker/rebind.d.ts +1 -0
- package/dist/core/runtime/broker/rebind.js +6 -1
- package/dist/core/runtime/broker-extension-render.js +19 -0
- package/dist/core/runtime/broker-protocol.d.ts +14 -0
- package/dist/core/runtime/broker.d.ts +1 -1
- package/dist/core/runtime/broker.js +17 -6
- package/dist/core/runtime/front-door-env.d.ts +5 -0
- package/dist/core/runtime/front-door-env.js +16 -0
- package/dist/core/runtime/front-door.d.ts +1 -5
- package/dist/core/runtime/front-door.js +2 -5
- package/dist/core/runtime/host.d.ts +1 -1
- package/dist/core/runtime/invocation.d.ts +6 -0
- package/dist/core/runtime/invocation.js +10 -0
- package/dist/core/runtime/kickoff.js +34 -0
- package/dist/core/runtime/launch.d.ts +2 -6
- package/dist/core/runtime/node-read.js +4 -1
- package/dist/core/runtime/nodes.js +2 -0
- package/dist/core/runtime/promote.d.ts +1 -0
- package/dist/core/runtime/promote.js +3 -1
- package/dist/core/runtime/session-cycles.d.ts +6 -0
- package/dist/core/runtime/session-cycles.js +15 -1
- package/dist/core/runtime/session-visibility.d.ts +17 -8
- package/dist/core/runtime/session-visibility.js +19 -10
- package/dist/core/runtime/spawn-env.d.ts +9 -1
- package/dist/core/runtime/spawn-env.js +62 -2
- package/dist/core/runtime/stop-guard.d.ts +2 -2
- package/dist/core/runtime/stop-guard.js +2 -2
- package/dist/core/runtime/stop-signals.d.ts +1 -1
- package/dist/core/runtime/stop-signals.js +2 -2
- package/dist/core/runtime/tmux-bindings.js +1 -1
- package/dist/core/session-model/session-state.d.ts +4 -3
- package/dist/core/session-model/session-state.js +14 -1
- package/dist/core/user-settings.d.ts +21 -2
- package/dist/core/user-settings.js +32 -11
- package/dist/core/worktree.d.ts +15 -45
- package/dist/core/worktree.js +59 -261
- package/dist/daemon/api/__tests__/seam/profile-delete.test.d.ts +1 -0
- package/dist/daemon/api/__tests__/seam/profile-delete.test.js +386 -0
- package/dist/daemon/api/handlers/bash-jobs.js +1 -1
- package/dist/daemon/api/handlers/broker-ops.js +9 -5
- package/dist/daemon/api/handlers/feedback-comments.d.ts +2 -0
- package/dist/daemon/api/handlers/feedback-comments.js +207 -0
- package/dist/daemon/api/handlers/human.js +24 -20
- package/dist/daemon/api/handlers/inbox.d.ts +10 -0
- package/dist/daemon/api/handlers/inbox.js +39 -115
- package/dist/daemon/api/handlers/profiles.js +11 -29
- package/dist/daemon/api/map.d.ts +6 -1
- package/dist/daemon/api/map.js +23 -0
- package/dist/daemon/api/server.js +3 -1
- package/dist/daemon/companion-retire.d.ts +2 -0
- package/dist/daemon/companion-retire.js +33 -0
- package/dist/daemon/cron-run.js +14 -16
- package/dist/daemon/human/finish.d.ts +12 -7
- package/dist/daemon/human/finish.js +101 -38
- package/dist/daemon/human/sweep.js +5 -1
- package/dist/daemon/profile-delete.d.ts +7 -0
- package/dist/daemon/profile-delete.js +306 -0
- package/dist/daemon/reconcilers/broker-supervision.js +3 -1
- package/dist/daemon/review/deliver.js +3 -3
- package/dist/daemon/review/finish.d.ts +0 -2
- package/dist/daemon/review/finish.js +1 -29
- package/dist/pi-extensions/__tests__/canvas-bash-valve.test.js +56 -1
- package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +4 -4
- package/dist/pi-extensions/canvas-bash-valve.js +19 -5
- package/dist/pi-extensions/canvas-inbox-watcher.js +1 -1
- package/dist/pi-extensions/canvas-review-boundary.d.ts +4 -0
- package/dist/pi-extensions/canvas-review-boundary.js +18 -9
- package/dist/pi-extensions/canvas-stophook.js +4 -4
- package/dist/shared/generated-context.js +2 -2
- package/dist/types.d.ts +44 -2
- package/dist/types.js +7 -2
- package/package.json +4 -4
- package/runtime.lock.json +5 -33
- package/dist/builtin-memory/init.md +0 -38
- package/dist/builtin-memory/plan.md +0 -19
- package/dist/builtin-memory/spec.md +0 -19
- package/dist/commands/human/doc.d.ts +0 -2
- package/dist/commands/human/doc.js +0 -91
- package/dist/commands/human/html.js +0 -81
- package/dist/commands/human/inbox.d.ts +0 -2
- package/dist/core/human/__tests__/html-markdown.test.js +0 -52
- package/dist/core/human/__tests__/page-render.test.js +0 -66
- package/dist/core/human/html-markdown.d.ts +0 -3
- package/dist/core/human/html-markdown.js +0 -288
- package/dist/core/human/page-render.d.ts +0 -4
- package/dist/core/human/page-render.js +0 -105
- package/dist/pages/bundle.css +0 -1
- package/dist/pages/bundle.js +0 -1778
- package/dist/pages/comments.d.ts +0 -72
- package/dist/pages/comments.js +0 -176
- package/dist/pages/controls.d.ts +0 -98
- package/dist/pages/controls.js +0 -261
- package/dist/pages/elements/cards.d.ts +0 -28
- package/dist/pages/elements/cards.js +0 -915
- package/dist/pages/elements/chart.d.ts +0 -23
- package/dist/pages/elements/chart.js +0 -664
- package/dist/pages/elements/options.d.ts +0 -30
- package/dist/pages/elements/options.js +0 -742
- package/dist/pages/elements/pages.d.ts +0 -30
- package/dist/pages/elements/pages.js +0 -377
- package/dist/pages/elements/slot.d.ts +0 -26
- package/dist/pages/elements/slot.js +0 -112
- package/dist/pages/elements/table.d.ts +0 -36
- package/dist/pages/elements/table.js +0 -1043
- package/dist/pages/elements/text.d.ts +0 -31
- package/dist/pages/elements/text.js +0 -1175
- package/dist/pages/entry.d.ts +0 -8
- package/dist/pages/entry.js +0 -8
- package/dist/pages/host.d.ts +0 -134
- package/dist/pages/host.js +0 -165
- package/dist/pages/readonly.d.ts +0 -24
- package/dist/pages/readonly.js +0 -31
- package/dist/pages/register.d.ts +0 -2
- package/dist/pages/register.js +0 -3
- package/dist/pages/responses.d.ts +0 -37
- package/dist/pages/responses.js +0 -56
- package/dist/pages/slot-config.d.ts +0 -36
- package/dist/pages/slot-config.js +0 -50
- package/dist/pages/types.d.ts +0 -98
- package/dist/pages/types.js +0 -23
- /package/dist/{core/human/__tests__/html-markdown.test.d.ts → commands/__tests__/node-message.test.d.ts} +0 -0
- /package/dist/core/{human/__tests__/page-render.test.d.ts → __tests__/plugin-page-components.test.d.ts} +0 -0
|
@@ -1,1175 +0,0 @@
|
|
|
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 — pre-filled and editable by default;
|
|
8
|
-
* - **read** text the user leaves anchored comments on, review-style — set `editable: false`,
|
|
9
|
-
* then select a range and the comment affordance appears at the selection;
|
|
10
|
-
* - an **empty input** — `editable` with no starting text, which opens already editing.
|
|
11
|
-
*
|
|
12
|
-
* `singleLine` is the fourth shape and the one that is not prose: one compact form field for a
|
|
13
|
-
* subject line, a name, a URL. It is always editing, so it drops the edit toggle; it has no
|
|
14
|
-
* paragraphs to anchor a range against, so it drops the comment affordance and shows only the
|
|
15
|
-
* comments a saved answer already carried. Enter is a form field's Enter — it types nothing,
|
|
16
|
-
* and the pager's `⌘`/`Ctrl` + `Enter` still submits.
|
|
17
|
-
*
|
|
18
|
-
* Text is always commentable; that is not a capability switch, and edit and comment coexist.
|
|
19
|
-
* Reading is the resting state, because that is where anchored commenting lives; the edit
|
|
20
|
-
* toggle swaps the block for a plain textarea. Comments survive the switch, but a range
|
|
21
|
-
* anchor written before an edit can end up pointing at moved text — which is why every range
|
|
22
|
-
* carries the `quote` it was written against.
|
|
23
|
-
*
|
|
24
|
-
* `edited` means *differs*, not *was touched*: returning the text to what the agent wrote
|
|
25
|
-
* un-sets the flag.
|
|
26
|
-
*
|
|
27
|
-
* Ported from the reviewed interaction kit (`apps/gateway/components/agent-ui/agent-text.tsx`
|
|
28
|
-
* and `comment-affordance.tsx` @ `3705fe9e2`), framework-free and styled only through the
|
|
29
|
-
* bundle's `--crtr-*` tokens.
|
|
30
|
-
*/
|
|
31
|
-
import { applyReadOnly, isReadOnly } from '../readonly.js';
|
|
32
|
-
import { registerElement } from '../register.js';
|
|
33
|
-
import { readSlotConfig, readSlotId } from '../slot-config.js';
|
|
34
|
-
import { respond, savedResponse } from '../host.js';
|
|
35
|
-
import { registerSlotResponse } from '../responses.js';
|
|
36
|
-
import { adoptComments, canRecordComments, findComment, quotePreview, setComment } from '../comments.js';
|
|
37
|
-
import { focusRingStyle, writingBoxStyle } from '../controls.js';
|
|
38
|
-
const WHOLE = { kind: 'whole' };
|
|
39
|
-
const WHOLE_TARGET = 'the whole passage';
|
|
40
|
-
const RANGE_TARGET = 'the selected text';
|
|
41
|
-
const POPOVER_GAP = 6;
|
|
42
|
-
const POPOVER_ID = 'comment-popover';
|
|
43
|
-
/** Ids are scoped to this element's shadow root, so one constant is unique everywhere. */
|
|
44
|
-
const LABEL_ID = 'prompt-label';
|
|
45
|
-
const STYLE = `
|
|
46
|
-
:host {
|
|
47
|
-
display: block;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
[part="container"] {
|
|
51
|
-
position: relative;
|
|
52
|
-
display: grid;
|
|
53
|
-
gap: var(--crtr-text-container-gap, var(--crtr-space-2));
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/* The prompt reads as the agent's own prose above the passage, matching the table's
|
|
57
|
-
caption rather than shouting over the text it introduces. */
|
|
58
|
-
[part="label"] {
|
|
59
|
-
margin: var(--crtr-text-label-margin, 0);
|
|
60
|
-
color: var(--crtr-text-label-text-color, var(--crtr-color-text-muted));
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
[part="toolbar"] {
|
|
64
|
-
display: flex;
|
|
65
|
-
flex-wrap: wrap;
|
|
66
|
-
align-items: center;
|
|
67
|
-
justify-content: flex-end;
|
|
68
|
-
gap: var(--crtr-text-toolbar-gap, var(--crtr-space-2));
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
[part="edited"] {
|
|
72
|
-
padding: var(--crtr-text-edited-padding, 0 var(--crtr-space-2));
|
|
73
|
-
color: var(--crtr-text-edited-text-color, var(--crtr-color-accent));
|
|
74
|
-
background: var(--crtr-text-edited-fill, color-mix(in srgb, var(--crtr-color-accent) 14%, transparent));
|
|
75
|
-
border-radius: var(--crtr-text-edited-radius, var(--crtr-radius-small));
|
|
76
|
-
font-family: var(--crtr-text-edited-font-family, var(--crtr-font-mono));
|
|
77
|
-
font-size: var(--crtr-text-edited-font-size, 0.75em);
|
|
78
|
-
letter-spacing: var(--crtr-text-edited-letter-spacing, 0.06em);
|
|
79
|
-
line-height: var(--crtr-text-edited-line-height, 1.7);
|
|
80
|
-
text-transform: var(--crtr-text-edited-text-transform, uppercase);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
button {
|
|
84
|
-
display: inline-flex;
|
|
85
|
-
align-items: center;
|
|
86
|
-
gap: var(--crtr-text-button-gap, var(--crtr-space-1));
|
|
87
|
-
max-width: var(--crtr-text-button-max-width, 100%);
|
|
88
|
-
padding: var(--crtr-text-button-padding, var(--crtr-space-1) var(--crtr-space-2));
|
|
89
|
-
color: var(--crtr-text-button-text-color, var(--crtr-color-text-muted));
|
|
90
|
-
font: var(--crtr-text-button-font, inherit);
|
|
91
|
-
background: var(--crtr-text-button-fill, transparent);
|
|
92
|
-
border: var(--crtr-text-button-border-width, 1px)
|
|
93
|
-
var(--crtr-text-button-border-style, solid)
|
|
94
|
-
var(--crtr-text-button-border-color, transparent);
|
|
95
|
-
border-radius: var(--crtr-text-button-radius, var(--crtr-radius-small));
|
|
96
|
-
cursor: pointer;
|
|
97
|
-
/* One motion knob for all three properties, over the family's, because a button that
|
|
98
|
-
settles at a different speed than the field beside it is the drift controls.ts exists
|
|
99
|
-
to remove. */
|
|
100
|
-
transition: color var(--crtr-text-button-transition, var(--crtr-control-transition, 120ms ease)),
|
|
101
|
-
background-color var(--crtr-text-button-transition, var(--crtr-control-transition, 120ms ease)),
|
|
102
|
-
border-color var(--crtr-text-button-transition, var(--crtr-control-transition, 120ms ease));
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
button:hover {
|
|
106
|
-
color: var(--crtr-text-button-hover-text-color, var(--crtr-color-text));
|
|
107
|
-
background: var(--crtr-text-button-hover-fill, var(--crtr-color-surface-hover));
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
${focusRingStyle({ scope: 'text-whole-comment' }, '[part~="whole-comment"]')}
|
|
111
|
-
${focusRingStyle({ scope: 'text-edit-toggle' }, '[part="edit-toggle"]')}
|
|
112
|
-
${focusRingStyle({ scope: 'text-selection-comment' }, '[part~="selection-comment"]')}
|
|
113
|
-
${focusRingStyle({ scope: 'text-comment-chip' }, '[part~="comment-chip"]')}
|
|
114
|
-
${focusRingStyle({ scope: 'text-popover-save' }, '[part="popover-save"]')}
|
|
115
|
-
${focusRingStyle({ scope: 'text-popover-cancel' }, '[part="popover-cancel"]')}
|
|
116
|
-
button[data-state="on"] {
|
|
117
|
-
color: var(--crtr-text-button-on-text-color, var(--crtr-color-accent));
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/* Editing a prefilled draft is a primary path, not an icon to discover. Keep the control
|
|
121
|
-
beside the text but give it the bordered, labelled shape that reads as an action. */
|
|
122
|
-
[part="edit-toggle"] {
|
|
123
|
-
color: var(--crtr-text-edit-toggle-text-color, var(--crtr-color-text));
|
|
124
|
-
background: var(--crtr-text-edit-toggle-fill, var(--crtr-color-background));
|
|
125
|
-
border-color: var(--crtr-text-edit-toggle-border-color, var(--crtr-color-border));
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/* Reached for and engaged are one treatment here, so they are one knob: active. */
|
|
129
|
-
[part="edit-toggle"]:hover,
|
|
130
|
-
[part="edit-toggle"][data-state="on"] {
|
|
131
|
-
color: var(--crtr-text-edit-toggle-active-text-color, var(--crtr-color-accent));
|
|
132
|
-
border-color: var(--crtr-text-edit-toggle-active-border-color,
|
|
133
|
-
color-mix(in srgb, var(--crtr-color-accent) 45%, var(--crtr-color-border)));
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/* Width and height are one visual knob — the icon is square. */
|
|
137
|
-
svg {
|
|
138
|
-
width: var(--crtr-text-icon-size, 0.875em);
|
|
139
|
-
height: var(--crtr-text-icon-size, 0.875em);
|
|
140
|
-
flex: none;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/* Both writing boxes in one call, because in the kit they are one control at two heights:
|
|
144
|
-
input.tsx and textarea.tsx carry the same border, radius, padding, fill, elevation and
|
|
145
|
-
focus ring, and differ only in how tall they start. Writing them separately is what let
|
|
146
|
-
them drift into two visibly different fields — different padding, different radius,
|
|
147
|
-
different fill, and an inset outline on one against the kit's outset ring on the other.
|
|
148
|
-
|
|
149
|
-
Each selector still gets its own scope: the field and editor are distinct anatomy even
|
|
150
|
-
though this recipe keeps their default paint identical. */
|
|
151
|
-
${writingBoxStyle({ scope: 'text-input' }, '[part="input"]')}
|
|
152
|
-
${writingBoxStyle({ scope: 'text-editor' }, '[part="editor"]')}
|
|
153
|
-
/* The one thing the textarea does not share: it starts tall and may be dragged taller. */
|
|
154
|
-
[part="editor"] {
|
|
155
|
-
min-height: var(--crtr-text-editor-min-height, 10em);
|
|
156
|
-
resize: vertical;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/* The kit ring is outset paint, so a writing box needs a three-pixel gutter inside its grid
|
|
160
|
-
track. Without it the box fills the track exactly and the ring is shaved off by a tight
|
|
161
|
-
host frame. Prose is the other face of this surface and keeps its own inset ring, so it
|
|
162
|
-
stays full-bleed. */
|
|
163
|
-
[part="surface"]:has([part="input"]),
|
|
164
|
-
[part="surface"]:has([part="editor"]) {
|
|
165
|
-
padding: var(--crtr-text-surface-padding, 0.1875rem);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
[part="body"] {
|
|
169
|
-
position: relative;
|
|
170
|
-
padding: var(--crtr-text-body-padding, var(--crtr-space-3) var(--crtr-space-4));
|
|
171
|
-
background: var(--crtr-text-body-fill, var(--crtr-color-surface));
|
|
172
|
-
border: var(--crtr-text-body-border-width, 1px)
|
|
173
|
-
var(--crtr-text-body-border-style, solid)
|
|
174
|
-
var(--crtr-text-body-border-color, var(--crtr-color-border));
|
|
175
|
-
border-radius: var(--crtr-text-body-radius, var(--crtr-radius-medium));
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/* The ring sits inside the border rather than outside it. The body is full-bleed — it runs
|
|
179
|
-
the whole width of the frame the host gives the page — so an outset ring has nowhere to be
|
|
180
|
-
drawn and gets clipped at the frame's edges, showing three sides of a rectangle. */
|
|
181
|
-
[part="body"]:focus-visible {
|
|
182
|
-
outline: var(--crtr-text-body-focus-ring-width, 2px)
|
|
183
|
-
var(--crtr-text-body-focus-ring-style, solid)
|
|
184
|
-
var(--crtr-text-body-focus-ring-color, var(--crtr-color-focus));
|
|
185
|
-
outline-offset: var(--crtr-text-body-focus-ring-offset, -2px);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
[part="prose"] {
|
|
189
|
-
display: grid;
|
|
190
|
-
gap: var(--crtr-text-prose-gap, var(--crtr-space-3));
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
[part="prose"] p {
|
|
194
|
-
margin: var(--crtr-text-prose-paragraph-margin, 0);
|
|
195
|
-
/* The agent's own line breaks are the passage; preserving them is behavior. */
|
|
196
|
-
white-space: pre-wrap;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
[part="placeholder"] {
|
|
200
|
-
color: var(--crtr-text-placeholder-text-color, var(--crtr-color-text-muted));
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
[part~="commented"] {
|
|
204
|
-
position: relative;
|
|
205
|
-
border-radius: var(--crtr-text-commented-radius, var(--crtr-radius-small));
|
|
206
|
-
background: var(--crtr-text-commented-fill, color-mix(in srgb, var(--crtr-color-accent) 18%, transparent));
|
|
207
|
-
text-decoration: var(--crtr-text-commented-text-decoration, underline dotted);
|
|
208
|
-
text-decoration-color: var(--crtr-text-commented-text-decoration-color, var(--crtr-color-accent));
|
|
209
|
-
text-underline-offset: var(--crtr-text-commented-text-underline-offset, 0.25em);
|
|
210
|
-
cursor: help;
|
|
211
|
-
outline: var(--crtr-text-commented-outline, none);
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
[part~="commented"]:focus-visible {
|
|
215
|
-
outline: var(--crtr-text-commented-focus-ring-width, 2px)
|
|
216
|
-
var(--crtr-text-commented-focus-ring-style, solid)
|
|
217
|
-
var(--crtr-text-commented-focus-ring-color, var(--crtr-color-focus));
|
|
218
|
-
outline-offset: var(--crtr-text-commented-focus-ring-offset, 2px);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/* The staged delays here and on the hover rule below are the bubble's show/hide
|
|
222
|
-
choreography — behavior rather than a styling value, so they stay literal. */
|
|
223
|
-
[part="comment-bubble"] {
|
|
224
|
-
position: absolute;
|
|
225
|
-
bottom: calc(100% + var(--crtr-text-comment-bubble-offset, var(--crtr-space-1)));
|
|
226
|
-
left: 0;
|
|
227
|
-
z-index: 2;
|
|
228
|
-
display: grid;
|
|
229
|
-
gap: var(--crtr-text-comment-bubble-gap, var(--crtr-space-1));
|
|
230
|
-
width: var(--crtr-text-comment-bubble-width, max-content);
|
|
231
|
-
max-width: var(--crtr-text-comment-bubble-max-width, 18em);
|
|
232
|
-
padding: var(--crtr-text-comment-bubble-padding, var(--crtr-space-2) var(--crtr-space-3));
|
|
233
|
-
color: var(--crtr-text-comment-bubble-text-color, var(--crtr-color-text));
|
|
234
|
-
background: var(--crtr-text-comment-bubble-fill, var(--crtr-color-background));
|
|
235
|
-
border: var(--crtr-text-comment-bubble-border-width, 1px)
|
|
236
|
-
var(--crtr-text-comment-bubble-border-style, solid)
|
|
237
|
-
var(--crtr-text-comment-bubble-border-color, var(--crtr-color-border));
|
|
238
|
-
border-radius: var(--crtr-text-comment-bubble-radius, var(--crtr-radius-medium));
|
|
239
|
-
box-shadow: var(--crtr-text-comment-bubble-shadow, var(--crtr-shadow));
|
|
240
|
-
text-decoration: none;
|
|
241
|
-
white-space: normal;
|
|
242
|
-
user-select: none;
|
|
243
|
-
opacity: 0;
|
|
244
|
-
visibility: hidden;
|
|
245
|
-
transition: opacity 120ms ease 120ms, visibility 0s linear 240ms;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
[part~="commented"]:hover > [part="comment-bubble"],
|
|
249
|
-
[part~="commented"]:focus > [part="comment-bubble"] {
|
|
250
|
-
opacity: 1;
|
|
251
|
-
visibility: visible;
|
|
252
|
-
transition: opacity 120ms ease 220ms, visibility 0s linear 220ms;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
/* The comment layer's micro-labels are the page's own prose, one step down and muted — not
|
|
256
|
-
caps-mono. Mono set in capitals reads as machine output, and what these say ("Comment on
|
|
257
|
-
this passage", "2 comments") is the interface talking to a person. */
|
|
258
|
-
[part="bubble-label"],
|
|
259
|
-
[part="comment-count"],
|
|
260
|
-
[part="popover-subject"],
|
|
261
|
-
[part="popover-hint"] {
|
|
262
|
-
color: var(--crtr-text-comment-label-text-color, var(--crtr-color-text-muted));
|
|
263
|
-
font-size: var(--crtr-text-comment-label-font-size, 0.875em);
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
[part="bubble-label"] {
|
|
267
|
-
color: var(--crtr-text-bubble-label-text-color, var(--crtr-color-accent));
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
[part~="selection-comment"] {
|
|
271
|
-
position: absolute;
|
|
272
|
-
z-index: 3;
|
|
273
|
-
color: var(--crtr-text-selection-comment-text-color, var(--crtr-color-accent));
|
|
274
|
-
background: var(--crtr-text-selection-comment-fill, var(--crtr-color-background));
|
|
275
|
-
border-color: var(--crtr-text-selection-comment-border-color,
|
|
276
|
-
color-mix(in srgb, var(--crtr-color-accent) 40%, transparent));
|
|
277
|
-
box-shadow: var(--crtr-text-selection-comment-shadow, var(--crtr-shadow));
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
[part="comment-rail"] {
|
|
281
|
-
display: grid;
|
|
282
|
-
gap: var(--crtr-text-comment-rail-gap, var(--crtr-space-1));
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
[part="comment-chips"] {
|
|
286
|
-
display: flex;
|
|
287
|
-
flex-wrap: wrap;
|
|
288
|
-
gap: var(--crtr-text-comment-chips-gap, var(--crtr-space-2));
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
[part~="comment-chip"] {
|
|
292
|
-
color: var(--crtr-text-comment-chip-text-color, var(--crtr-color-text));
|
|
293
|
-
border-color: var(--crtr-text-comment-chip-border-color, var(--crtr-color-border));
|
|
294
|
-
text-align: var(--crtr-text-comment-chip-text-align, left);
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
[part~="comment-chip"] > span {
|
|
298
|
-
overflow: hidden;
|
|
299
|
-
white-space: nowrap;
|
|
300
|
-
text-overflow: ellipsis;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
[part="popover"] {
|
|
304
|
-
position: absolute;
|
|
305
|
-
z-index: 5;
|
|
306
|
-
box-sizing: border-box;
|
|
307
|
-
display: grid;
|
|
308
|
-
gap: var(--crtr-text-popover-gap, var(--crtr-space-2));
|
|
309
|
-
width: var(--crtr-text-popover-width, 20em);
|
|
310
|
-
max-width: var(--crtr-text-popover-max-width, 100%);
|
|
311
|
-
padding: var(--crtr-text-popover-padding, var(--crtr-space-3));
|
|
312
|
-
background: var(--crtr-text-popover-fill, var(--crtr-color-background));
|
|
313
|
-
border: var(--crtr-text-popover-border-width, 1px)
|
|
314
|
-
var(--crtr-text-popover-border-style, solid)
|
|
315
|
-
var(--crtr-text-popover-border-color, var(--crtr-color-border));
|
|
316
|
-
border-radius: var(--crtr-text-popover-radius, var(--crtr-radius-medium));
|
|
317
|
-
box-shadow: var(--crtr-text-popover-shadow, var(--crtr-shadow));
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
[part="popover"][hidden],
|
|
321
|
-
[part="toolbar"][hidden],
|
|
322
|
-
[part~="whole-comment"][hidden],
|
|
323
|
-
[part="edited"][hidden],
|
|
324
|
-
[part="comment-rail"][hidden],
|
|
325
|
-
[part~="selection-comment"][hidden],
|
|
326
|
-
[part="popover-quote"][hidden],
|
|
327
|
-
[part="edit-toggle"][hidden] {
|
|
328
|
-
display: none;
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
[part="popover-subject"] {
|
|
332
|
-
margin: var(--crtr-text-popover-subject-margin, 0);
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
/* Smaller than the comment being written, because it is the context and not the subject.
|
|
336
|
-
The text arrives already elided to about two lines by quotePreview, so it wraps normally
|
|
337
|
-
rather than keeping the source's line breaks. */
|
|
338
|
-
[part="popover-quote"] {
|
|
339
|
-
margin: var(--crtr-text-popover-quote-margin, 0);
|
|
340
|
-
padding: var(--crtr-text-popover-quote-padding, var(--crtr-space-1) var(--crtr-space-2));
|
|
341
|
-
color: var(--crtr-text-popover-quote-text-color, var(--crtr-color-text-muted));
|
|
342
|
-
font-size: var(--crtr-text-popover-quote-font-size, 0.8125em);
|
|
343
|
-
background: var(--crtr-text-popover-quote-fill, color-mix(in srgb, var(--crtr-color-accent) 8%, transparent));
|
|
344
|
-
border-left: var(--crtr-text-popover-quote-border-width, 2px)
|
|
345
|
-
var(--crtr-text-popover-quote-border-style, solid)
|
|
346
|
-
var(--crtr-text-popover-quote-border-color, color-mix(in srgb, var(--crtr-color-accent) 40%, transparent));
|
|
347
|
-
overflow-wrap: anywhere;
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
/* The composer is its own scope: it is the one writing box a person can meet beside the
|
|
351
|
-
passage's own, so the two are separately restylable. */
|
|
352
|
-
${writingBoxStyle({ scope: 'text-popover-input' }, '[part="popover-input"]')}
|
|
353
|
-
[part="popover-input"] {
|
|
354
|
-
min-height: var(--crtr-text-popover-input-min-height, 5em);
|
|
355
|
-
resize: vertical;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
[part="popover-actions"] {
|
|
359
|
-
display: flex;
|
|
360
|
-
align-items: center;
|
|
361
|
-
justify-content: space-between;
|
|
362
|
-
gap: var(--crtr-text-popover-actions-gap, var(--crtr-space-2));
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
[part="popover-buttons"] {
|
|
366
|
-
display: flex;
|
|
367
|
-
gap: var(--crtr-text-popover-buttons-gap, var(--crtr-space-2));
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
[part="popover-save"] {
|
|
371
|
-
color: var(--crtr-text-popover-save-text-color, var(--crtr-color-accent-contrast));
|
|
372
|
-
background: var(--crtr-text-popover-save-fill, var(--crtr-color-accent));
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
/* Mixing the accent toward the text colour darkens on a light token set and lightens on a
|
|
376
|
-
dark one — an opacity or brightness step moves the wrong way on one of them. */
|
|
377
|
-
[part="popover-save"]:hover {
|
|
378
|
-
color: var(--crtr-text-popover-save-hover-text-color, var(--crtr-color-accent-contrast));
|
|
379
|
-
background: var(--crtr-text-popover-save-hover-fill,
|
|
380
|
-
color-mix(in srgb, var(--crtr-color-accent) 88%, var(--crtr-color-text)));
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
/* Cancel is the quiet half of every editor in this bundle: no border until you reach for it. */
|
|
384
|
-
[part="popover-cancel"] {
|
|
385
|
-
border-color: var(--crtr-text-popover-cancel-border-color, transparent);
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
/* Flat: this sits in the flow of the page. A shadow in this bundle means floating — the
|
|
389
|
-
comment bubble, the selection button, the popover, and nothing else. */
|
|
390
|
-
[part="unavailable"] {
|
|
391
|
-
padding: var(--crtr-text-unavailable-padding, var(--crtr-space-3));
|
|
392
|
-
color: var(--crtr-text-unavailable-text-color, var(--crtr-color-text-muted));
|
|
393
|
-
background: var(--crtr-text-unavailable-fill, var(--crtr-color-surface));
|
|
394
|
-
border: var(--crtr-text-unavailable-border-width, 1px)
|
|
395
|
-
var(--crtr-text-unavailable-border-style, solid)
|
|
396
|
-
var(--crtr-text-unavailable-border-color, var(--crtr-color-border));
|
|
397
|
-
border-radius: var(--crtr-text-unavailable-radius, var(--crtr-radius-medium));
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
[part="reason"] {
|
|
401
|
-
color: var(--crtr-text-reason-text-color, var(--crtr-color-text));
|
|
402
|
-
font-family: var(--crtr-text-reason-font-family, var(--crtr-font-mono));
|
|
403
|
-
}
|
|
404
|
-
`;
|
|
405
|
-
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
406
|
-
const BUBBLE = 'M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z';
|
|
407
|
-
const ICON_COMMENT_ADD = [BUBBLE, 'M12 7v6', 'M9 10h6'];
|
|
408
|
-
const ICON_COMMENT = [BUBBLE, 'M13 8H7', 'M17 12H7'];
|
|
409
|
-
const ICON_EDIT = [
|
|
410
|
-
'M12 20h9',
|
|
411
|
-
'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',
|
|
412
|
-
'm15 5 3 3',
|
|
413
|
-
];
|
|
414
|
-
const ICON_DONE = ['M20 6 9 17l-5-5'];
|
|
415
|
-
function icon(paths) {
|
|
416
|
-
const svg = document.createElementNS(SVG_NS, 'svg');
|
|
417
|
-
svg.setAttribute('viewBox', '0 0 24 24');
|
|
418
|
-
svg.setAttribute('fill', 'none');
|
|
419
|
-
svg.setAttribute('stroke', 'currentColor');
|
|
420
|
-
svg.setAttribute('stroke-width', '2');
|
|
421
|
-
svg.setAttribute('stroke-linecap', 'round');
|
|
422
|
-
svg.setAttribute('stroke-linejoin', 'round');
|
|
423
|
-
svg.setAttribute('aria-hidden', 'true');
|
|
424
|
-
for (const d of paths) {
|
|
425
|
-
const path = document.createElementNS(SVG_NS, 'path');
|
|
426
|
-
path.setAttribute('d', d);
|
|
427
|
-
svg.append(path);
|
|
428
|
-
}
|
|
429
|
-
return svg;
|
|
430
|
-
}
|
|
431
|
-
/** Paragraphs with their character offsets into the whole text preserved. */
|
|
432
|
-
function splitParagraphs(text) {
|
|
433
|
-
const out = [];
|
|
434
|
-
const separator = /\n{2,}/g;
|
|
435
|
-
let cursor = 0;
|
|
436
|
-
let match;
|
|
437
|
-
while ((match = separator.exec(text)) !== null) {
|
|
438
|
-
out.push({ start: cursor, text: text.slice(cursor, match.index) });
|
|
439
|
-
cursor = match.index + match[0].length;
|
|
440
|
-
}
|
|
441
|
-
out.push({ start: cursor, text: text.slice(cursor) });
|
|
442
|
-
return out.filter((paragraph) => paragraph.text.trim().length > 0);
|
|
443
|
-
}
|
|
444
|
-
/** Splits one paragraph at its commented ranges so they can be painted. */
|
|
445
|
-
function segmentParagraph(paragraph, comments) {
|
|
446
|
-
const anchored = comments
|
|
447
|
-
.filter((comment) => comment.anchor.kind === 'range')
|
|
448
|
-
.filter((comment) => comment.anchor.end > paragraph.start && comment.anchor.start < paragraph.start + paragraph.text.length)
|
|
449
|
-
.sort((a, b) => a.anchor.start - b.anchor.start);
|
|
450
|
-
const segments = [];
|
|
451
|
-
let cursor = 0;
|
|
452
|
-
for (const comment of anchored) {
|
|
453
|
-
const start = Math.max(cursor, comment.anchor.start - paragraph.start);
|
|
454
|
-
const end = Math.min(paragraph.text.length, comment.anchor.end - paragraph.start);
|
|
455
|
-
if (end <= start)
|
|
456
|
-
continue; // fully overlapped by an earlier comment
|
|
457
|
-
if (start > cursor)
|
|
458
|
-
segments.push({ text: paragraph.text.slice(cursor, start) });
|
|
459
|
-
segments.push({ text: paragraph.text.slice(start, end), comment });
|
|
460
|
-
cursor = end;
|
|
461
|
-
}
|
|
462
|
-
if (cursor < paragraph.text.length)
|
|
463
|
-
segments.push({ text: paragraph.text.slice(cursor) });
|
|
464
|
-
return segments;
|
|
465
|
-
}
|
|
466
|
-
function closestParagraph(node) {
|
|
467
|
-
const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
|
|
468
|
-
return element?.closest('[data-paragraph]') ?? null;
|
|
469
|
-
}
|
|
470
|
-
/**
|
|
471
|
-
* Characters of `root` that precede the (node, offset) boundary. Comment bubbles live inside
|
|
472
|
-
* their highlighted range for positioning, but are UI rather than passage text, so cloned
|
|
473
|
-
* bubble nodes are removed before measuring. This keeps later anchors aligned to `value.text`
|
|
474
|
-
* after an earlier range has already been commented.
|
|
475
|
-
*/
|
|
476
|
-
function offsetWithin(root, node, offset) {
|
|
477
|
-
const range = document.createRange();
|
|
478
|
-
range.selectNodeContents(root);
|
|
479
|
-
range.setEnd(node, offset);
|
|
480
|
-
const contents = range.cloneContents();
|
|
481
|
-
for (const bubble of contents.querySelectorAll('[part="comment-bubble"]'))
|
|
482
|
-
bubble.remove();
|
|
483
|
-
return contents.textContent?.length ?? 0;
|
|
484
|
-
}
|
|
485
|
-
function passageLength(root) {
|
|
486
|
-
const contents = root.cloneNode(true);
|
|
487
|
-
for (const bubble of contents.querySelectorAll('[part="comment-bubble"]'))
|
|
488
|
-
bubble.remove();
|
|
489
|
-
return contents.textContent?.length ?? 0;
|
|
490
|
-
}
|
|
491
|
-
/**
|
|
492
|
-
* Normalizes a browser Selection boundary to one of the rendered paragraph text ranges.
|
|
493
|
-
* A whole-paragraph selection commonly ends at character 0 of the NEXT `<p>` (or at the next
|
|
494
|
-
* child boundary of their shared wrapper), despite selecting no text there. For an end
|
|
495
|
-
* boundary, that belongs to the previous paragraph.
|
|
496
|
-
*/
|
|
497
|
-
function paragraphBoundary(body, node, offset, edge) {
|
|
498
|
-
const paragraphs = Array.from(body.querySelectorAll('[data-paragraph]'));
|
|
499
|
-
const direct = closestParagraph(node);
|
|
500
|
-
if (direct) {
|
|
501
|
-
if (edge === 'end' && offset === 0) {
|
|
502
|
-
const index = paragraphs.indexOf(direct);
|
|
503
|
-
const previous = paragraphs[index - 1];
|
|
504
|
-
if (previous)
|
|
505
|
-
return { paragraph: previous, offset: passageLength(previous) };
|
|
506
|
-
}
|
|
507
|
-
return { paragraph: direct, offset: offsetWithin(direct, node, offset) };
|
|
508
|
-
}
|
|
509
|
-
// The range can also land on the wrapper between paragraph elements. Locate the first
|
|
510
|
-
// paragraph at/after that caret for a start, or the last paragraph before it for an end.
|
|
511
|
-
const boundary = document.createRange();
|
|
512
|
-
boundary.setStart(node, offset);
|
|
513
|
-
boundary.collapse(true);
|
|
514
|
-
const ordered = edge === 'start' ? paragraphs : [...paragraphs].reverse();
|
|
515
|
-
for (const paragraph of ordered) {
|
|
516
|
-
const contents = document.createRange();
|
|
517
|
-
contents.selectNodeContents(paragraph);
|
|
518
|
-
const relation = boundary.compareBoundaryPoints(edge === 'start' ? Range.START_TO_START : Range.START_TO_END, contents);
|
|
519
|
-
if ((edge === 'start' && relation <= 0) || (edge === 'end' && relation >= 0)) {
|
|
520
|
-
return { paragraph, offset: edge === 'start' ? 0 : passageLength(paragraph) };
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
return null;
|
|
524
|
-
}
|
|
525
|
-
/**
|
|
526
|
-
* The live selection that covers this element's prose. A selection made inside a shadow root
|
|
527
|
-
* is reported by `ShadowRoot.getSelection()` where that exists and by the document selection
|
|
528
|
-
* elsewhere, so both are consulted and the containment check decides.
|
|
529
|
-
*/
|
|
530
|
-
function selectionRange(root, body) {
|
|
531
|
-
const scoped = root;
|
|
532
|
-
const candidates = [
|
|
533
|
-
typeof scoped.getSelection === 'function' ? scoped.getSelection() : null,
|
|
534
|
-
document.getSelection(),
|
|
535
|
-
];
|
|
536
|
-
for (const selection of candidates) {
|
|
537
|
-
if (!selection || selection.isCollapsed || selection.rangeCount === 0)
|
|
538
|
-
continue;
|
|
539
|
-
const range = selection.getRangeAt(0);
|
|
540
|
-
if (body.contains(range.startContainer) && body.contains(range.endContainer))
|
|
541
|
-
return range;
|
|
542
|
-
}
|
|
543
|
-
return null;
|
|
544
|
-
}
|
|
545
|
-
/**
|
|
546
|
-
* Every node that opens the comment popover carries the popover's own semantics: the kit's
|
|
547
|
-
* Radix trigger supplied these, and a port that drops them leaves a dialog no assistive
|
|
548
|
-
* technology can find from the control that opens it.
|
|
549
|
-
*/
|
|
550
|
-
function markTrigger(element) {
|
|
551
|
-
element.setAttribute('aria-haspopup', 'dialog');
|
|
552
|
-
element.setAttribute('aria-controls', POPOVER_ID);
|
|
553
|
-
element.setAttribute('aria-expanded', 'false');
|
|
554
|
-
}
|
|
555
|
-
class CrtrTextElement extends HTMLElement {
|
|
556
|
-
/** The host marks a resolved or canceled page `inert`; that is this element's read-only signal. */
|
|
557
|
-
static observedAttributes = ['inert'];
|
|
558
|
-
#root;
|
|
559
|
-
#container = document.createElement('div');
|
|
560
|
-
#label = document.createElement('p');
|
|
561
|
-
#toolbar = document.createElement('div');
|
|
562
|
-
#editedBadge = document.createElement('span');
|
|
563
|
-
#wholeComment = document.createElement('button');
|
|
564
|
-
#editToggle = document.createElement('button');
|
|
565
|
-
#editLabel = document.createElement('span');
|
|
566
|
-
#surface = document.createElement('div');
|
|
567
|
-
#editor = document.createElement('textarea');
|
|
568
|
-
#input = document.createElement('input');
|
|
569
|
-
#body = document.createElement('div');
|
|
570
|
-
#prose = document.createElement('div');
|
|
571
|
-
#selectionComment = document.createElement('button');
|
|
572
|
-
#rail = document.createElement('div');
|
|
573
|
-
#railCount = document.createElement('span');
|
|
574
|
-
#railChips = document.createElement('div');
|
|
575
|
-
#popover = document.createElement('div');
|
|
576
|
-
#popoverSubject = document.createElement('p');
|
|
577
|
-
#popoverQuote = document.createElement('blockquote');
|
|
578
|
-
#popoverInput = document.createElement('textarea');
|
|
579
|
-
#popoverHint = document.createElement('span');
|
|
580
|
-
#popoverSave = document.createElement('button');
|
|
581
|
-
#popoverCancel = document.createElement('button');
|
|
582
|
-
#config = { initialText: '' };
|
|
583
|
-
#slotId;
|
|
584
|
-
#singleLine = false;
|
|
585
|
-
#text = '';
|
|
586
|
-
#comments = [];
|
|
587
|
-
#editing = false;
|
|
588
|
-
#live = false;
|
|
589
|
-
#draft = null;
|
|
590
|
-
#open = null;
|
|
591
|
-
/** Live only while the popover is open: its composer resizes, and the room reserved for it must follow. */
|
|
592
|
-
#popoverResize = new ResizeObserver(() => {
|
|
593
|
-
const open = this.#open;
|
|
594
|
-
if (open !== null)
|
|
595
|
-
this.#positionPopover(open.trigger, open.align);
|
|
596
|
-
});
|
|
597
|
-
constructor() {
|
|
598
|
-
super();
|
|
599
|
-
this.#root = this.attachShadow({ mode: 'open' });
|
|
600
|
-
const style = document.createElement('style');
|
|
601
|
-
style.textContent = STYLE;
|
|
602
|
-
this.#container.part.add('container');
|
|
603
|
-
this.#label.part.add('label');
|
|
604
|
-
this.#label.id = LABEL_ID;
|
|
605
|
-
this.#label.hidden = true;
|
|
606
|
-
this.#toolbar.part.add('toolbar');
|
|
607
|
-
this.#editedBadge.part.add('edited');
|
|
608
|
-
this.#editedBadge.textContent = 'edited';
|
|
609
|
-
this.#editedBadge.hidden = true;
|
|
610
|
-
this.#wholeComment.type = 'button';
|
|
611
|
-
this.#wholeComment.part.add('comment-trigger');
|
|
612
|
-
this.#wholeComment.part.add('whole-comment');
|
|
613
|
-
markTrigger(this.#wholeComment);
|
|
614
|
-
this.#wholeComment.addEventListener('click', () => {
|
|
615
|
-
this.#openComment(WHOLE, WHOLE_TARGET, this.#wholeComment, 'end');
|
|
616
|
-
});
|
|
617
|
-
this.#editToggle.type = 'button';
|
|
618
|
-
this.#editToggle.part.add('edit-toggle');
|
|
619
|
-
this.#editToggle.hidden = true;
|
|
620
|
-
this.#editToggle.append(this.#editLabel);
|
|
621
|
-
this.#editToggle.addEventListener('click', () => {
|
|
622
|
-
this.#editing = !this.#editing;
|
|
623
|
-
this.#clearDraft();
|
|
624
|
-
this.#renderSurface();
|
|
625
|
-
this.#renderToolbar();
|
|
626
|
-
if (this.#editing)
|
|
627
|
-
this.#editor.focus();
|
|
628
|
-
else
|
|
629
|
-
this.#body.focus();
|
|
630
|
-
});
|
|
631
|
-
this.#toolbar.append(this.#editedBadge, this.#wholeComment, this.#editToggle);
|
|
632
|
-
this.#surface.part.add('surface');
|
|
633
|
-
this.#editor.part.add('editor');
|
|
634
|
-
this.#editor.addEventListener('input', () => {
|
|
635
|
-
this.#text = this.#editor.value;
|
|
636
|
-
this.#renderToolbar();
|
|
637
|
-
this.#emit();
|
|
638
|
-
});
|
|
639
|
-
this.#input.type = 'text';
|
|
640
|
-
this.#input.part.add('input');
|
|
641
|
-
this.#input.addEventListener('input', () => {
|
|
642
|
-
this.#text = this.#input.value;
|
|
643
|
-
this.#renderToolbar();
|
|
644
|
-
this.#emit();
|
|
645
|
-
});
|
|
646
|
-
// A form field's Enter types nothing and submits nothing here: the page's own submit is the
|
|
647
|
-
// pager's modified chord, which this leaves alone.
|
|
648
|
-
this.#input.addEventListener('keydown', (event) => {
|
|
649
|
-
if (event.key === 'Enter' && !event.metaKey && !event.ctrlKey && !event.altKey)
|
|
650
|
-
event.preventDefault();
|
|
651
|
-
});
|
|
652
|
-
this.#body.part.add('body');
|
|
653
|
-
this.#body.tabIndex = 0;
|
|
654
|
-
this.#prose.part.add('prose');
|
|
655
|
-
this.#selectionComment.type = 'button';
|
|
656
|
-
this.#selectionComment.part.add('comment-trigger');
|
|
657
|
-
this.#selectionComment.part.add('selection-comment');
|
|
658
|
-
this.#selectionComment.hidden = true;
|
|
659
|
-
markTrigger(this.#selectionComment);
|
|
660
|
-
this.#selectionComment.append(icon(ICON_COMMENT_ADD), document.createTextNode('Comment'));
|
|
661
|
-
this.#selectionComment.addEventListener('mousedown', (event) => event.preventDefault());
|
|
662
|
-
this.#selectionComment.addEventListener('click', () => {
|
|
663
|
-
const draft = this.#draft;
|
|
664
|
-
if (draft === null)
|
|
665
|
-
return;
|
|
666
|
-
this.#openComment(draft.anchor, RANGE_TARGET, this.#selectionComment, 'start', draft.anchor.quote);
|
|
667
|
-
});
|
|
668
|
-
this.#body.append(this.#prose, this.#selectionComment);
|
|
669
|
-
this.#body.addEventListener('mouseup', () => this.#captureSelection());
|
|
670
|
-
this.#rail.part.add('comment-rail');
|
|
671
|
-
this.#rail.hidden = true;
|
|
672
|
-
this.#railCount.part.add('comment-count');
|
|
673
|
-
this.#railChips.part.add('comment-chips');
|
|
674
|
-
this.#rail.append(this.#railCount, this.#railChips);
|
|
675
|
-
this.#popover.part.add('popover');
|
|
676
|
-
this.#popover.hidden = true;
|
|
677
|
-
this.#popover.id = POPOVER_ID;
|
|
678
|
-
this.#popover.setAttribute('role', 'dialog');
|
|
679
|
-
this.#popoverSubject.part.add('popover-subject');
|
|
680
|
-
this.#popoverQuote.part.add('popover-quote');
|
|
681
|
-
this.#popoverQuote.hidden = true;
|
|
682
|
-
this.#popoverInput.part.add('popover-input');
|
|
683
|
-
this.#popoverInput.placeholder = 'What should the agent know about this?';
|
|
684
|
-
this.#popoverInput.addEventListener('keydown', (event) => {
|
|
685
|
-
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
|
|
686
|
-
event.preventDefault();
|
|
687
|
-
this.#commitComment();
|
|
688
|
-
}
|
|
689
|
-
});
|
|
690
|
-
this.#popoverHint.part.add('popover-hint');
|
|
691
|
-
this.#popoverCancel.type = 'button';
|
|
692
|
-
this.#popoverCancel.part.add('popover-cancel');
|
|
693
|
-
this.#popoverCancel.textContent = 'Cancel';
|
|
694
|
-
this.#popoverCancel.addEventListener('click', () => this.#closeComment());
|
|
695
|
-
this.#popoverSave.type = 'button';
|
|
696
|
-
this.#popoverSave.part.add('popover-save');
|
|
697
|
-
this.#popoverSave.textContent = 'Save';
|
|
698
|
-
this.#popoverSave.addEventListener('click', () => this.#commitComment());
|
|
699
|
-
const actions = document.createElement('div');
|
|
700
|
-
actions.part.add('popover-actions');
|
|
701
|
-
const buttons = document.createElement('div');
|
|
702
|
-
buttons.part.add('popover-buttons');
|
|
703
|
-
buttons.append(this.#popoverCancel, this.#popoverSave);
|
|
704
|
-
actions.append(this.#popoverHint, buttons);
|
|
705
|
-
this.#popover.append(this.#popoverSubject, this.#popoverQuote, this.#popoverInput, actions);
|
|
706
|
-
this.#container.append(this.#label, this.#toolbar, this.#surface, this.#rail, this.#popover);
|
|
707
|
-
this.#root.append(style, this.#container);
|
|
708
|
-
this.addEventListener('keydown', this.#onKeyDown);
|
|
709
|
-
}
|
|
710
|
-
connectedCallback() {
|
|
711
|
-
if (this.#live)
|
|
712
|
-
return;
|
|
713
|
-
const read = readSlotConfig(this);
|
|
714
|
-
if (read.status !== 'ok') {
|
|
715
|
-
this.#renderUnavailable(read.reason);
|
|
716
|
-
return;
|
|
717
|
-
}
|
|
718
|
-
if (typeof read.config.initialText !== 'string') {
|
|
719
|
-
this.#renderUnavailable('text config has no initialText string');
|
|
720
|
-
return;
|
|
721
|
-
}
|
|
722
|
-
this.#live = true;
|
|
723
|
-
this.#config = read.config;
|
|
724
|
-
this.#singleLine = read.config.singleLine === true;
|
|
725
|
-
this.#surface.toggleAttribute('data-single-line', this.#singleLine);
|
|
726
|
-
this.#slotId = readSlotId(this);
|
|
727
|
-
this.#text = read.config.initialText;
|
|
728
|
-
this.#renderLabel();
|
|
729
|
-
this.#hydrate();
|
|
730
|
-
// A text slot the person never touches still has an answer — the words on screen — so the
|
|
731
|
-
// pager can report it at submit even though no change ever called `respond()`.
|
|
732
|
-
if (this.#slotId !== undefined)
|
|
733
|
-
registerSlotResponse(this, this.#slotId, () => this.#response());
|
|
734
|
-
this.#editing = this.#editable && this.#text.length === 0;
|
|
735
|
-
this.#renderToolbar();
|
|
736
|
-
this.#renderSurface();
|
|
737
|
-
this.#renderRail();
|
|
738
|
-
}
|
|
739
|
-
attributeChangedCallback() {
|
|
740
|
-
if (this.isConnected)
|
|
741
|
-
this.#syncReadOnly();
|
|
742
|
-
}
|
|
743
|
-
disconnectedCallback() {
|
|
744
|
-
document.removeEventListener('pointerdown', this.#onDocumentPointerDown, true);
|
|
745
|
-
this.#popoverResize.disconnect();
|
|
746
|
-
}
|
|
747
|
-
/**
|
|
748
|
-
* Seed local state from the answer the host is already holding, so a reopened page shows
|
|
749
|
-
* the edited words and the comments on them rather than the agent's original text. An
|
|
750
|
-
* uneditable slot keeps its authored text — only comments carry over — because a saved
|
|
751
|
-
* edit there could not have come from this control.
|
|
752
|
-
*/
|
|
753
|
-
#hydrate() {
|
|
754
|
-
if (this.#slotId === undefined)
|
|
755
|
-
return;
|
|
756
|
-
const saved = savedResponse(this.#slotId);
|
|
757
|
-
if (saved === undefined)
|
|
758
|
-
return;
|
|
759
|
-
if ((this.#editable || this.#singleLine) && typeof saved.text === 'string')
|
|
760
|
-
this.#text = saved.text;
|
|
761
|
-
this.#comments = adoptComments(saved.comments);
|
|
762
|
-
}
|
|
763
|
-
/** The complete current response — what every change hands `respond()`. */
|
|
764
|
-
#response() {
|
|
765
|
-
return { text: this.#text, edited: this.#text !== this.#config.initialText, comments: this.#comments };
|
|
766
|
-
}
|
|
767
|
-
#emit() {
|
|
768
|
-
if (this.#slotId === undefined)
|
|
769
|
-
return;
|
|
770
|
-
respond(this.#slotId, this.#response());
|
|
771
|
-
}
|
|
772
|
-
// — rendering —————————————————————————————————————————————————————————————
|
|
773
|
-
#renderUnavailable(reason) {
|
|
774
|
-
const box = document.createElement('div');
|
|
775
|
-
box.part.add('unavailable');
|
|
776
|
-
box.setAttribute('role', 'status');
|
|
777
|
-
const detail = document.createElement('span');
|
|
778
|
-
detail.part.add('reason');
|
|
779
|
-
detail.textContent = reason;
|
|
780
|
-
box.append('Text unavailable: ', detail);
|
|
781
|
-
this.#container.replaceChildren(box);
|
|
782
|
-
}
|
|
783
|
-
/**
|
|
784
|
-
* The prompt names both faces of the surface, because the writing box and the prose body
|
|
785
|
-
* swap places under it and either one can be what the user is on.
|
|
786
|
-
*/
|
|
787
|
-
#renderLabel() {
|
|
788
|
-
const label = this.#config.label;
|
|
789
|
-
const shown = label !== undefined && label !== '';
|
|
790
|
-
this.#label.hidden = !shown;
|
|
791
|
-
this.#label.textContent = shown ? label : '';
|
|
792
|
-
for (const surface of [this.#editor, this.#input, this.#body]) {
|
|
793
|
-
if (shown)
|
|
794
|
-
surface.setAttribute('aria-labelledby', LABEL_ID);
|
|
795
|
-
else
|
|
796
|
-
surface.removeAttribute('aria-labelledby');
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
#renderToolbar() {
|
|
800
|
-
// A field the agent left empty has nothing to differ FROM, so the badge would fire on the
|
|
801
|
-
// first keystroke and say nothing; it earns its place only over a prefill.
|
|
802
|
-
this.#editedBadge.hidden = this.#text === this.#config.initialText || (this.#singleLine && this.#config.initialText === '');
|
|
803
|
-
const whole = findComment(this.#comments, WHOLE);
|
|
804
|
-
this.#wholeComment.replaceChildren(icon(whole ? ICON_COMMENT : ICON_COMMENT_ADD));
|
|
805
|
-
this.#wholeComment.dataset.state = whole ? 'on' : 'off';
|
|
806
|
-
this.#wholeComment.setAttribute('aria-label', `${whole ? 'Edit comment on' : 'Comment on'} ${WHOLE_TARGET}`);
|
|
807
|
-
// No comment affordance where a comment has nowhere to go — see `canRecordComments`.
|
|
808
|
-
this.#wholeComment.hidden = this.#singleLine || !this.#commentable;
|
|
809
|
-
this.#editToggle.hidden = !this.#editable;
|
|
810
|
-
// In single-line mode the toolbar carries the badge alone, so it goes with it.
|
|
811
|
-
this.#toolbar.hidden = this.#singleLine && this.#editedBadge.hidden;
|
|
812
|
-
this.#editToggle.dataset.state = this.#editing ? 'on' : 'off';
|
|
813
|
-
this.#editLabel.textContent = this.#editing ? 'Done editing' : 'Edit text';
|
|
814
|
-
this.#editToggle.replaceChildren(icon(this.#editing ? ICON_DONE : ICON_EDIT), this.#editLabel);
|
|
815
|
-
this.#syncReadOnly();
|
|
816
|
-
}
|
|
817
|
-
/**
|
|
818
|
-
* Repainted with the toolbar, which is repainted on every state change. The writing box
|
|
819
|
-
* goes read-only rather than disabled, so a saved answer stays selectable and copyable;
|
|
820
|
-
* the edit toggle goes disabled, because a page that can no longer take an answer has
|
|
821
|
-
* nothing to toggle into.
|
|
822
|
-
*/
|
|
823
|
-
#syncReadOnly() {
|
|
824
|
-
const readOnly = isReadOnly(this);
|
|
825
|
-
applyReadOnly(this.#root, readOnly);
|
|
826
|
-
// The editor lives outside the tree whenever the surface is showing prose, so the
|
|
827
|
-
// sweep over the shadow root never reaches it. The compact field is a writing box too:
|
|
828
|
-
// the sweep disables every input it finds, and this puts it back as read-only, which
|
|
829
|
-
// keeps a saved one-line answer selectable and copyable.
|
|
830
|
-
this.#editor.readOnly = readOnly;
|
|
831
|
-
this.#input.disabled = false;
|
|
832
|
-
this.#input.readOnly = readOnly;
|
|
833
|
-
this.#editToggle.disabled = readOnly;
|
|
834
|
-
// A page can go read-only while the popover is open — the host sets `inert` on a ticket
|
|
835
|
-
// that resolves under it. A box that can no longer save is closed rather than left there
|
|
836
|
-
// taking words nothing will keep.
|
|
837
|
-
if (readOnly && this.#open !== null)
|
|
838
|
-
this.#closeComment(false);
|
|
839
|
-
}
|
|
840
|
-
/** Multi-line text is a writing surface unless the author explicitly makes it review-only. */
|
|
841
|
-
get #editable() {
|
|
842
|
-
return !this.#singleLine && this.#config.editable !== false;
|
|
843
|
-
}
|
|
844
|
-
/** Whether this slot can keep a comment — see `canRecordComments`. */
|
|
845
|
-
get #commentable() {
|
|
846
|
-
return canRecordComments(this.#slotId);
|
|
847
|
-
}
|
|
848
|
-
#renderSurface() {
|
|
849
|
-
if (this.#singleLine) {
|
|
850
|
-
if (this.#input.value !== this.#text)
|
|
851
|
-
this.#input.value = this.#text;
|
|
852
|
-
this.#input.placeholder = this.#config.placeholder ?? '';
|
|
853
|
-
if (this.#surface.firstChild !== this.#input)
|
|
854
|
-
this.#surface.replaceChildren(this.#input);
|
|
855
|
-
return;
|
|
856
|
-
}
|
|
857
|
-
if (this.#editing) {
|
|
858
|
-
if (this.#editor.value !== this.#text)
|
|
859
|
-
this.#editor.value = this.#text;
|
|
860
|
-
this.#editor.placeholder = this.#config.placeholder ?? 'Write your answer\u2026';
|
|
861
|
-
if (this.#surface.firstChild !== this.#editor)
|
|
862
|
-
this.#surface.replaceChildren(this.#editor);
|
|
863
|
-
return;
|
|
864
|
-
}
|
|
865
|
-
if (this.#surface.firstChild !== this.#body)
|
|
866
|
-
this.#surface.replaceChildren(this.#body);
|
|
867
|
-
this.#renderProse();
|
|
868
|
-
}
|
|
869
|
-
#renderProse() {
|
|
870
|
-
const paragraphs = splitParagraphs(this.#text);
|
|
871
|
-
if (paragraphs.length === 0) {
|
|
872
|
-
const empty = document.createElement('p');
|
|
873
|
-
empty.part.add('placeholder');
|
|
874
|
-
empty.textContent = this.#config.placeholder ?? 'Nothing yet.';
|
|
875
|
-
this.#prose.replaceChildren(empty);
|
|
876
|
-
return;
|
|
877
|
-
}
|
|
878
|
-
const rendered = paragraphs.map((paragraph) => {
|
|
879
|
-
const element = document.createElement('p');
|
|
880
|
-
element.dataset.paragraph = '';
|
|
881
|
-
element.dataset.start = String(paragraph.start);
|
|
882
|
-
for (const segment of segmentParagraph(paragraph, this.#comments)) {
|
|
883
|
-
element.append(segment.comment ? this.#renderCommented(segment.text, segment.comment) : segment.text);
|
|
884
|
-
}
|
|
885
|
-
return element;
|
|
886
|
-
});
|
|
887
|
-
this.#prose.replaceChildren(...rendered);
|
|
888
|
-
}
|
|
889
|
-
/**
|
|
890
|
-
* A saved range comment in place. The highlight is both the durable anchor and the hover
|
|
891
|
-
* target: the comment opens over the words it refers to, while the chip rail below remains
|
|
892
|
-
* the editing path.
|
|
893
|
-
*/
|
|
894
|
-
#renderCommented(text, comment) {
|
|
895
|
-
const span = document.createElement('span');
|
|
896
|
-
span.part.add('commented');
|
|
897
|
-
span.tabIndex = 0;
|
|
898
|
-
span.dataset.commentId = comment.id;
|
|
899
|
-
markTrigger(span);
|
|
900
|
-
const bubble = document.createElement('span');
|
|
901
|
-
bubble.part.add('comment-bubble');
|
|
902
|
-
bubble.setAttribute('role', 'note');
|
|
903
|
-
const label = document.createElement('span');
|
|
904
|
-
label.part.add('bubble-label');
|
|
905
|
-
label.textContent = 'Comment';
|
|
906
|
-
const body = document.createElement('span');
|
|
907
|
-
body.textContent = comment.text;
|
|
908
|
-
bubble.append(label, body);
|
|
909
|
-
span.append(text, bubble);
|
|
910
|
-
return span;
|
|
911
|
-
}
|
|
912
|
-
#renderRail() {
|
|
913
|
-
const count = this.#comments.length;
|
|
914
|
-
this.#rail.hidden = count === 0;
|
|
915
|
-
if (count === 0) {
|
|
916
|
-
this.#railChips.replaceChildren();
|
|
917
|
-
return;
|
|
918
|
-
}
|
|
919
|
-
this.#railCount.textContent = `${count} comment${count === 1 ? '' : 's'}`;
|
|
920
|
-
const chips = this.#comments.map((comment) => {
|
|
921
|
-
const chip = document.createElement('button');
|
|
922
|
-
chip.type = 'button';
|
|
923
|
-
chip.part.add('comment-trigger');
|
|
924
|
-
chip.part.add('comment-chip');
|
|
925
|
-
chip.dataset.commentId = comment.id;
|
|
926
|
-
markTrigger(chip);
|
|
927
|
-
const label = document.createElement('span');
|
|
928
|
-
label.textContent = comment.text;
|
|
929
|
-
chip.append(icon(ICON_COMMENT), label);
|
|
930
|
-
const range = comment.anchor.kind === 'range' ? comment.anchor : undefined;
|
|
931
|
-
chip.setAttribute('aria-label', `Edit comment on ${range ? RANGE_TARGET : WHOLE_TARGET}`);
|
|
932
|
-
chip.addEventListener('click', () => {
|
|
933
|
-
this.#openComment(comment.anchor, range ? RANGE_TARGET : WHOLE_TARGET, chip, 'start', range?.quote);
|
|
934
|
-
});
|
|
935
|
-
return chip;
|
|
936
|
-
});
|
|
937
|
-
this.#railChips.replaceChildren(...chips);
|
|
938
|
-
}
|
|
939
|
-
// — selection ——————————————————————————————————————————————————————————————
|
|
940
|
-
/**
|
|
941
|
-
* Reads the live selection as a range anchor. One anchor is one paragraph: a comment
|
|
942
|
-
* spanning a paragraph break has no readable quote, and the review UIs worth copying all
|
|
943
|
-
* draw the same line. The boundary resolver deliberately treats an end at offset 0 of the
|
|
944
|
-
* next paragraph as the end of the previous one — that is the DOM range browsers produce
|
|
945
|
-
* for a normal whole-paragraph selection.
|
|
946
|
-
*/
|
|
947
|
-
#readSelection() {
|
|
948
|
-
const range = selectionRange(this.#root, this.#body);
|
|
949
|
-
if (range === null)
|
|
950
|
-
return null;
|
|
951
|
-
const start = paragraphBoundary(this.#body, range.startContainer, range.startOffset, 'start');
|
|
952
|
-
const end = paragraphBoundary(this.#body, range.endContainer, range.endOffset, 'end');
|
|
953
|
-
if (start === null || end === null || start.paragraph !== end.paragraph)
|
|
954
|
-
return null;
|
|
955
|
-
const paragraphStart = Number(start.paragraph.dataset.start ?? '0');
|
|
956
|
-
const from = paragraphStart + start.offset;
|
|
957
|
-
const to = paragraphStart + end.offset;
|
|
958
|
-
if (to <= from)
|
|
959
|
-
return null;
|
|
960
|
-
const rect = range.getBoundingClientRect();
|
|
961
|
-
const bounds = this.#body.getBoundingClientRect();
|
|
962
|
-
return {
|
|
963
|
-
anchor: { kind: 'range', start: from, end: to, quote: this.#text.slice(from, to) },
|
|
964
|
-
left: rect.left - bounds.left,
|
|
965
|
-
top: rect.bottom - bounds.top + 4,
|
|
966
|
-
};
|
|
967
|
-
}
|
|
968
|
-
/**
|
|
969
|
-
* The affordance is anchored to the selection rather than to a toolbar, so commenting
|
|
970
|
-
* starts where the user is looking.
|
|
971
|
-
*/
|
|
972
|
-
#captureSelection() {
|
|
973
|
-
if (this.#editing || this.#open !== null || !this.#commentable)
|
|
974
|
-
return;
|
|
975
|
-
const draft = this.#readSelection();
|
|
976
|
-
if (draft === null) {
|
|
977
|
-
this.#clearDraft();
|
|
978
|
-
return;
|
|
979
|
-
}
|
|
980
|
-
this.#draft = draft;
|
|
981
|
-
this.#selectionComment.hidden = false;
|
|
982
|
-
this.#selectionComment.style.left = `${draft.left}px`;
|
|
983
|
-
this.#selectionComment.style.top = `${draft.top}px`;
|
|
984
|
-
}
|
|
985
|
-
#clearDraft() {
|
|
986
|
-
this.#draft = null;
|
|
987
|
-
this.#selectionComment.hidden = true;
|
|
988
|
-
}
|
|
989
|
-
// — the comment popover ————————————————————————————————————————————————————
|
|
990
|
-
#openComment(anchor, target, trigger, align, quote) {
|
|
991
|
-
// The popover opens only where its comment can land: never on a slot that cannot record
|
|
992
|
-
// one, and never on a read-only page, whose textareas are already `readOnly`, so it would
|
|
993
|
-
// take focus and refuse every keystroke.
|
|
994
|
-
if (!this.#commentable || isReadOnly(this))
|
|
995
|
-
return;
|
|
996
|
-
// Invoking the trigger of the open popover closes it, and moving to another trigger
|
|
997
|
-
// hands the open state over — the popover is one dialog, and exactly one control at a
|
|
998
|
-
// time reports itself as expanded.
|
|
999
|
-
const previous = this.#open;
|
|
1000
|
-
if (previous !== null) {
|
|
1001
|
-
if (previous.trigger === trigger) {
|
|
1002
|
-
this.#closeComment();
|
|
1003
|
-
return;
|
|
1004
|
-
}
|
|
1005
|
-
previous.trigger.setAttribute('aria-expanded', 'false');
|
|
1006
|
-
}
|
|
1007
|
-
const existing = findComment(this.#comments, anchor);
|
|
1008
|
-
this.#open = { anchor, trigger, align };
|
|
1009
|
-
trigger.setAttribute('aria-expanded', 'true');
|
|
1010
|
-
this.#popoverSubject.textContent = `Comment on ${target}`;
|
|
1011
|
-
this.#popover.setAttribute('aria-label', `Comment on ${target}`);
|
|
1012
|
-
const preview = quotePreview(quote ?? '');
|
|
1013
|
-
this.#popoverQuote.hidden = preview === '';
|
|
1014
|
-
this.#popoverQuote.textContent = preview;
|
|
1015
|
-
// The box is seeded from the saved comment every time it opens, so an abandoned edit
|
|
1016
|
-
// never leaks into the next one.
|
|
1017
|
-
this.#popoverInput.value = existing?.text ?? '';
|
|
1018
|
-
this.#popoverHint.textContent = existing ? 'empty to delete' : '\u2318\u21b5 to save';
|
|
1019
|
-
this.#popover.hidden = false;
|
|
1020
|
-
this.#positionPopover(trigger, align);
|
|
1021
|
-
// The composer is draggable-tall; observing the open popover keeps the reserved room in
|
|
1022
|
-
// step with it, so a resized box never pushes its own actions past the frame.
|
|
1023
|
-
this.#popoverResize.observe(this.#popover);
|
|
1024
|
-
this.#popoverInput.focus();
|
|
1025
|
-
document.addEventListener('pointerdown', this.#onDocumentPointerDown, true);
|
|
1026
|
-
}
|
|
1027
|
-
#positionPopover(trigger, align) {
|
|
1028
|
-
// Start from the page's natural height. A previous open may have reserved room for a
|
|
1029
|
-
// lower-edge composer, and measuring against that stale room would make it accumulate.
|
|
1030
|
-
this.#container.style.paddingBottom = '';
|
|
1031
|
-
const bounds = this.#container.getBoundingClientRect();
|
|
1032
|
-
const rect = trigger.getBoundingClientRect();
|
|
1033
|
-
const width = this.#popover.offsetWidth;
|
|
1034
|
-
const height = this.#popover.offsetHeight;
|
|
1035
|
-
const raw = align === 'end' ? rect.right - bounds.left - width : rect.left - bounds.left;
|
|
1036
|
-
const left = Math.min(Math.max(raw, 0), Math.max(0, bounds.width - width));
|
|
1037
|
-
const below = rect.bottom + POPOVER_GAP + height <= window.innerHeight;
|
|
1038
|
-
const above = rect.top - POPOVER_GAP - height >= 0;
|
|
1039
|
-
const top = !below && above
|
|
1040
|
-
? rect.top - bounds.top - POPOVER_GAP - height
|
|
1041
|
-
: rect.bottom - bounds.top + POPOVER_GAP;
|
|
1042
|
-
this.#popover.style.left = `${left}px`;
|
|
1043
|
-
this.#popover.style.top = `${top}px`;
|
|
1044
|
-
// An inline Gateway frame reports the body's in-flow height. Absolute popovers do not
|
|
1045
|
-
// contribute to that measurement, so reserve only the missing tail when neither side can
|
|
1046
|
-
// hold the composer. The host then grows the iframe and the entire writing box stays live.
|
|
1047
|
-
const overflow = this.#popover.getBoundingClientRect().bottom - document.body.getBoundingClientRect().bottom;
|
|
1048
|
-
if (overflow > 0)
|
|
1049
|
-
this.#container.style.paddingBottom = `${overflow}px`;
|
|
1050
|
-
}
|
|
1051
|
-
#closeComment(restoreFocus = true) {
|
|
1052
|
-
const open = this.#open;
|
|
1053
|
-
this.#open = null;
|
|
1054
|
-
this.#popover.hidden = true;
|
|
1055
|
-
this.#popoverResize.disconnect();
|
|
1056
|
-
this.#container.style.paddingBottom = '';
|
|
1057
|
-
open?.trigger.setAttribute('aria-expanded', 'false');
|
|
1058
|
-
document.removeEventListener('pointerdown', this.#onDocumentPointerDown, true);
|
|
1059
|
-
const returnFocus = open !== null && open.trigger !== this.#selectionComment ? open.trigger : this.#body;
|
|
1060
|
-
this.#clearDraft();
|
|
1061
|
-
if (restoreFocus && this.#root.contains(returnFocus))
|
|
1062
|
-
returnFocus.focus();
|
|
1063
|
-
}
|
|
1064
|
-
/** Clearing the box and saving deletes the comment; `setComment` is where that lives. */
|
|
1065
|
-
#commitComment() {
|
|
1066
|
-
const open = this.#open;
|
|
1067
|
-
if (open === null || isReadOnly(this))
|
|
1068
|
-
return;
|
|
1069
|
-
// Saving replaces every highlight and every chip, so a trigger living in either is
|
|
1070
|
-
// about to be detached: close without restoring focus to that doomed node, then hand
|
|
1071
|
-
// focus to the node that now stands for the same anchor so j/k/c keep working.
|
|
1072
|
-
const anchored = this.#prose.contains(open.trigger) || this.#railChips.contains(open.trigger);
|
|
1073
|
-
this.#comments = setComment(this.#comments, open.anchor, this.#popoverInput.value);
|
|
1074
|
-
this.#closeComment(!anchored);
|
|
1075
|
-
this.#renderToolbar();
|
|
1076
|
-
if (!this.#editing && !this.#singleLine)
|
|
1077
|
-
this.#renderProse();
|
|
1078
|
-
this.#renderRail();
|
|
1079
|
-
if (anchored)
|
|
1080
|
-
this.#refocusAnchor(open.anchor);
|
|
1081
|
-
this.#emit();
|
|
1082
|
-
}
|
|
1083
|
-
/** Focus the freshly rendered highlight or chip for an anchor; the body when it is gone. */
|
|
1084
|
-
#refocusAnchor(anchor) {
|
|
1085
|
-
const id = findComment(this.#comments, anchor)?.id;
|
|
1086
|
-
const selector = id === undefined ? null : `[data-comment-id="${CSS.escape(id)}"]`;
|
|
1087
|
-
const replacement = selector === null
|
|
1088
|
-
? null
|
|
1089
|
-
: (this.#prose.querySelector(selector) ??
|
|
1090
|
-
this.#railChips.querySelector(selector));
|
|
1091
|
-
const target = replacement ?? this.#body;
|
|
1092
|
-
if (this.#root.contains(target))
|
|
1093
|
-
target.focus();
|
|
1094
|
-
}
|
|
1095
|
-
#onDocumentPointerDown = (event) => {
|
|
1096
|
-
if (this.#open === null)
|
|
1097
|
-
return;
|
|
1098
|
-
const path = event.composedPath();
|
|
1099
|
-
if (path.includes(this.#popover) || path.includes(this.#open.trigger))
|
|
1100
|
-
return;
|
|
1101
|
-
this.#closeComment();
|
|
1102
|
-
};
|
|
1103
|
-
// — the kit's keyboard grammar ——————————————————————————————————————————————
|
|
1104
|
-
#onKeyDown = (event) => {
|
|
1105
|
-
if (event.defaultPrevented)
|
|
1106
|
-
return;
|
|
1107
|
-
if (event.key === 'Escape' && this.#open !== null) {
|
|
1108
|
-
event.preventDefault();
|
|
1109
|
-
this.#closeComment();
|
|
1110
|
-
return;
|
|
1111
|
-
}
|
|
1112
|
-
if (this.#open !== null || this.#editing)
|
|
1113
|
-
return;
|
|
1114
|
-
const focus = event.composedPath()[0];
|
|
1115
|
-
if (focus instanceof HTMLElement && (focus.tagName === 'TEXTAREA' || focus.tagName === 'INPUT'))
|
|
1116
|
-
return;
|
|
1117
|
-
if (event.metaKey || event.ctrlKey || event.altKey)
|
|
1118
|
-
return;
|
|
1119
|
-
if (event.key === 'j' || event.key === 'k') {
|
|
1120
|
-
event.preventDefault();
|
|
1121
|
-
this.#moveHighlight(event.key === 'j' ? 1 : -1);
|
|
1122
|
-
return;
|
|
1123
|
-
}
|
|
1124
|
-
if (event.key === 'c') {
|
|
1125
|
-
event.preventDefault();
|
|
1126
|
-
this.#commentFocusedAnchor();
|
|
1127
|
-
}
|
|
1128
|
-
};
|
|
1129
|
-
#highlights() {
|
|
1130
|
-
return Array.from(this.#prose.querySelectorAll('[part~="commented"]'));
|
|
1131
|
-
}
|
|
1132
|
-
#moveHighlight(delta) {
|
|
1133
|
-
const spans = this.#highlights();
|
|
1134
|
-
if (spans.length === 0)
|
|
1135
|
-
return;
|
|
1136
|
-
const active = this.#root.activeElement;
|
|
1137
|
-
const current = active instanceof HTMLElement ? spans.indexOf(active) : -1;
|
|
1138
|
-
const next = current === -1 ? (delta > 0 ? 0 : spans.length - 1) : current + delta;
|
|
1139
|
-
const target = spans[Math.min(Math.max(next, 0), spans.length - 1)];
|
|
1140
|
-
target?.focus();
|
|
1141
|
-
}
|
|
1142
|
-
/**
|
|
1143
|
-
* `c` comments what the user is on: a live selection first, then a focused highlight, and
|
|
1144
|
-
* the whole passage when neither says otherwise.
|
|
1145
|
-
*/
|
|
1146
|
-
#commentFocusedAnchor() {
|
|
1147
|
-
// A one-line field has no passage to anchor against, and the shape offers no way to write
|
|
1148
|
-
// a comment; the chips a saved answer arrived with stay clickable, and that is the whole path.
|
|
1149
|
-
// The same key must not raise the selection button on a slot that cannot record what it
|
|
1150
|
-
// would take — this is the one comment path that shows an affordance before opening it.
|
|
1151
|
-
if (this.#singleLine || !this.#commentable)
|
|
1152
|
-
return;
|
|
1153
|
-
const draft = this.#draft ?? this.#readSelection();
|
|
1154
|
-
if (draft !== null) {
|
|
1155
|
-
this.#draft = draft;
|
|
1156
|
-
this.#selectionComment.hidden = false;
|
|
1157
|
-
this.#selectionComment.style.left = `${draft.left}px`;
|
|
1158
|
-
this.#selectionComment.style.top = `${draft.top}px`;
|
|
1159
|
-
this.#openComment(draft.anchor, RANGE_TARGET, this.#selectionComment, 'start', draft.anchor.quote);
|
|
1160
|
-
return;
|
|
1161
|
-
}
|
|
1162
|
-
const active = this.#root.activeElement;
|
|
1163
|
-
if (active instanceof HTMLElement && active.dataset.commentId !== undefined) {
|
|
1164
|
-
const comment = this.#comments.find((entry) => entry.id === active.dataset.commentId);
|
|
1165
|
-
if (comment !== undefined) {
|
|
1166
|
-
const range = comment.anchor.kind === 'range' ? comment.anchor : undefined;
|
|
1167
|
-
const target = range === undefined ? WHOLE_TARGET : RANGE_TARGET;
|
|
1168
|
-
this.#openComment(comment.anchor, target, active, 'start', range?.quote);
|
|
1169
|
-
return;
|
|
1170
|
-
}
|
|
1171
|
-
}
|
|
1172
|
-
this.#openComment(WHOLE, WHOLE_TARGET, this.#wholeComment, 'end');
|
|
1173
|
-
}
|
|
1174
|
-
}
|
|
1175
|
-
registerElement('crtr-text', CrtrTextElement);
|