@acornly/ui 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +283 -2
- package/dist/card.d.ts +104 -0
- package/dist/card.d.ts.map +1 -0
- package/dist/card.js +375 -0
- package/dist/card.js.map +1 -0
- package/dist/clock.d.ts +3 -0
- package/dist/clock.d.ts.map +1 -0
- package/dist/clock.js +9 -0
- package/dist/clock.js.map +1 -0
- package/dist/compose.d.ts +8 -0
- package/dist/compose.d.ts.map +1 -0
- package/dist/compose.js +7 -0
- package/dist/compose.js.map +1 -0
- package/dist/composer.d.ts +166 -0
- package/dist/composer.d.ts.map +1 -0
- package/dist/composer.js +561 -0
- package/dist/composer.js.map +1 -0
- package/dist/dom.d.ts +20 -0
- package/dist/dom.d.ts.map +1 -0
- package/dist/dom.js +77 -0
- package/dist/dom.js.map +1 -0
- package/dist/float.d.ts +27 -0
- package/dist/float.d.ts.map +1 -0
- package/dist/float.js +76 -0
- package/dist/float.js.map +1 -0
- package/dist/focus.d.ts +25 -0
- package/dist/focus.d.ts.map +1 -0
- package/dist/focus.js +165 -0
- package/dist/focus.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/lazy.d.ts +20 -0
- package/dist/lazy.d.ts.map +1 -0
- package/dist/lazy.js +23 -0
- package/dist/lazy.js.map +1 -0
- package/dist/markdown/index.d.ts +8 -0
- package/dist/markdown/index.d.ts.map +1 -0
- package/dist/markdown/index.js +8 -0
- package/dist/markdown/index.js.map +1 -0
- package/dist/markdown/markdown.d.ts +33 -0
- package/dist/markdown/markdown.d.ts.map +1 -0
- package/dist/markdown/markdown.js +200 -0
- package/dist/markdown/markdown.js.map +1 -0
- package/dist/markdown/render.d.ts +25 -0
- package/dist/markdown/render.d.ts.map +1 -0
- package/dist/markdown/render.js +127 -0
- package/dist/markdown/render.js.map +1 -0
- package/dist/markdown/sanitize.d.ts +8 -0
- package/dist/markdown/sanitize.d.ts.map +1 -0
- package/dist/markdown/sanitize.js +145 -0
- package/dist/markdown/sanitize.js.map +1 -0
- package/dist/media.d.ts +45 -0
- package/dist/media.d.ts.map +1 -0
- package/dist/media.js +207 -0
- package/dist/media.js.map +1 -0
- package/dist/painter.d.ts +114 -0
- package/dist/painter.d.ts.map +1 -0
- package/dist/painter.js +750 -0
- package/dist/painter.js.map +1 -0
- package/dist/panel.d.ts +135 -0
- package/dist/panel.d.ts.map +1 -0
- package/dist/panel.js +541 -0
- package/dist/panel.js.map +1 -0
- package/dist/parts.d.ts +23 -0
- package/dist/parts.d.ts.map +1 -0
- package/dist/parts.js +23 -0
- package/dist/parts.js.map +1 -0
- package/dist/pins.d.ts +71 -0
- package/dist/pins.d.ts.map +1 -0
- package/dist/pins.js +656 -0
- package/dist/pins.js.map +1 -0
- package/dist/root.d.ts +83 -0
- package/dist/root.d.ts.map +1 -0
- package/dist/root.js +220 -0
- package/dist/root.js.map +1 -0
- package/dist/selection.d.ts +22 -0
- package/dist/selection.d.ts.map +1 -0
- package/dist/selection.js +81 -0
- package/dist/selection.js.map +1 -0
- package/dist/shortcuts.d.ts +64 -0
- package/dist/shortcuts.d.ts.map +1 -0
- package/dist/shortcuts.js +202 -0
- package/dist/shortcuts.js.map +1 -0
- package/dist/theme.d.ts +46 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/theme.js +111 -0
- package/dist/theme.js.map +1 -0
- package/dist/toolbar.d.ts +102 -0
- package/dist/toolbar.d.ts.map +1 -0
- package/dist/toolbar.js +263 -0
- package/dist/toolbar.js.map +1 -0
- package/package.json +58 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Free Solo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,284 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @acornly/ui
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The UI layer of Acornly: Lit web components rendered inside one closed Shadow DOM. Most apps get it through `@acornly/sdk`; this package is the shell those surfaces render into.
|
|
4
|
+
|
|
5
|
+
## The shell
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { mountRoot } from "@acornly/ui";
|
|
9
|
+
|
|
10
|
+
const root = mountRoot({
|
|
11
|
+
theme: { accent: "#5b5bd6", radius: "6px" },
|
|
12
|
+
colorScheme: "auto", // or "light" / "dark"
|
|
13
|
+
zIndex: 1000,
|
|
14
|
+
shortcuts: { "note.create": "alt+n", "panel.toggle": false },
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
root.destroy(); // removes the element, its listeners and everything inside it
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`mountRoot` appends one `<acornly-root>` element to `document.documentElement`. Everything Acornly draws lives in its closed shadow root:
|
|
21
|
+
|
|
22
|
+
- Page CSS cannot reach the UI. The host carries inline `all: initial !important`, so page rules can neither hide or move it nor pass inherited fonts and colors into it.
|
|
23
|
+
- The UI adds no global styles and no `window` globals. (Lit itself records its version on `globalThis` for duplicate detection.)
|
|
24
|
+
- Text normalization in `@acornly/core` skips `<acornly-root>` (landing with [#19](https://github.com/free-solo/contexthive/issues/19)), so notes never anchor to Acornly's own text.
|
|
25
|
+
- Keys typed into an Acornly field stop at the shadow root, so the page's bubbling key listeners (most page shortcuts) never see them.
|
|
26
|
+
- Surfaces size text in `px` or `em`, never `rem`: `rem` follows the page's root font size, which the shell cannot isolate.
|
|
27
|
+
|
|
28
|
+
## Theming
|
|
29
|
+
|
|
30
|
+
Set any of these custom properties on `acornly-root` in your CSS, or pass them as `theme` (the option names are in parentheses). Unset tokens use built-in light or dark defaults.
|
|
31
|
+
|
|
32
|
+
| Property | Option | What it styles |
|
|
33
|
+
| ----------------------- | ---------------- | ----------------------------------- |
|
|
34
|
+
| `--ctx-accent` | `accent` | Buttons, pins, focus rings |
|
|
35
|
+
| `--ctx-accent-contrast` | `accentContrast` | Text on the accent color |
|
|
36
|
+
| `--ctx-background` | `background` | Cards, toolbars, the panel |
|
|
37
|
+
| `--ctx-foreground` | `foreground` | Main text |
|
|
38
|
+
| `--ctx-muted` | `muted` | Timestamps, hints, placeholders |
|
|
39
|
+
| `--ctx-border` | `border` | Borders and dividers |
|
|
40
|
+
| `--ctx-highlight` | `highlight` | Fill behind highlighted text |
|
|
41
|
+
| `--ctx-font` | `font` | Font stack |
|
|
42
|
+
| `--ctx-font-size` | `fontSize` | Base font size |
|
|
43
|
+
| `--ctx-radius` | `radius` | Corner radius |
|
|
44
|
+
| `--ctx-shadow` | `shadow` | Shadow of floating surfaces |
|
|
45
|
+
| `--ctx-z-index` | `zIndex` | Stacking layer relative to the page |
|
|
46
|
+
|
|
47
|
+
```css
|
|
48
|
+
acornly-root {
|
|
49
|
+
--ctx-accent: #5b5bd6;
|
|
50
|
+
}
|
|
51
|
+
@media (prefers-color-scheme: dark) {
|
|
52
|
+
acornly-root {
|
|
53
|
+
--ctx-accent: #a5a5f0;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`colorScheme: "auto"` (the default) follows `prefers-color-scheme`; the `color-scheme` attribute on the element shows the current setting.
|
|
59
|
+
|
|
60
|
+
### Parts
|
|
61
|
+
|
|
62
|
+
For styling beyond the tokens, these `::part()` names are stable:
|
|
63
|
+
|
|
64
|
+
| Part | Element |
|
|
65
|
+
| ------------------- | ------------------------------------------- |
|
|
66
|
+
| `layer` | The full-page layer every surface sits in. |
|
|
67
|
+
| `pins` | The box holding every margin pin. |
|
|
68
|
+
| `pin` | One margin pin button. |
|
|
69
|
+
| `card` | A note card. |
|
|
70
|
+
| `panel` | The side panel. |
|
|
71
|
+
| `selection-toolbar` | The toolbar next to a text selection. |
|
|
72
|
+
| `composer` | The note composer. |
|
|
73
|
+
| `launcher` | The SDK's notes button, fixed bottom right. |
|
|
74
|
+
|
|
75
|
+
```css
|
|
76
|
+
acornly-root::part(layer) {
|
|
77
|
+
font-variant-numeric: tabular-nums;
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Later surfaces add their parts to this table and to `PARTS`.
|
|
82
|
+
|
|
83
|
+
### Host CSS
|
|
84
|
+
|
|
85
|
+
For anything else, pass `css` (or call `root.setStyles(css)` later): your own rules, applied inside the shadow root after every built-in style, including those of surfaces that load later, so a rule as specific as the built-in one wins. Select by `[part~="..."]` where you can; the `.ctx-*` classes may change between minor versions. `@import` is not supported.
|
|
86
|
+
|
|
87
|
+
## Keyboard shortcuts
|
|
88
|
+
|
|
89
|
+
Every action has a shortcut the host can remap or turn off through `shortcuts`: a map from command id to a combo, `false` to disable one command, or `shortcuts: false` to disable all of them. A combo is modifiers plus one key joined by `+`, such as `mod+shift+c`; `mod` is ⌘ on Apple platforms and Ctrl elsewhere. Name Space and Escape as `space` and `escape`. Shortcuts do not fire while focus is in a text field, or again while a key is held, unless the command opts in.
|
|
90
|
+
|
|
91
|
+
## Building blocks
|
|
92
|
+
|
|
93
|
+
Exported for the surfaces built on the shell, and usable by apps rendering their own UI:
|
|
94
|
+
|
|
95
|
+
- `await layerOf(root)` is the layer inside the shell that pins, cards, the panel and the painter's outlines go in.
|
|
96
|
+
- `trapFocus(container, options)` keeps Tab inside a card or dialog and restores focus on release; traps stack.
|
|
97
|
+
- `createShortcutRegistry(options)` is the registry behind `root.shortcuts`.
|
|
98
|
+
- `lazyPart(() => import("./composer.js"))` loads a heavy surface as a separate chunk on first use.
|
|
99
|
+
|
|
100
|
+
## Highlights
|
|
101
|
+
|
|
102
|
+
`createHighlightPainter` draws notes on the page without fighting the host's rendering. Its inputs are plain `Range`s and elements, so it works with any anchoring.
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
import { createHighlightPainter } from "@acornly/ui";
|
|
106
|
+
|
|
107
|
+
const painter = createHighlightPainter({
|
|
108
|
+
layer, // the shell's layer: element outlines go here
|
|
109
|
+
onActivate: (id) => openCard(id), // a click or tap on a highlight
|
|
110
|
+
onHover: (id) => showPreview(id), // null when the pointer leaves
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
painter.paint("note-1", range); // a Range or an array of Ranges
|
|
114
|
+
painter.paint("note-2", range2, { approximate: true }); // re-anchored by fuzzy matching
|
|
115
|
+
painter.paint("note-3", imageElement); // an outline over an element
|
|
116
|
+
painter.paintAll(anchored); // [{ id, target, approximate }], e.g. after one anchoring pass
|
|
117
|
+
painter.focus("note-1");
|
|
118
|
+
painter.clear("note-2");
|
|
119
|
+
painter.destroy(); // removes every highlight, listener and style
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
- Where the [CSS Custom Highlight API](https://developer.mozilla.org/en-US/docs/Web/API/CSS_Custom_Highlight_API) exists, text is painted through `CSS.highlights` and the page's DOM is never changed, so React or Vue re-renders never fight with it. `painter.mode` is `"highlights"`.
|
|
123
|
+
- Elsewhere, the covered text nodes are wrapped in `<ctx-mark>` elements, which `clear` and `destroy` remove, merging split text back into the page's original text nodes. `painter.mode` is `"marks"`. Wrapping and unwrapping move text nodes. The painter puts the ranges of its notes (the ones you passed, and its own copies) back on the same text after every change, but any other live `Range` over painted text may shift, so paint ranges built in one pass with `paintAll`, which reads them all before changing the page. SVG and MathML text is left unpainted in this mode.
|
|
124
|
+
- Element anchors get an outline drawn by an absolutely positioned box in the shell's layer; the element's own style is never edited.
|
|
125
|
+
- Highlights receive no events, so clicks and hovers are found by hit-testing the pointer against each note's cached client rects, re-measured after scroll (the window or any scroller), resize and layout changes (`ResizeObserver`). `painter.idsAt(x, y)` exposes the same test, innermost note first. Call `painter.refresh()` after a change those signals miss.
|
|
126
|
+
|
|
127
|
+
Each note is in one state at a time, each with its own highlight name: `ctx-note` (exact anchor), `ctx-note-approximate` (dashed underline), `ctx-note-hover` and `ctx-note-focus`. Restyle them with `::highlight()` rules in your own CSS; the painter's defaults sit in a cascade layer, so your rules win. Highlights are painted in the page's own tree, so the fill reads `--ctx-highlight` and `--ctx-highlight-active` from the page (for example on `:root`), not from `<acornly-root>`; the `colors` option and `painter.setColors()` set them explicitly instead. Highlight names are shared by the whole page, so with several painters on one page the last one created sets the colors. In `marks` mode, two painters marking the same text restore it with the same text but possibly in more text nodes than before.
|
|
128
|
+
|
|
129
|
+
A painter keeps its own copy of each `Range`, so later changes to a passed `Range` do not move the highlight; call `paint` again to move it.
|
|
130
|
+
|
|
131
|
+
## Pins
|
|
132
|
+
|
|
133
|
+
`createPins` puts one focusable button in the margin beside each note. Pins are the only reading surface on the page-load path; the card and the panel below are separate entry points that load when first opened.
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
import { createPins, layerOf } from "@acornly/ui";
|
|
137
|
+
|
|
138
|
+
const layer = await layerOf(root); // the shell's layer, once it has rendered
|
|
139
|
+
const pins = createPins({
|
|
140
|
+
layer,
|
|
141
|
+
onActivate: (id, pin) => openNote(id, pin), // click, Enter or Space
|
|
142
|
+
onHover: (id) => painter.focus(id), // pointer or focus on a pin; null when it leaves
|
|
143
|
+
gutter: document.querySelector("article"), // optional: put pins in the page margin, past this element
|
|
144
|
+
side: "right", // or "left"
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
pins.setAll([
|
|
148
|
+
{ id: "note-1", target: range }, // a Range, an element, or anything with getClientRects()
|
|
149
|
+
{ id: "note-2", target: { getClientRects: () => painter.rectsOf("note-2") }, approximate: true },
|
|
150
|
+
{ id: "note-3", target: image, quote: "Revenue chart", count: 3 },
|
|
151
|
+
]);
|
|
152
|
+
pins.setExpanded("note-1"); // aria-expanded on the pin whose card is open
|
|
153
|
+
pins.destroy();
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
- A pin sits beside the first line of its anchor, past the right (or left) edge of the anchor's nearest block-level ancestor: by default that is the paragraph, table cell or list item the anchor is in, so pins in a table sit inside it, beside their cells. Pass `gutter` (the article, say) to line every pin up past that element's edge instead, in the page margin. Pins are kept within the viewport's width, and a pin on text in a fixed box, or in a sticky box while it is stuck to the viewport, is fixed too, so it stays beside its text while the window scrolls. A sticky box's pin can still trail by a frame the moment the box sticks or unsticks. A pin is re-measured after resize and layout changes (`ResizeObserver`) and after scrolls that can move it: a scroller's own content, or, on a window scroll, anchors in fixed or sticky boxes and rect sources (every pin when the scroll is horizontal). Call `refresh()` after a change those miss: an ancestor made fixed or sticky, an anchor moved into another scroller, or content that moves without anything observed changing size (a block growing above the text inside a fixed-height scroller, say). Pins that would overlap stack instead, and a pin whose anchor is not rendered, or is scrolled out of a scroller, is hidden. Scroll events inside shadow roots do not reach the document, so pins on text in a shadow-DOM scroller need `refresh()` from the host.
|
|
157
|
+
- Each pin is a `<button>` whose accessible name quotes the anchored text: `Note on “quick brown fox”`, `3 notes on “…”`, with `(approximate)` for fuzzy anchors. Pass `label` to word it differently.
|
|
158
|
+
- Pins are kept in reading order (by the anchor's line, then left to right) in the DOM. They form one Tab stop, a `group` named `groupLabel` (default `Notes`): Tab reaches the last pin focused, or the first, and the arrow keys, Home and End move between pins.
|
|
159
|
+
- With the `<ctx-mark>` fallback of the painter, pass the painter's rects (`{ getClientRects: () => painter.rectsOf(id) }`) instead of a `Range`, since wrapping text can move other live ranges.
|
|
160
|
+
|
|
161
|
+
## Media markers
|
|
162
|
+
|
|
163
|
+
`@acornly/ui/media` draws notes on a moment or span of a `video` or `audio` element as markers on a thin strip under the player, along its timeline. It is its own entry point: load it with the first such note, so pages without one never fetch it.
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
const { createMediaStrips } = await import("@acornly/ui/media");
|
|
167
|
+
const strips = createMediaStrips({ layer, onActivate: (id, marker) => openNote(id, marker) });
|
|
168
|
+
strips.set({ id: "note-4", element: video, time: { start: 74, end: 91.5 } }); // a span
|
|
169
|
+
strips.set({ id: "note-5", element: video, time: { start: 12 } }); // a moment
|
|
170
|
+
strips.destroy();
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
- A strip is as wide as its player and sits just below it while the player is shown and its duration is known; markers on a stream whose length is not known yet appear once it is (`loadedmetadata`, `durationchange`). A note past the end is not drawn.
|
|
174
|
+
- A click, Enter or Space on a marker seeks the player to the note's start (`currentTime`), then calls `onActivate`. Each marker is a `<button>` named `Note at 1:05` or `Note from 1:05 to 1:40` (pass `label` to word it differently); the arrow keys, Home and End move between a strip's markers in time order.
|
|
175
|
+
- `resolve` in `@acornly/core` reports a match's `time` for such notes, and the SDK's built-in UI and the extension draw them this way.
|
|
176
|
+
|
|
177
|
+
## Note cards
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
const { openCard } = await import("@acornly/ui/card");
|
|
181
|
+
|
|
182
|
+
const card = openCard({
|
|
183
|
+
layer,
|
|
184
|
+
anchor: pins.pinOf(id)!, // or a virtual element: { getBoundingClientRect: () => rect }
|
|
185
|
+
thread, // assembleThread(await store.listThread(...), id)
|
|
186
|
+
approximate: true, // shows the "approximate" mark
|
|
187
|
+
viewerId, // marks the viewer's own reactions, and shows their notes as "Me"
|
|
188
|
+
viewerPicture, // optional: the viewer's photo (https), next to their own notes
|
|
189
|
+
registry: ctx.bodyTypes, // app-defined body types
|
|
190
|
+
actions: {
|
|
191
|
+
// Reply, Edit and Delete buttons on each note; the card only reports the choice.
|
|
192
|
+
reply: (note, button) => openReplyComposer(note, pin),
|
|
193
|
+
edit: (note) => openEditComposer(note, pin),
|
|
194
|
+
remove: (note) => ctx.remove(note.id),
|
|
195
|
+
canChange: (note) => note.creator?.id === viewerId, // Edit and Delete on these only
|
|
196
|
+
report: (note) => ctx.reportUrl(note), // a Report link, opened in a new tab; null for none
|
|
197
|
+
},
|
|
198
|
+
onClose: () => pins.setExpanded(null),
|
|
199
|
+
});
|
|
200
|
+
card.update(newerThread); // after a reply arrives
|
|
201
|
+
card.close();
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
The card is placed with [Floating UI](https://floating-ui.com): below its anchor, flipped above when there is no room, kept inside the viewport and following the anchor as the page scrolls. It shows the note and its replies, parents before children: replies are indented up to three levels (`MAX_INDENT`), and deeper ones are shown flat with a "Replying to …" label. Reactions are shown as counts on the note they react to; an edited note carries an "edited" mark and a deleted note that still has replies shows as a placeholder. The card is a `dialog` that takes focus, keeps Tab inside it and closes on Escape, its close button or a pointer down outside it and its anchor (`closeOnOutsideClick: false` turns that off); focus then returns to the anchor when it is an element (the pin or panel row), else to where it was. If that element was re-rendered meanwhile, the element with the same `data-id` and class gets focus instead. Every visible string can be replaced through `labels`. While its pin is hidden (its text scrolled out of a scroller, say), the card stays where the pin last was and follows the pin again once it shows. A removed pin does not come back as the same button, so close the card when its note goes away (`setAll` without it, say) and open a new one on the new pin. The card stacks above the side panel, whichever opened first. The card renders bodies with `@acornly/ui/markdown`, so loading it loads the Markdown renderer too.
|
|
205
|
+
|
|
206
|
+
## Side panel
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
const { openPanel } = await import("@acornly/ui/panel");
|
|
210
|
+
|
|
211
|
+
const panel = openPanel({
|
|
212
|
+
layer,
|
|
213
|
+
items: notes.map((note) => ({
|
|
214
|
+
id: note.id,
|
|
215
|
+
annotation: note,
|
|
216
|
+
state: manager.get(note.id).state, // pending, anchored, approximate or orphaned
|
|
217
|
+
target: rangeOf(note.id), // where it sits, for reading order
|
|
218
|
+
replyCount,
|
|
219
|
+
})),
|
|
220
|
+
onSelect: (id, item) => (item.state === "orphaned" ? offerReattach(id) : openNote(id)),
|
|
221
|
+
});
|
|
222
|
+
panel.update(items);
|
|
223
|
+
panel.close();
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
The panel is a `complementary` landmark named by its heading. It lists the page's notes in reading order, the same rule the pins follow (by line, then left to right), then notes still being found or not rendered, and puts orphaned notes in a **Detached** list with the text they were on, oldest first. Each row shows the quoted text, the note as plain text (`markdownExcerpt` strips Markdown markers without loading the parser), its author, reply count and state. Rows keep their elements across `update`, so focus stays on them. The rows are one Tab stop (the row last focused); arrow keys, Home and End move between them; Escape closes the panel and returns focus to where it was. `orderPanelItems` gives the same order without the panel, for apps that render their own.
|
|
227
|
+
|
|
228
|
+
With `actions`, choosing a row opens that note in place instead: the row shows the note's whole text and a button for each action (`{ label, run(row) }`, such as Reply with a composer opened next to the row), and choosing it again closes it. `onSelect` is still called when a row opens. The SDK's built-in UI passes Reply, Edit and Delete as the note's card would offer them, and Open thread for the card with its replies.
|
|
229
|
+
|
|
230
|
+
## Selection toolbar and composer
|
|
231
|
+
|
|
232
|
+
The writing half of the UI is a separate entry point, `@acornly/ui/compose`, loaded the first time the user selects something; only `watchSelection` sits on the read path.
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
import { lazyPart, watchSelection } from "@acornly/ui";
|
|
236
|
+
|
|
237
|
+
const compose = lazyPart(() => import("@acornly/ui/compose"));
|
|
238
|
+
|
|
239
|
+
watchSelection({
|
|
240
|
+
root: document.querySelector("main"), // selections elsewhere are ignored
|
|
241
|
+
onChange: async (range) => {
|
|
242
|
+
if (!range) return toolbar?.close();
|
|
243
|
+
const { openSelectionToolbar, openComposer } = await compose.load();
|
|
244
|
+
toolbar = openSelectionToolbar({
|
|
245
|
+
layer,
|
|
246
|
+
range,
|
|
247
|
+
root: document.querySelector("main")!,
|
|
248
|
+
motivations: ["commenting", "highlighting"],
|
|
249
|
+
shortcuts: root.shortcuts,
|
|
250
|
+
onChoose: ({ action, target, range, quote, level }) => {
|
|
251
|
+
if (action === "highlight") return ctx.add({ target, motivation: "highlighting" });
|
|
252
|
+
openComposer({
|
|
253
|
+
layer,
|
|
254
|
+
anchor: range,
|
|
255
|
+
quote,
|
|
256
|
+
level,
|
|
257
|
+
shortcuts: root.shortcuts,
|
|
258
|
+
onSubmit: ({ markdown }) =>
|
|
259
|
+
ctx.add({ target, body: { value: markdown, format: "text/markdown" } }),
|
|
260
|
+
});
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
- `watchSelection` reports a selection once it settles (the pointer is released, or keyboard selection pauses) and `null` when it is cleared. Selections in form fields, editable content and Acornly's own UI are ignored.
|
|
268
|
+
- The toolbar asks `describeBest` from `@acornly/core` how the selection would be anchored. Words get **Comment** and **Highlight**; a selection over a video or audio element gets **Note on this moment** at the player's current time (the composer's badge shows it, `Note on this moment (1:05)`), a selection with no words (an image, an empty cell) gets **Note on this region**, and one with nothing more precise than the page gets **Note on this page**. Buttons follow `motivations` (`commenting`, `highlighting`), and the chosen `target` is what the SDK's `add` takes: the Range, the element, `{ element, time }` for a moment, an anchor key, or `{ page: true }`. The toolbar does not take focus or clear the selection.
|
|
269
|
+
- The composer is a dialog with a Markdown text area and a preview, in `new`, `reply` or `edit` mode, with an optional reaction row (`onReact`). It holds no storage logic: `onSubmit` saves, and the composer closes when it resolves or stays open with the text and an error when it throws. It takes focus, keeps Tab inside and returns focus on close. The preview loads `@acornly/ui/markdown` on first use.
|
|
270
|
+
- Both follow their anchor as the page scrolls. While the anchor is hidden (the selected text re-rendered away, or a pin scrolled out of a scroller), they stay where it last was, like the card, and follow it again once it shows.
|
|
271
|
+
- Every string can be replaced through `labels`.
|
|
272
|
+
|
|
273
|
+
Keyboard shortcuts, registered in the shell's registry while the surface is open (remap or disable them with `shortcuts` on `mountRoot`):
|
|
274
|
+
|
|
275
|
+
| Command | Default | Does |
|
|
276
|
+
| --------------------- | ----------- | --------------------------------------------- |
|
|
277
|
+
| `selection.comment` | `mod+alt+m` | Comment on the selection (or its region/page) |
|
|
278
|
+
| `selection.highlight` | `mod+alt+h` | Highlight the selection |
|
|
279
|
+
| `selection.toolbar` | `alt+f10` | Move focus into the toolbar |
|
|
280
|
+
| `composer.submit` | `mod+enter` | Save the note |
|
|
281
|
+
| `composer.cancel` | `escape` | Close without saving |
|
|
282
|
+
| `composer.preview` | `mod+alt+p` | Switch between writing and the preview |
|
|
283
|
+
|
|
284
|
+
Once focus is in the toolbar (Alt+F10 or Tab), the arrow keys, Home and End move between buttons and Escape closes it and gives focus back. When two open surfaces bind the same key, the newest one wins while its `when` guard allows it (the composer's keys apply only while focus is inside it).
|
package/dist/card.d.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The note card: a popover that shows a note and its thread, placed next to its pin or highlight
|
|
3
|
+
* with Floating UI. Loaded on demand (`import("@acornly/ui/card")`), with the Markdown
|
|
4
|
+
* renderer, so neither sits on the page-load path.
|
|
5
|
+
*/
|
|
6
|
+
import { type ReferenceElement } from "@floating-ui/dom";
|
|
7
|
+
import { type Annotation, type BodyTypeLookup, type ThreadNode } from "@acornly/core";
|
|
8
|
+
/** Replies are indented up to this many levels; deeper ones are shown flat with a label. */
|
|
9
|
+
export declare const MAX_INDENT = 3;
|
|
10
|
+
export interface CardLabels {
|
|
11
|
+
close: string;
|
|
12
|
+
/** Shown on a note whose anchor was found by fuzzy matching. */
|
|
13
|
+
approximate: string;
|
|
14
|
+
edited: string;
|
|
15
|
+
/** Stands in for the body of a deleted note that still has replies. */
|
|
16
|
+
deleted: string;
|
|
17
|
+
/** The author's name when the note has none. */
|
|
18
|
+
anonymous: string;
|
|
19
|
+
/** The author's name on the viewer's own notes. */
|
|
20
|
+
me: string;
|
|
21
|
+
/** The label on a reply deeper than {@link MAX_INDENT}. */
|
|
22
|
+
replyingTo: (name: string) => string;
|
|
23
|
+
/** The accessible name of one reaction count. */
|
|
24
|
+
reaction: (emoji: string, count: number) => string;
|
|
25
|
+
reactions: string;
|
|
26
|
+
/** The card's accessible name. */
|
|
27
|
+
card: (author: string) => string;
|
|
28
|
+
reply: string;
|
|
29
|
+
edit: string;
|
|
30
|
+
remove: string;
|
|
31
|
+
/** The link to report a note, which opens the report form in a new tab. */
|
|
32
|
+
report: string;
|
|
33
|
+
/** An action button's accessible name, telling one note's from the next. */
|
|
34
|
+
action: (action: string, author: string) => string;
|
|
35
|
+
}
|
|
36
|
+
export declare const DEFAULT_CARD_LABELS: CardLabels;
|
|
37
|
+
/**
|
|
38
|
+
* What the reader can do on each note of the thread. The card only reports the choice: saving is
|
|
39
|
+
* up to the host. `button` is the button chosen, for placing a composer or a confirmation.
|
|
40
|
+
*/
|
|
41
|
+
export interface CardActions {
|
|
42
|
+
reply?: (annotation: Annotation, button: HTMLElement) => void;
|
|
43
|
+
edit?: (annotation: Annotation, button: HTMLElement) => void;
|
|
44
|
+
remove?: (annotation: Annotation, button: HTMLElement) => void;
|
|
45
|
+
/** Whether the reader may edit and delete this note, typically their own. Default: none. */
|
|
46
|
+
canChange?: (annotation: Annotation) => boolean;
|
|
47
|
+
/** Whether this note has text to edit (not a highlight or a reaction). Default: all do. */
|
|
48
|
+
canEdit?: (annotation: Annotation) => boolean;
|
|
49
|
+
/** Whether this note takes replies (the backend may keep them one level deep). Default: all do. */
|
|
50
|
+
canReply?: (annotation: Annotation) => boolean;
|
|
51
|
+
/**
|
|
52
|
+
* Where the reader reports this note, shown as a Report link after the buttons (opening in a
|
|
53
|
+
* new tab), or null for no link. Default: none.
|
|
54
|
+
*/
|
|
55
|
+
report?: (annotation: Annotation) => string | null;
|
|
56
|
+
}
|
|
57
|
+
export interface CardOptions {
|
|
58
|
+
/** The shell's layer (`layerOf(root)`). */
|
|
59
|
+
layer: HTMLElement;
|
|
60
|
+
/** What the card is placed next to: the note's pin, or a virtual element over its highlight. */
|
|
61
|
+
anchor: ReferenceElement;
|
|
62
|
+
/** The note and its replies, from `assembleThread`. */
|
|
63
|
+
thread: ThreadNode;
|
|
64
|
+
/** Mark the note as re-anchored by fuzzy matching. */
|
|
65
|
+
approximate?: boolean;
|
|
66
|
+
/** The viewer, to mark their own reactions and show their notes as {@link CardLabels.me}. */
|
|
67
|
+
viewerId?: string;
|
|
68
|
+
/** The viewer's profile photo (an https URL), shown next to their own notes. */
|
|
69
|
+
viewerPicture?: string;
|
|
70
|
+
/** App-defined body types. */
|
|
71
|
+
registry?: BodyTypeLookup;
|
|
72
|
+
/** Called once when the card closes, whatever closed it. */
|
|
73
|
+
onClose?: () => void;
|
|
74
|
+
/**
|
|
75
|
+
* Focused when the card closes. Defaults to the anchor when it is an element (a pin or a panel
|
|
76
|
+
* row), else to what had focus when the card opened. If that element has been re-rendered,
|
|
77
|
+
* the element with the same `data-id` is focused instead.
|
|
78
|
+
*/
|
|
79
|
+
returnFocusTo?: HTMLElement;
|
|
80
|
+
/** Close when the pointer goes down outside the card and its anchor. Default true. */
|
|
81
|
+
closeOnOutsideClick?: boolean;
|
|
82
|
+
labels?: Partial<CardLabels>;
|
|
83
|
+
/** Reply, Edit and Delete buttons and a Report link on each note; none by default. */
|
|
84
|
+
actions?: CardActions;
|
|
85
|
+
/** Formats a note's time. Defaults to the viewer's locale, date and time. */
|
|
86
|
+
formatTime?: (iso: string) => string;
|
|
87
|
+
}
|
|
88
|
+
export interface NoteCard {
|
|
89
|
+
/** The card element, inside the shell's layer. */
|
|
90
|
+
readonly element: HTMLElement;
|
|
91
|
+
readonly closed: boolean;
|
|
92
|
+
/** Shows a newer version of the thread, such as after a reply arrives. */
|
|
93
|
+
update(thread: ThreadNode, options?: {
|
|
94
|
+
approximate?: boolean;
|
|
95
|
+
}): void;
|
|
96
|
+
close(): void;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Opens a card for a thread next to `anchor`. Focus moves into the card and stays there (Tab
|
|
100
|
+
* cycles inside it) until it closes, on Escape, the close button, a click outside, or `close()`;
|
|
101
|
+
* focus then returns to where it was, typically the pin.
|
|
102
|
+
*/
|
|
103
|
+
export declare function openCard(options: CardOptions): NoteCard;
|
|
104
|
+
//# sourceMappingURL=card.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"card.d.ts","sourceRoot":"","sources":["../src/card.ts"],"names":[],"mappings":"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"}
|