pi-weave 0.3.9 → 0.3.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -2
- package/package.json +1 -1
- package/src/web/client/context/context.model.ts +5 -0
- package/src/web/client/dist/app.js +392 -153
- package/src/web/client/graph/Graph.tsx +143 -12
- package/src/web/client/graph/column.model.ts +58 -1
- package/src/web/client/graph/graph.model.ts +114 -0
- package/src/web/client/graph/renderer.ts +21 -7
- package/src/web/client/graph/spotlight.model.ts +472 -0
- package/src/web/client/note/Note.tsx +212 -59
- package/src/web/client/note/Replay.tsx +18 -7
- package/src/web/client/note/drafts.ts +69 -6
- package/src/web/client/note/editbar.model.ts +153 -0
- package/src/web/client/note/note.model.ts +65 -1
- package/src/web/client/note/replay.model.ts +75 -0
- package/src/web/client/search/SearchPalette.tsx +103 -34
- package/src/web/client/search/search.model.ts +533 -26
- package/src/web/client/shell/Shell.tsx +61 -19
- package/src/web/client/shell/StatusBar.tsx +1 -0
- package/src/web/client/shell/icons.model.ts +68 -1
- package/src/web/client/shell/keys.model.ts +11 -1
- package/src/web/client/shell/shell.model.ts +48 -1
- package/src/web/client/shell/theme.model.ts +3 -0
- package/src/web/client/shell/theme.ts +252 -47
- package/src/web/client/shell/workspace.css.ts +38 -11
- package/src/web/shared/themes.ts +1 -1
- package/src/web/shared/workspace.ts +10 -2
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The edit bar: the pure half (the refresh's E1).
|
|
3
|
+
*
|
|
4
|
+
* While a note is being edited, one bar under the title says so, carries the
|
|
5
|
+
* formatting buttons, shows whether the edits are on disk yet, and offers
|
|
6
|
+
* Discard and Done. Saving is automatic: a short pause in typing writes the
|
|
7
|
+
* draft (see `drafts.ts`, `keepOpen`), so Done only has to catch up and close.
|
|
8
|
+
*
|
|
9
|
+
* Everything that can be decided without a DOM is here: what the status says,
|
|
10
|
+
* and how a formatting button rewrites the text around the selection.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { IconName } from "../shell/icons.model";
|
|
14
|
+
|
|
15
|
+
/** How long typing has to pause before the draft is written. */
|
|
16
|
+
export const AUTOSAVE_MS = 2500;
|
|
17
|
+
|
|
18
|
+
export const EDITING_LABEL = "Editing";
|
|
19
|
+
export const DISCARD_EDITS_LABEL = "Discard changes";
|
|
20
|
+
export const RETRY_LABEL = "Retry";
|
|
21
|
+
/** Asked before Discard writes the opening version back over autosaved edits. */
|
|
22
|
+
export const REVERT_PROMPT = "Put this note back the way it was when you started editing?";
|
|
23
|
+
|
|
24
|
+
/** What the status slot shows. */
|
|
25
|
+
export type EditStatusKind = "saving" | "failed" | "pending" | "saved" | "clean";
|
|
26
|
+
|
|
27
|
+
export interface EditStatus {
|
|
28
|
+
kind: EditStatusKind;
|
|
29
|
+
label: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The parts of a draft the status depends on. */
|
|
33
|
+
export interface EditStatusInput {
|
|
34
|
+
saving: boolean;
|
|
35
|
+
failed: boolean;
|
|
36
|
+
/** Edits not yet on disk. */
|
|
37
|
+
dirty: boolean;
|
|
38
|
+
savedAt: number | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function clock(at: number): string {
|
|
42
|
+
return new Date(at).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The status slot, in priority order: a write in flight, a failed one, waiting edits, the last save. */
|
|
46
|
+
export function editStatus(input: EditStatusInput): EditStatus {
|
|
47
|
+
if (input.saving) return { kind: "saving", label: "Saving…" };
|
|
48
|
+
if (input.failed) return { kind: "failed", label: "Not saved" };
|
|
49
|
+
if (input.dirty) return { kind: "pending", label: "Unsaved changes" };
|
|
50
|
+
if (input.savedAt !== null) return { kind: "saved", label: `Saved ${clock(input.savedAt)}` };
|
|
51
|
+
return { kind: "clean", label: "No changes yet" };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The formatting buttons, in bar order. */
|
|
55
|
+
export type FormatKind = "heading" | "bold" | "italic" | "link" | "wikilink" | "task";
|
|
56
|
+
|
|
57
|
+
export interface FormatTool {
|
|
58
|
+
kind: FormatKind;
|
|
59
|
+
icon: IconName;
|
|
60
|
+
label: string;
|
|
61
|
+
/** The key that applies it with ⌘/Ctrl, if any. */
|
|
62
|
+
key: string | null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const FORMAT_TOOLS: readonly FormatTool[] = [
|
|
66
|
+
{ kind: "heading", icon: "heading", label: "Heading", key: null },
|
|
67
|
+
{ kind: "bold", icon: "bold", label: "Bold", key: "b" },
|
|
68
|
+
{ kind: "italic", icon: "italic", label: "Italic", key: "i" },
|
|
69
|
+
// No ⌘K: that is search, everywhere in the workspace, the editor included.
|
|
70
|
+
{ kind: "link", icon: "link", label: "Link", key: null },
|
|
71
|
+
{ kind: "wikilink", icon: "wikilink", label: "Link to a note", key: null },
|
|
72
|
+
{ kind: "task", icon: "task", label: "Task", key: null },
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
/** The tool bound to a ⌘/Ctrl key, if any. */
|
|
76
|
+
export function formatForKey(key: string): FormatKind | null {
|
|
77
|
+
return FORMAT_TOOLS.find((tool) => tool.key === key.toLowerCase())?.kind ?? null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A textarea's value and selection. */
|
|
81
|
+
export interface TextEdit {
|
|
82
|
+
value: string;
|
|
83
|
+
start: number;
|
|
84
|
+
end: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Wrap the selection, or insert a placeholder and select it so typing replaces it. */
|
|
88
|
+
function wrap(edit: TextEdit, before: string, after: string, placeholder: string): TextEdit {
|
|
89
|
+
const selected = edit.value.slice(edit.start, edit.end);
|
|
90
|
+
const inner = selected === "" ? placeholder : selected;
|
|
91
|
+
const value = edit.value.slice(0, edit.start) + before + inner + after + edit.value.slice(edit.end);
|
|
92
|
+
return { value, start: edit.start + before.length, end: edit.start + before.length + inner.length };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Rewrite every line the selection touches. A selection that ends just after
|
|
97
|
+
* a line break (a triple-clicked line) does not touch the line below it.
|
|
98
|
+
*/
|
|
99
|
+
function eachLine(edit: TextEdit, rewrite: (line: string) => string): TextEdit {
|
|
100
|
+
const from = edit.value.lastIndexOf("\n", edit.start - 1) + 1;
|
|
101
|
+
const last = edit.end > edit.start && edit.value[edit.end - 1] === "\n" ? edit.end - 1 : edit.end;
|
|
102
|
+
const newline = edit.value.indexOf("\n", last);
|
|
103
|
+
const to = newline === -1 ? edit.value.length : newline;
|
|
104
|
+
const lines = edit.value.slice(from, to).split("\n").map(rewrite).join("\n");
|
|
105
|
+
const value = edit.value.slice(0, from) + lines + edit.value.slice(to);
|
|
106
|
+
return { value, start: from, end: from + lines.length };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const HEADING_RE = /^#{1,6}[ \t]+/;
|
|
110
|
+
const TASK_RE = /^([ \t]*)[-*+][ \t]+\[[ xX]\][ \t]+/;
|
|
111
|
+
const BULLET_RE = /^([ \t]*)[-*+][ \t]+/;
|
|
112
|
+
|
|
113
|
+
/** Apply one formatting button to the text around the selection. */
|
|
114
|
+
export function applyFormat(edit: TextEdit, kind: FormatKind): TextEdit {
|
|
115
|
+
switch (kind) {
|
|
116
|
+
case "bold":
|
|
117
|
+
return wrap(edit, "**", "**", "bold text");
|
|
118
|
+
case "italic":
|
|
119
|
+
return wrap(edit, "_", "_", "italic text");
|
|
120
|
+
case "wikilink":
|
|
121
|
+
return wrap(edit, "[[", "]]", "note title");
|
|
122
|
+
case "link": {
|
|
123
|
+
const selected = edit.value.slice(edit.start, edit.end);
|
|
124
|
+
if (selected === "") return wrap(edit, "[", "](https://)", "link text");
|
|
125
|
+
// With text selected, the URL is what is left to type, so select that.
|
|
126
|
+
const value = `${edit.value.slice(0, edit.start)}[${selected}](https://)${edit.value.slice(edit.end)}`;
|
|
127
|
+
const url = edit.start + selected.length + 3;
|
|
128
|
+
return { value, start: url, end: url + "https://".length };
|
|
129
|
+
}
|
|
130
|
+
case "heading":
|
|
131
|
+
return eachLine(edit, (line) => (HEADING_RE.test(line) ? line.replace(HEADING_RE, "") : `## ${line}`));
|
|
132
|
+
case "task":
|
|
133
|
+
return eachLine(edit, (line) => {
|
|
134
|
+
if (TASK_RE.test(line)) return line.replace(TASK_RE, "$1");
|
|
135
|
+
if (BULLET_RE.test(line)) return line.replace(BULLET_RE, "$1- [ ] ");
|
|
136
|
+
return `- [ ] ${line}`;
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The one span a formatting change replaced: `before[start, end)` became
|
|
143
|
+
* `text`. The editor inserts just that span through the browser, so ⌘Z
|
|
144
|
+
* undoes the formatting; assigning the whole value would wipe the undo stack.
|
|
145
|
+
*/
|
|
146
|
+
export function replacedSpan(before: string, after: string): { start: number; end: number; text: string } {
|
|
147
|
+
const max = Math.min(before.length, after.length);
|
|
148
|
+
let head = 0;
|
|
149
|
+
while (head < max && before.charCodeAt(head) === after.charCodeAt(head)) head++;
|
|
150
|
+
let tail = 0;
|
|
151
|
+
while (tail < max - head && before.charCodeAt(before.length - 1 - tail) === after.charCodeAt(after.length - 1 - tail)) tail++;
|
|
152
|
+
return { start: head, end: before.length - tail, text: after.slice(head, after.length - tail) };
|
|
153
|
+
}
|
|
@@ -678,7 +678,51 @@ export const SANITIZE_CONFIG: SanitizeConfig = frozen({
|
|
|
678
678
|
* what gets handed over, with {@link SANITIZE_CONFIG}.
|
|
679
679
|
*/
|
|
680
680
|
export function renderNote(purify: Purifier, body: string, index: WikiIndex): string {
|
|
681
|
-
return purify.sanitize(renderMarkdown(body, index), SANITIZE_CONFIG);
|
|
681
|
+
return tldrCard(purify.sanitize(renderMarkdown(body, index), SANITIZE_CONFIG));
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// `TLDR`, `TL;DR`, either with the colon inside or after the bold, as search and the graph sheet read it.
|
|
685
|
+
const TLDR_OPEN_RE = /^(\s*<h1>[\s\S]*?<\/h1>)?\s*<p><strong>TL;?DR[ \t]*:?<\/strong>[ \t]*:?[ \t\n]*/i;
|
|
686
|
+
const LIST_TAG_RE = /<(\/?)(?:ul|ol)\b[^>]*>/g;
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Lift a note's opening TLDR into a card.
|
|
690
|
+
*
|
|
691
|
+
* Finalized notes open with a `**TLDR:**` paragraph, often followed by a list
|
|
692
|
+
* of `- [ ]` tasks (the notepad skill's format). The reading view sets that
|
|
693
|
+
* block apart so the summary is the first thing the eye lands on. Only the
|
|
694
|
+
* opening TLDR counts (optionally under a repeated `# Title`); a TLDR further
|
|
695
|
+
* down is ordinary prose.
|
|
696
|
+
*
|
|
697
|
+
* It runs on the *sanitised* HTML and only moves markup that is already
|
|
698
|
+
* there, adding one `div` and one label `span` of its own, so it opens no
|
|
699
|
+
* hole in the three layers above. The task checkboxes keep their order, and
|
|
700
|
+
* with it their `data-weave-task` indices.
|
|
701
|
+
*/
|
|
702
|
+
export function tldrCard(html: string): string {
|
|
703
|
+
const open = TLDR_OPEN_RE.exec(html);
|
|
704
|
+
if (open === null) return html;
|
|
705
|
+
const close = html.indexOf("</p>", open[0].length);
|
|
706
|
+
if (close === -1) return html;
|
|
707
|
+
const lead = open[1] ?? "";
|
|
708
|
+
const summary = html.slice(open[0].length, close);
|
|
709
|
+
let end = close + "</p>".length;
|
|
710
|
+
const list = /^\s*<(?:ul|ol)\b/.exec(html.slice(end));
|
|
711
|
+
if (list !== null) {
|
|
712
|
+
// Balanced, so a nested list stays inside the card with its parent.
|
|
713
|
+
LIST_TAG_RE.lastIndex = end;
|
|
714
|
+
let depth = 0;
|
|
715
|
+
for (let tag = LIST_TAG_RE.exec(html); tag !== null; tag = LIST_TAG_RE.exec(html)) {
|
|
716
|
+
depth += tag[1] === "/" ? -1 : 1;
|
|
717
|
+
if (depth === 0) {
|
|
718
|
+
end = tag.index + tag[0].length;
|
|
719
|
+
break;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
if (depth !== 0) return html;
|
|
723
|
+
}
|
|
724
|
+
const items = html.slice(close + "</p>".length, end);
|
|
725
|
+
return `${lead}\n<div class="weave-tldr"><p><span class="weave-tldr-label">TLDR</span>${summary}</p>${items}\n</div>${html.slice(end)}`;
|
|
682
726
|
}
|
|
683
727
|
|
|
684
728
|
// --- reading a click back out of the DOM --------------------------------------------------
|
|
@@ -877,6 +921,24 @@ export interface NoteHeaderView {
|
|
|
877
921
|
readonly created: string;
|
|
878
922
|
readonly createdIso: string;
|
|
879
923
|
readonly tags: readonly string[];
|
|
924
|
+
/** Who wrote it, as a sentence: "You wrote this". */
|
|
925
|
+
readonly byline: string;
|
|
926
|
+
/** Where it lives: "Vault / projects", or "Vault" at the root. */
|
|
927
|
+
readonly folder: string;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/** The byline under the title, in plain words rather than a provenance code. */
|
|
931
|
+
export function noteByline(source: WireNoteSource): string {
|
|
932
|
+
if (source === "human") return "You wrote this";
|
|
933
|
+
if (source === "agent") return "Agent wrote this";
|
|
934
|
+
return "Generated by pi-weave";
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
export const VAULT_LABEL = "Vault";
|
|
938
|
+
|
|
939
|
+
/** The folder crumb above the title: the slug's directories under the vault. */
|
|
940
|
+
export function noteFolder(slug: string): string {
|
|
941
|
+
return [VAULT_LABEL, ...slug.split("/").slice(0, -1)].join(" / ");
|
|
880
942
|
}
|
|
881
943
|
|
|
882
944
|
/**
|
|
@@ -902,6 +964,8 @@ export function noteHeader(note: ViewNote, now: number): NoteHeaderView {
|
|
|
902
964
|
created: relTime(note.created, now),
|
|
903
965
|
createdIso: note.created,
|
|
904
966
|
tags: note.tags,
|
|
967
|
+
byline: noteByline(note.source),
|
|
968
|
+
folder: noteFolder(note.slug),
|
|
905
969
|
};
|
|
906
970
|
}
|
|
907
971
|
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* rather than splitting `[[target]]` or `(url)`.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
+
import { splitRawTail } from "../../shared/view";
|
|
25
26
|
import type { HistoryRevision } from "../../shared/wire";
|
|
26
27
|
|
|
27
28
|
export const MARK_OPEN = "";
|
|
@@ -129,6 +130,13 @@ export interface ReplayStep {
|
|
|
129
130
|
/** The body to render, with the added text marked. */
|
|
130
131
|
marked: string;
|
|
131
132
|
by: HistoryRevision["by"];
|
|
133
|
+
/**
|
|
134
|
+
* Whether the column scrolls to the change. Not when it starts in the
|
|
135
|
+
* `## Raw` tail: dictation lands at the very end of the note, and following
|
|
136
|
+
* it dragged the reader to the bottom on every Raw step, then back up on the
|
|
137
|
+
* next edit. The added words are still marked where they are.
|
|
138
|
+
*/
|
|
139
|
+
follow: boolean;
|
|
132
140
|
}
|
|
133
141
|
|
|
134
142
|
function shortTime(iso: string, previous: string | null): string {
|
|
@@ -139,6 +147,12 @@ function shortTime(iso: string, previous: string | null): string {
|
|
|
139
147
|
return sameDay ? time : `${date.toLocaleDateString(undefined, { month: "short", day: "numeric" })}, ${time}`;
|
|
140
148
|
}
|
|
141
149
|
|
|
150
|
+
/** Whether offset `at` of `body` falls in its `## Raw` tail (or the gap just above it). */
|
|
151
|
+
export function inRawTail(body: string, at: number): boolean {
|
|
152
|
+
const { head, tail } = splitRawTail(body);
|
|
153
|
+
return tail !== "" && at >= head.length;
|
|
154
|
+
}
|
|
155
|
+
|
|
142
156
|
/** Describe revision `index`. Clamped, so a stale index after a refetch still shows something. */
|
|
143
157
|
export function replayStep(revisions: readonly HistoryRevision[], index: number): ReplayStep | null {
|
|
144
158
|
if (revisions.length === 0) return null;
|
|
@@ -157,9 +171,70 @@ export function replayStep(revisions: readonly HistoryRevision[], index: number)
|
|
|
157
171
|
change,
|
|
158
172
|
marked: markChange(previous?.body ?? null, revision.body),
|
|
159
173
|
by: revision.by,
|
|
174
|
+
follow: !inRawTail(revision.body, range.start),
|
|
160
175
|
};
|
|
161
176
|
}
|
|
162
177
|
|
|
178
|
+
/** One bar of the history band: how much a version changed, and who changed it. */
|
|
179
|
+
export interface ChangeBar {
|
|
180
|
+
/** 0–1 of the band's height; never so small that a version disappears. */
|
|
181
|
+
height: number;
|
|
182
|
+
by: HistoryRevision["by"];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The shortest bar, so a one-character fix still reads as a version. */
|
|
186
|
+
export const MIN_BAR = 0.14;
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Bar heights for the history band (the refresh's "change bars"). Height
|
|
190
|
+
* follows how many characters a version added and removed, on a log scale so
|
|
191
|
+
* one big rewrite does not flatten every other bar into the floor. The first
|
|
192
|
+
* version counts its whole body.
|
|
193
|
+
*/
|
|
194
|
+
export function changeBars(revisions: readonly HistoryRevision[]): ChangeBar[] {
|
|
195
|
+
const sizes = revisions.map((revision, i) => {
|
|
196
|
+
const range = changedRange(i === 0 ? "" : (revisions[i - 1] as HistoryRevision).body, revision.body);
|
|
197
|
+
return range.end - range.start + range.removed;
|
|
198
|
+
});
|
|
199
|
+
const top = Math.log1p(Math.max(0, ...sizes));
|
|
200
|
+
return revisions.map((revision, i) => ({
|
|
201
|
+
height: top === 0 ? MIN_BAR : MIN_BAR + (1 - MIN_BAR) * (Math.log1p(sizes[i] as number) / top),
|
|
202
|
+
by: revision.by,
|
|
203
|
+
}));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** A day label under the band, at a fraction of its width. */
|
|
207
|
+
export interface DayMark {
|
|
208
|
+
/** 0–1 along the band: version index over the last index. */
|
|
209
|
+
at: number;
|
|
210
|
+
label: string;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Where each day starts along the band, labelled "Today", "Yesterday" or a
|
|
215
|
+
* short date. The first version always gets a label, so the band says when
|
|
216
|
+
* the history begins.
|
|
217
|
+
*/
|
|
218
|
+
export function dayMarks(revisions: readonly HistoryRevision[], now: number): DayMark[] {
|
|
219
|
+
const today = new Date(now);
|
|
220
|
+
const yesterday = new Date(now - 86_400_000);
|
|
221
|
+
const last = Math.max(1, revisions.length - 1);
|
|
222
|
+
const marks: DayMark[] = [];
|
|
223
|
+
let previous = "";
|
|
224
|
+
revisions.forEach((revision, i) => {
|
|
225
|
+
const date = new Date(revision.at);
|
|
226
|
+
if (Number.isNaN(date.getTime())) return;
|
|
227
|
+
const day = date.toDateString();
|
|
228
|
+
if (day === previous) return;
|
|
229
|
+
previous = day;
|
|
230
|
+
const label = day === today.toDateString() ? "Today"
|
|
231
|
+
: day === yesterday.toDateString() ? "Yesterday"
|
|
232
|
+
: date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
|
233
|
+
marks.push({ at: revisions.length === 1 ? 0 : i / last, label });
|
|
234
|
+
});
|
|
235
|
+
return marks;
|
|
236
|
+
}
|
|
237
|
+
|
|
163
238
|
/** The next index while playing, or null at the end. */
|
|
164
239
|
export function nextStep(index: number, count: number): number | null {
|
|
165
240
|
return index + 1 < count ? index + 1 : null;
|
|
@@ -1,32 +1,72 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from "preact/hooks";
|
|
2
2
|
import type { GraphPayload } from "../../shared/wire";
|
|
3
3
|
import { useFocusTrap } from "../shell/FocusTrap";
|
|
4
|
+
import { ICON_BOX, ICON_STROKE } from "../shell/icons.model";
|
|
5
|
+
import type { IconDef } from "../shell/icons.model";
|
|
4
6
|
import { fetchSearch } from "../api";
|
|
5
7
|
import type { FetchLike } from "../api";
|
|
6
|
-
import type { SearchRowView } from "./search.model";
|
|
7
|
-
import {
|
|
8
|
-
|
|
8
|
+
import type { PaletteIntent, SearchRowView, SearchState } from "./search.model";
|
|
9
|
+
import {
|
|
10
|
+
DEBOUNCE_MS,
|
|
11
|
+
LIGHT_UP_KEY,
|
|
12
|
+
PALETTE_HINT,
|
|
13
|
+
PALETTE_PLACEHOLDER,
|
|
14
|
+
PALETTE_TITLE,
|
|
15
|
+
SCOPE_GROUP_LABEL,
|
|
16
|
+
SEARCH_GLYPH,
|
|
17
|
+
closesPalette,
|
|
18
|
+
initialSearchState,
|
|
19
|
+
lightUpIntent,
|
|
20
|
+
lightUpLabel,
|
|
21
|
+
nextState,
|
|
22
|
+
paletteKey,
|
|
23
|
+
paletteModel,
|
|
24
|
+
rowIntent,
|
|
25
|
+
serverQuery,
|
|
26
|
+
} from "./search.model";
|
|
9
27
|
|
|
10
28
|
export interface SearchPaletteProps {
|
|
11
29
|
graph: GraphPayload | null;
|
|
12
30
|
onSelect: (id: string, newTab?: boolean) => void;
|
|
13
31
|
onClose: () => void;
|
|
32
|
+
/** Light the query's matches (`ids`, the results) up in the graph. The footer offers it only when this is given. */
|
|
33
|
+
onLightUp?: (query: string, ids: readonly string[]) => void;
|
|
14
34
|
ports: { fetch: FetchLike };
|
|
15
35
|
}
|
|
16
36
|
|
|
37
|
+
function Glyph({ def, class: className }: { def: IconDef; class: string }) {
|
|
38
|
+
return (
|
|
39
|
+
<svg
|
|
40
|
+
class={className}
|
|
41
|
+
width={ICON_BOX}
|
|
42
|
+
height={ICON_BOX}
|
|
43
|
+
viewBox={`0 0 ${ICON_BOX} ${ICON_BOX}`}
|
|
44
|
+
fill={def.filled ? "currentColor" : "none"}
|
|
45
|
+
stroke={def.filled ? undefined : "currentColor"}
|
|
46
|
+
stroke-width={def.filled ? undefined : def.width ?? ICON_STROKE}
|
|
47
|
+
stroke-linecap="round"
|
|
48
|
+
stroke-linejoin="round"
|
|
49
|
+
aria-hidden="true"
|
|
50
|
+
>
|
|
51
|
+
{def.d.map((d) => <path d={d} />)}
|
|
52
|
+
</svg>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
17
56
|
function Row({ row, onPick, onHover }: { row: SearchRowView; onPick: (newTab: boolean) => void; onHover: () => void }) {
|
|
18
57
|
return (
|
|
19
58
|
<li
|
|
20
59
|
id={row.domId}
|
|
21
60
|
role="option"
|
|
22
61
|
aria-selected={row.active}
|
|
23
|
-
class={`weave-hit weave-hit-${row.
|
|
62
|
+
class={`weave-hit weave-hit-${row.group}${row.active ? " weave-hit-on" : ""}`}
|
|
24
63
|
onMouseMove={onHover}
|
|
25
64
|
onClick={(event) => onPick(event.metaKey || event.ctrlKey)}
|
|
26
65
|
>
|
|
27
|
-
<
|
|
28
|
-
<span class="weave-hit-label">{row.label}</span>
|
|
29
|
-
<span class="weave-hit-detail">{row.
|
|
66
|
+
<Glyph def={row.icon} class="weave-icon weave-hit-icon" />
|
|
67
|
+
<span class={row.mono ? "weave-hit-label weave-hit-mono" : "weave-hit-label"}>{row.label}</span>
|
|
68
|
+
<span class="weave-hit-detail">{row.parts.map((part) => part.mark ? <mark class="weave-hit-mark">{part.text}</mark> : part.text)}</span>
|
|
69
|
+
{row.meta ? <span class="weave-hit-meta">{row.meta}</span> : null}
|
|
30
70
|
</li>
|
|
31
71
|
);
|
|
32
72
|
}
|
|
@@ -41,7 +81,7 @@ export function SearchPalette(props: SearchPaletteProps) {
|
|
|
41
81
|
useEffect(() => void input.current?.focus(), []);
|
|
42
82
|
|
|
43
83
|
useEffect(() => {
|
|
44
|
-
const query = state.query
|
|
84
|
+
const query = serverQuery(state.query);
|
|
45
85
|
let active = true;
|
|
46
86
|
if (!query) {
|
|
47
87
|
abort.current?.abort();
|
|
@@ -69,15 +109,17 @@ export function SearchPalette(props: SearchPaletteProps) {
|
|
|
69
109
|
};
|
|
70
110
|
}, [state.query, props.ports.fetch]);
|
|
71
111
|
|
|
72
|
-
const model = paletteModel(state, props.graph);
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
if (
|
|
76
|
-
props.onClose();
|
|
112
|
+
const model = paletteModel(state, props.graph, Date.now());
|
|
113
|
+
const run = (intent: PaletteIntent): void => {
|
|
114
|
+
if (intent.type === "open") props.onSelect(intent.id, intent.newTab);
|
|
115
|
+
if (intent.type === "lightUp") props.onLightUp?.(intent.query, intent.ids);
|
|
116
|
+
if (closesPalette(intent)) props.onClose();
|
|
117
|
+
else setState((current) => nextState(current, intent));
|
|
118
|
+
if (intent.type === "scope" || intent.type === "tag") input.current?.focus();
|
|
77
119
|
};
|
|
78
120
|
|
|
79
121
|
return (
|
|
80
|
-
<div class="weave-scrim" onClick={() =>
|
|
122
|
+
<div class="weave-scrim" onClick={() => run({ type: "close" })}>
|
|
81
123
|
<div
|
|
82
124
|
class="weave-palette"
|
|
83
125
|
role="dialog"
|
|
@@ -87,36 +129,63 @@ export function SearchPalette(props: SearchPaletteProps) {
|
|
|
87
129
|
ref={trap.ref as { current: HTMLDivElement | null }}
|
|
88
130
|
onClick={(event) => event.stopPropagation()}
|
|
89
131
|
onKeyDown={(event) => {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
132
|
+
const intent = paletteKey(model, { key: event.key, shift: event.shiftKey, mod: event.metaKey || event.ctrlKey, inInput: event.target === input.current }, props.onLightUp !== undefined);
|
|
133
|
+
if (intent.type === "none") {
|
|
134
|
+
trap.onKeyDown(event as unknown as KeyboardEvent);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
93
137
|
event.preventDefault();
|
|
94
|
-
|
|
95
|
-
if (next.dismiss) pick(next.activate, event.metaKey || event.ctrlKey);
|
|
138
|
+
run(intent);
|
|
96
139
|
}}
|
|
97
140
|
>
|
|
98
|
-
<
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
141
|
+
<div class="weave-palette-bar">
|
|
142
|
+
<Glyph def={SEARCH_GLYPH} class="weave-icon weave-palette-glyph" />
|
|
143
|
+
<input
|
|
144
|
+
type="search"
|
|
145
|
+
class="weave-palette-input"
|
|
146
|
+
ref={input}
|
|
147
|
+
value={state.query}
|
|
148
|
+
placeholder={PALETTE_PLACEHOLDER}
|
|
149
|
+
role="combobox"
|
|
150
|
+
aria-label={PALETTE_TITLE}
|
|
151
|
+
aria-expanded={model.count > 0}
|
|
152
|
+
aria-autocomplete="list"
|
|
153
|
+
aria-controls="weave-search-results"
|
|
154
|
+
aria-activedescendant={model.activeDomId ?? undefined}
|
|
155
|
+
onInput={(event) => setState((current) => ({ ...current, query: event.currentTarget.value, cursor: 0 }))}
|
|
156
|
+
/>
|
|
157
|
+
<kbd class="weave-palette-kbd" aria-hidden="true">Esc</kbd>
|
|
158
|
+
</div>
|
|
159
|
+
{model.scopes.length ? (
|
|
160
|
+
<div class="weave-palette-scopes" role="group" aria-label={SCOPE_GROUP_LABEL}>
|
|
161
|
+
{model.scopes.map((scope) => (
|
|
162
|
+
<button key={scope.scope} type="button" class="weave-palette-scope" aria-pressed={scope.active} onClick={() => run({ type: "scope", scope: scope.scope })}>
|
|
163
|
+
{scope.label} <span class="weave-palette-scope-count">{scope.count}</span>
|
|
164
|
+
</button>
|
|
165
|
+
))}
|
|
166
|
+
</div>
|
|
167
|
+
) : null}
|
|
109
168
|
{model.status === null ? (
|
|
110
169
|
<ul id="weave-search-results" class="weave-hits" role="listbox" aria-label={PALETTE_TITLE}>
|
|
111
|
-
{model.
|
|
170
|
+
{model.groups.map((group) => (
|
|
171
|
+
<li key={group.group} role="presentation" class="weave-hit-section">
|
|
172
|
+
<div id={group.domId} role="presentation" class="weave-hit-group">{group.heading}</div>
|
|
173
|
+
<ul role="group" aria-labelledby={group.domId} class="weave-hit-rows">
|
|
174
|
+
{group.rows.map((row) => <Row key={row.id} row={row} onPick={(newTab) => run(rowIntent(row, newTab))} onHover={() => run({ type: "move", cursor: row.index })} />)}
|
|
175
|
+
</ul>
|
|
176
|
+
</li>
|
|
177
|
+
))}
|
|
112
178
|
</ul>
|
|
113
179
|
) : (
|
|
114
180
|
<p class="weave-palette-status" role="status">{model.status}</p>
|
|
115
181
|
)}
|
|
116
182
|
<p class="weave-palette-foot">
|
|
117
|
-
<span>{
|
|
118
|
-
|
|
119
|
-
|
|
183
|
+
<span>{PALETTE_HINT}</span>
|
|
184
|
+
{props.onLightUp && model.lightUp.length > 0 ? (
|
|
185
|
+
<button type="button" class="weave-palette-light" onClick={() => run(lightUpIntent(model))}>
|
|
186
|
+
{lightUpLabel(model.lightUp.length)} <kbd class="weave-palette-kbd">{LIGHT_UP_KEY}</kbd>
|
|
187
|
+
</button>
|
|
188
|
+
) : null}
|
|
120
189
|
</p>
|
|
121
190
|
</div>
|
|
122
191
|
</div>
|