@valentinkolb/cloud 0.4.0 → 0.5.1
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/package.json +18 -6
- package/scripts/preload.ts +78 -23
- package/src/_internal/define-app.ts +53 -46
- package/src/api/accounts-entities.ts +4 -0
- package/src/api/admin-core-settings.ts +98 -0
- package/src/api/announcements.ts +131 -0
- package/src/api/auth/schemas.ts +24 -0
- package/src/api/auth.ts +116 -13
- package/src/api/index.ts +7 -2
- package/src/api/me.ts +203 -14
- package/src/api/search/schemas.ts +1 -0
- package/src/api/search.ts +62 -8
- package/src/config/ssr.ts +2 -9
- package/src/contracts/announcements.test.ts +37 -0
- package/src/contracts/announcements.ts +121 -0
- package/src/contracts/app.ts +2 -0
- package/src/contracts/index.ts +3 -2
- package/src/contracts/registry.ts +2 -0
- package/src/contracts/shared.ts +108 -1
- package/src/desktop/index.ts +704 -0
- package/src/desktop/solid.tsx +938 -0
- package/src/server/api/index.ts +1 -1
- package/src/server/api/respond.ts +50 -10
- package/src/server/index.ts +44 -38
- package/src/server/middleware/auth.ts +98 -9
- package/src/server/middleware/index.ts +2 -1
- package/src/server/middleware/settings.ts +26 -0
- package/src/server/services/access.test.ts +197 -0
- package/src/server/services/access.ts +254 -6
- package/src/server/services/index.ts +14 -11
- package/src/server/services/pagination.ts +22 -0
- package/src/server/time.ts +45 -0
- package/src/services/account-lifecycle/index.ts +142 -18
- package/src/services/accounts/app.ts +658 -170
- package/src/services/accounts/authz.test.ts +77 -0
- package/src/services/accounts/authz.ts +22 -0
- package/src/services/accounts/entities.ts +84 -5
- package/src/services/accounts/groups.ts +30 -24
- package/src/services/accounts/model.test.ts +30 -0
- package/src/services/accounts/switching.test.ts +14 -0
- package/src/services/accounts/switching.ts +15 -6
- package/src/services/accounts/users.ts +75 -52
- package/src/services/announcements/index.test.ts +32 -0
- package/src/services/announcements/index.ts +224 -0
- package/src/services/audit/index.test.ts +84 -0
- package/src/services/audit/index.ts +431 -0
- package/src/services/auth-flows/index.ts +9 -2
- package/src/services/auth-flows/ipa.ts +47 -7
- package/src/services/auth-flows/magic-link.ts +92 -20
- package/src/services/auth-flows/password-reset.ts +284 -0
- package/src/services/auth-flows/proxy-return.test.ts +24 -0
- package/src/services/auth-flows/proxy-return.ts +49 -0
- package/src/services/gateway.ts +162 -0
- package/src/services/index.ts +44 -2
- package/src/services/ipa/effective-groups.test.ts +33 -0
- package/src/services/ipa/effective-groups.ts +70 -0
- package/src/services/ipa/profile.ts +45 -3
- package/src/services/ipa/search.ts +3 -5
- package/src/services/ipa/service-account.ts +15 -0
- package/src/services/ipa/sync-planning.test.ts +32 -0
- package/src/services/ipa/sync-planning.ts +22 -0
- package/src/services/ipa/sync.ts +110 -38
- package/src/services/notifications/index.ts +82 -11
- package/src/services/oauth-tokens.ts +104 -0
- package/src/services/postgres.ts +21 -6
- package/src/services/providers/local/auth.test.ts +22 -0
- package/src/services/providers/local/auth.ts +46 -3
- package/src/services/secrets.ts +10 -0
- package/src/services/service-account-credentials.test.ts +210 -0
- package/src/services/service-account-credentials.ts +715 -0
- package/src/services/service-accounts.ts +188 -0
- package/src/services/session/index.ts +7 -8
- package/src/services/settings/app.ts +4 -20
- package/src/services/settings/defaults.ts +79 -22
- package/src/services/settings/store.ts +47 -0
- package/src/services/weather/forecast.ts +40 -7
- package/src/services/webauthn.test.ts +36 -0
- package/src/services/webauthn.ts +384 -0
- package/src/shared/icons.ts +391 -100
- package/src/shared/index.ts +7 -0
- package/src/shared/markdown/extensions/code.ts +38 -1
- package/src/shared/markdown/extensions/images.ts +39 -3
- package/src/shared/markdown/extensions/info-blocks.ts +5 -5
- package/src/shared/markdown/extensions/mark.ts +48 -0
- package/src/shared/markdown/extensions/sub-sup.ts +60 -0
- package/src/shared/markdown/extensions/tables.ts +79 -58
- package/src/shared/markdown/formula.test.ts +1089 -0
- package/src/shared/markdown/formula.ts +1187 -0
- package/src/shared/markdown/index.ts +76 -2
- package/src/shared/mock-cover.ts +130 -0
- package/src/shared/redirect.test.ts +58 -0
- package/src/shared/redirect.ts +56 -0
- package/src/shared/theme.test.ts +24 -0
- package/src/shared/theme.ts +68 -0
- package/src/shared/time.ts +13 -0
- package/src/ssr/AdminLayout.tsx +7 -3
- package/src/ssr/AdminSidebar.tsx +115 -49
- package/src/ssr/AppLaunchpad.island.tsx +176 -0
- package/src/ssr/Footer.island.tsx +3 -8
- package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
- package/src/ssr/GlobalSearchDialog.tsx +545 -117
- package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
- package/src/ssr/Layout.tsx +74 -66
- package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
- package/src/ssr/LayoutHelp.tsx +266 -0
- package/src/ssr/NavMenu.island.tsx +0 -39
- package/src/ssr/ThemeToggleRail.island.tsx +3 -3
- package/src/ssr/TimezoneCookie.island.tsx +23 -0
- package/src/ssr/islands/index.ts +13 -0
- package/src/styles/base-popover.css +5 -2
- package/src/styles/effects.css +87 -6
- package/src/styles/global.css +146 -9
- package/src/styles/input.css +3 -1
- package/src/styles/utilities-buttons.css +133 -27
- package/src/styles/utilities-code-display.css +67 -0
- package/src/styles/utilities-completion.css +223 -0
- package/src/styles/utilities-detail.css +73 -0
- package/src/styles/utilities-feedback.css +16 -15
- package/src/styles/utilities-layout.css +42 -2
- package/src/styles/utilities-markdown-editor.css +472 -0
- package/src/styles/utilities-navigation.css +63 -8
- package/src/styles/utilities-script.css +84 -0
- package/src/styles/utilities-table-tile.css +229 -0
- package/src/types/ambient.d.ts +9 -0
- package/src/ui/completion/behaviors.test.ts +95 -0
- package/src/ui/completion/behaviors.ts +205 -0
- package/src/ui/completion/engine.ts +368 -0
- package/src/ui/completion/index.ts +40 -0
- package/src/ui/completion/overlay.ts +92 -0
- package/src/ui/dialog-core.ts +173 -45
- package/src/ui/filter/FilterChip.tsx +42 -40
- package/src/ui/index.ts +11 -12
- package/src/ui/input/AutocompleteEditor.tsx +656 -0
- package/src/ui/input/CheckboxCard.tsx +91 -0
- package/src/ui/input/Combobox.tsx +375 -0
- package/src/ui/input/DatePicker.tsx +846 -0
- package/src/ui/input/DateTimeInput.tsx +29 -4
- package/src/ui/input/FileDropzone.tsx +116 -0
- package/src/ui/input/IconInput.tsx +116 -0
- package/src/ui/input/ImageInput.tsx +19 -2
- package/src/ui/input/MultiSelectInput.tsx +448 -0
- package/src/ui/input/NumberInput.tsx +417 -61
- package/src/ui/input/SegmentedControl.tsx +2 -2
- package/src/ui/input/Select.tsx +172 -10
- package/src/ui/input/Slider.tsx +3 -4
- package/src/ui/input/Switch.tsx +3 -2
- package/src/ui/input/TemplateEditor.tsx +212 -0
- package/src/ui/input/TextInput.tsx +144 -13
- package/src/ui/input/index.ts +53 -8
- package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
- package/src/ui/input/markdown/Toolbar.tsx +90 -0
- package/src/ui/input/markdown/actions.ts +233 -0
- package/src/ui/input/markdown/active-formats.ts +94 -0
- package/src/ui/input/markdown/behaviors.ts +193 -0
- package/src/ui/input/markdown/code-zone.ts +23 -0
- package/src/ui/input/markdown/highlight.ts +316 -0
- package/src/ui/layout.ts +22 -0
- package/src/ui/misc/AppOverview.tsx +105 -0
- package/src/ui/misc/AppWorkspace.tsx +607 -0
- package/src/ui/misc/Calendar.tsx +1291 -0
- package/src/ui/misc/Chart.tsx +162 -0
- package/src/ui/misc/CodeDisplay.tsx +54 -0
- package/src/ui/misc/ContextMenu.tsx +2 -2
- package/src/ui/misc/DataTable.tsx +269 -0
- package/src/ui/misc/DockWorkspace.tsx +425 -0
- package/src/ui/misc/Docs.tsx +153 -0
- package/src/ui/misc/Dropdown.tsx +2 -2
- package/src/ui/misc/EntitySearch.tsx +260 -129
- package/src/ui/misc/LinkCard.tsx +14 -2
- package/src/ui/misc/LogEntriesTable.tsx +34 -31
- package/src/ui/misc/Pagination.tsx +31 -12
- package/src/ui/misc/PanelDialog.tsx +109 -0
- package/src/ui/misc/Panes.tsx +873 -0
- package/src/ui/misc/PermissionEditor.tsx +358 -262
- package/src/ui/misc/Placeholder.tsx +40 -0
- package/src/ui/misc/ProgressBar.tsx +1 -1
- package/src/ui/misc/ResourceApiKeys.tsx +260 -0
- package/src/ui/misc/SettingsModal.tsx +150 -0
- package/src/ui/misc/StatCell.tsx +182 -40
- package/src/ui/misc/StatGrid.tsx +149 -0
- package/src/ui/misc/StructuredDataPreview.tsx +107 -0
- package/src/ui/misc/code-highlight.ts +213 -0
- package/src/ui/misc/index.ts +93 -12
- package/src/ui/prompts.tsx +362 -312
- package/src/ui/toast.ts +384 -0
- package/src/ui/widgets/Widget.tsx +12 -4
- package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
- package/src/ui/ipa/GroupView.tsx +0 -36
- package/src/ui/ipa/LoginBtn.tsx +0 -16
- package/src/ui/ipa/UserView.tsx +0 -58
- package/src/ui/ipa/index.ts +0 -4
- package/src/ui/navigation.ts +0 -32
- package/src/ui/sidebar.tsx +0 -468
- /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic completion engine — pure logic, no DOM mutation, no timers.
|
|
3
|
+
*
|
|
4
|
+
* Used by `<MarkdownEditor>` for markdown autocompletion and by the
|
|
5
|
+
* standalone `<AutocompleteEditor>` for any plain-text use case
|
|
6
|
+
* (formulas, code, search builders, mentions, etc.). The engine has
|
|
7
|
+
* no opinions on syntax: it walks a textarea-like state, classifies
|
|
8
|
+
* the current "token" at the caret, and delegates to user-supplied
|
|
9
|
+
* `suggest` functions for the actual data.
|
|
10
|
+
*
|
|
11
|
+
* Three behaviours drive the same `Completion` type:
|
|
12
|
+
*
|
|
13
|
+
* 1. **Auto-expand** — Plain (trigger-less) completions whose
|
|
14
|
+
* suggestions have an `expansion` field. Typing a word-boundary
|
|
15
|
+
* char after a matching word replaces it verbatim (handled by
|
|
16
|
+
* `behaviors.tryExpand`).
|
|
17
|
+
* 2. **Ghost preview** — The closest matching suggestion is shown
|
|
18
|
+
* inline at the caret in dim text. Tab inserts it.
|
|
19
|
+
* 3. **Dropdown** — Per-completion opt-in via `dropdown: true`.
|
|
20
|
+
* Lists all matches; arrow keys cycle; Enter/Tab insert.
|
|
21
|
+
*
|
|
22
|
+
* Sync and async resolution
|
|
23
|
+
* -------------------------
|
|
24
|
+
* `Completion.suggest` may return either `Suggestion[]` or
|
|
25
|
+
* `Promise<Suggestion[]>`. Sync callers should use `suggestSync` (fast
|
|
26
|
+
* path — returns `null` for async results). Editors that need to
|
|
27
|
+
* support async fetches use `resolveSuggestions` and orchestrate
|
|
28
|
+
* debounce + AbortController themselves.
|
|
29
|
+
*
|
|
30
|
+
* The engine deliberately does NOT debounce or abort — those are
|
|
31
|
+
* UI-level concerns. Keeping the engine free of timers makes it
|
|
32
|
+
* trivially testable and reusable.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/* ── Public types ────────────────────────────────────────── */
|
|
36
|
+
|
|
37
|
+
export type Suggestion = {
|
|
38
|
+
/** Shown in the ghost preview and inserted when the user accepts.
|
|
39
|
+
* For triggered completions, includes the trigger char (e.g.
|
|
40
|
+
* `"#alice"`, not `"alice"`). */
|
|
41
|
+
text: string;
|
|
42
|
+
/** Optional override for the dropdown's display label. When not
|
|
43
|
+
* set, the dropdown shows `text` with the active trigger char
|
|
44
|
+
* stripped (so `(revenue` displays as `revenue`, `@alice` as
|
|
45
|
+
* `alice`). Set explicitly if you want the trigger visible in the
|
|
46
|
+
* row (`label: text`) or want a completely different label. */
|
|
47
|
+
label?: string;
|
|
48
|
+
/** Optional auto-expand target. When set, the engine replaces a
|
|
49
|
+
* verbatim word match with this on word boundary, and Tab-accept
|
|
50
|
+
* inserts `expansion` instead of `text`. */
|
|
51
|
+
expansion?: string;
|
|
52
|
+
/** Whether accepting the suggestion should append a separating space. Default true. */
|
|
53
|
+
appendSpace?: boolean;
|
|
54
|
+
/** Optional sub-label for dropdown rows (e.g. type hint, category). */
|
|
55
|
+
hint?: string;
|
|
56
|
+
/** Optional explicit replacement range. Editors that support this
|
|
57
|
+
* apply `text` to `[start, end)` instead of replacing the detected
|
|
58
|
+
* prefix. Intended for language-server-style completions. */
|
|
59
|
+
textEdit?: {
|
|
60
|
+
start: number;
|
|
61
|
+
end: number;
|
|
62
|
+
text: string;
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Context passed to a `Completion.suggest` call. Lets advanced consumers
|
|
68
|
+
* reach beyond the typed query (e.g. detect surrounding tokens, check
|
|
69
|
+
* for "inside SUM()" state in a formula, look up surrounding markdown
|
|
70
|
+
* context, etc.) without the engine knowing about any of it.
|
|
71
|
+
*/
|
|
72
|
+
export type SuggestContext = {
|
|
73
|
+
/** Full text content of the editor at the moment `suggest` was called. */
|
|
74
|
+
fullText: string;
|
|
75
|
+
/** Cursor position (`selectionStart`) at the moment of the call. */
|
|
76
|
+
caret: number;
|
|
77
|
+
/** Where the active token begins in `fullText` — the trigger char's
|
|
78
|
+
* position for triggered completions, or the word start for plain. */
|
|
79
|
+
tokenStart: number;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export type Completion = {
|
|
83
|
+
/** Character that activates this completion. Omit for the trigger-less
|
|
84
|
+
* "plain word" mode (abbreviation-style). At most one plain completion
|
|
85
|
+
* is honoured per editor; additional ones are ignored. */
|
|
86
|
+
trigger?: string;
|
|
87
|
+
/**
|
|
88
|
+
* Return suggestions for `query`. May be sync or async. Called with
|
|
89
|
+
* empty `query` when the editor scans for "known labels" (document
|
|
90
|
+
* highlight). Sync callers fast-path; async callers go through the
|
|
91
|
+
* debounced path. The `signal` is set up by the caller — abort it
|
|
92
|
+
* yourself in long-running fetches to avoid races.
|
|
93
|
+
*/
|
|
94
|
+
suggest: (query: string, ctx: SuggestContext, signal: AbortSignal) => Suggestion[] | Promise<Suggestion[]>;
|
|
95
|
+
/** When `true`, the editor opens a caret-anchored dropdown listing
|
|
96
|
+
* all matches. Default `false` — only the inline ghost preview shows. */
|
|
97
|
+
dropdown?: boolean;
|
|
98
|
+
/** By default a triggered completion only activates when the
|
|
99
|
+
* trigger char is NOT preceded by another word char — so `foo@bar`
|
|
100
|
+
* doesn't fire the `@` completion mid-email. Set `true` to allow
|
|
101
|
+
* activation even directly after a word, e.g. `SUM(` should fire
|
|
102
|
+
* the `(` completion to autocomplete column references. */
|
|
103
|
+
allowAfterWord?: boolean;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** Returned by `detectQuery` — describes what completion run the caret
|
|
107
|
+
* is currently inside. */
|
|
108
|
+
export type QueryContext = {
|
|
109
|
+
/** Where the matched run begins in the editor text. Includes the
|
|
110
|
+
* trigger char position for triggered completions. */
|
|
111
|
+
start: number;
|
|
112
|
+
/** Where the run ends (== caret). */
|
|
113
|
+
end: number;
|
|
114
|
+
/** The typed run as it appears in the text (`"@al"`, `"mfg"`). For
|
|
115
|
+
* triggered completions this includes the trigger character. */
|
|
116
|
+
text: string;
|
|
117
|
+
/** Query passed to `suggest` — for triggered completions this is the
|
|
118
|
+
* part AFTER the trigger; for plain it equals `text`. */
|
|
119
|
+
query: string;
|
|
120
|
+
/** Which completion this run belongs to. */
|
|
121
|
+
completion: Completion;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/* ── Helpers ────────────────────────────────────────────── */
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Convenience: turn a `{ short: long }` dictionary into a plain
|
|
128
|
+
* (trigger-less) `Completion`. Each entry becomes a suggestion with
|
|
129
|
+
* `expansion` set, so it participates in both ghost preview and
|
|
130
|
+
* word-boundary auto-expand.
|
|
131
|
+
*
|
|
132
|
+
* Case-insensitive lookup with exact-case preference.
|
|
133
|
+
*/
|
|
134
|
+
export const abbreviations = (dict: Record<string, string>): Completion => {
|
|
135
|
+
const keys = Object.keys(dict);
|
|
136
|
+
const suggestions: Suggestion[] = keys.map((key) => ({ text: key, expansion: dict[key]! }));
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
suggest: (query: string) => {
|
|
140
|
+
if (query === "") return suggestions;
|
|
141
|
+
const lowerQ = query.toLowerCase();
|
|
142
|
+
const out: Suggestion[] = [];
|
|
143
|
+
for (const s of suggestions) {
|
|
144
|
+
if (s.text === query || s.text.toLowerCase().startsWith(lowerQ)) out.push(s);
|
|
145
|
+
}
|
|
146
|
+
out.sort((a, b) => {
|
|
147
|
+
const aExact = a.text.startsWith(query) ? 0 : 1;
|
|
148
|
+
const bExact = b.text.startsWith(query) ? 0 : 1;
|
|
149
|
+
return aExact - bExact;
|
|
150
|
+
});
|
|
151
|
+
return out;
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/* ── Constants ──────────────────────────────────────────── */
|
|
157
|
+
|
|
158
|
+
/** Characters that close a word and may trigger an abbreviation
|
|
159
|
+
* expansion. Anything "punctuation-y" outside the word body counts. */
|
|
160
|
+
export const TRIGGER_CHARS = new Set([" ", "\t", "\n", ",", ".", "!", "?", ";", ":", ")", "]", "}", '"', "'"]);
|
|
161
|
+
|
|
162
|
+
/** Unicode word-char regex — letters, numbers, underscore. */
|
|
163
|
+
export const WORD_CHAR = /[\p{L}\p{N}_]/u;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Sentinel placed at the cursor when rendering an overlay preview so
|
|
167
|
+
* ghost / anchor HTML can be substituted in post-render. PUA codepoint
|
|
168
|
+
* so it can't appear in user input or be matched by any text regex.
|
|
169
|
+
*/
|
|
170
|
+
export const GHOST_SENTINEL = String.fromCharCode(0xe010);
|
|
171
|
+
|
|
172
|
+
/* ── Query detection ────────────────────────────────────── */
|
|
173
|
+
|
|
174
|
+
export type DetectOptions = {
|
|
175
|
+
/** Optional predicate: when it returns true for `(text, pos)`, the
|
|
176
|
+
* engine skips that position entirely (used by MarkdownEditor to
|
|
177
|
+
* disable completions inside code spans / fences). Default: never
|
|
178
|
+
* exclude. */
|
|
179
|
+
isExcluded?: (text: string, pos: number) => boolean;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Inspect the textarea state and figure out which completion (if any)
|
|
184
|
+
* is currently being typed at the caret. Returns null when nothing
|
|
185
|
+
* matches OR when the caret sits mid-word (so `m|ittag` doesn't
|
|
186
|
+
* suggest things just because `m` is a prefix).
|
|
187
|
+
*
|
|
188
|
+
* Priority: a triggered completion (its trigger char directly before
|
|
189
|
+
* the word) wins over the plain (trigger-less) completion.
|
|
190
|
+
*/
|
|
191
|
+
export const detectQuery = (
|
|
192
|
+
textarea: HTMLTextAreaElement,
|
|
193
|
+
completions: Completion[] | undefined,
|
|
194
|
+
options: DetectOptions = {},
|
|
195
|
+
): QueryContext | null => {
|
|
196
|
+
if (!completions || completions.length === 0) return null;
|
|
197
|
+
if (textarea.selectionStart !== textarea.selectionEnd) return null;
|
|
198
|
+
|
|
199
|
+
const value = textarea.value;
|
|
200
|
+
const caret = textarea.selectionStart;
|
|
201
|
+
if (caret === 0) return null;
|
|
202
|
+
if (options.isExcluded?.(value, caret)) return null;
|
|
203
|
+
|
|
204
|
+
// Only fire when the caret sits at the END of a word — never
|
|
205
|
+
// mid-word. End-of-buffer counts as "after a word".
|
|
206
|
+
const charAfterCaret = value[caret];
|
|
207
|
+
if (charAfterCaret !== undefined && WORD_CHAR.test(charAfterCaret)) return null;
|
|
208
|
+
|
|
209
|
+
// Walk back from caret over word chars to find the run start.
|
|
210
|
+
let wordStart = caret;
|
|
211
|
+
while (wordStart > 0 && WORD_CHAR.test(value[wordStart - 1]!)) wordStart--;
|
|
212
|
+
const triggerCandidate = wordStart > 0 ? value[wordStart - 1] : undefined;
|
|
213
|
+
|
|
214
|
+
// Triggered completion: trigger char directly before the word AND
|
|
215
|
+
// (by default) not preceded by another word char — so `foo#bar`
|
|
216
|
+
// doesn't activate `#`. Completions that legitimately fire after
|
|
217
|
+
// a word (e.g. `SUM(` for formula args) opt in via `allowAfterWord`.
|
|
218
|
+
if (triggerCandidate) {
|
|
219
|
+
const triggered = completions.find((c) => c.trigger === triggerCandidate);
|
|
220
|
+
if (triggered) {
|
|
221
|
+
const beforeTrigger = wordStart >= 2 ? value[wordStart - 2] : undefined;
|
|
222
|
+
const boundaryOk = !beforeTrigger || !WORD_CHAR.test(beforeTrigger);
|
|
223
|
+
if (boundaryOk || triggered.allowAfterWord) {
|
|
224
|
+
const query = value.slice(wordStart, caret);
|
|
225
|
+
return {
|
|
226
|
+
start: wordStart - 1,
|
|
227
|
+
end: caret,
|
|
228
|
+
text: triggerCandidate + query,
|
|
229
|
+
query,
|
|
230
|
+
completion: triggered,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Plain completion. Requires at least one word char and that the
|
|
237
|
+
// char before the word isn't itself a word char.
|
|
238
|
+
if (caret > wordStart) {
|
|
239
|
+
const plain = completions.find((c) => c.trigger === undefined);
|
|
240
|
+
if (plain) {
|
|
241
|
+
const query = value.slice(wordStart, caret);
|
|
242
|
+
return { start: wordStart, end: caret, text: query, query, completion: plain };
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return null;
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
/* ── Suggestion resolution ──────────────────────────────── */
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Discriminated-union result of resolving a completion's `suggest`.
|
|
253
|
+
* Lets the caller fast-path on `kind === "sync"` and await the
|
|
254
|
+
* promise on `kind === "async"`.
|
|
255
|
+
*/
|
|
256
|
+
export type ResolveResult = { kind: "sync"; data: Suggestion[] } | { kind: "async"; promise: Promise<Suggestion[]> };
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Call `completion.suggest` and discriminate between sync and async
|
|
260
|
+
* results. The caller owns the `AbortController`; abort it when the
|
|
261
|
+
* query changes mid-flight to drop the stale response.
|
|
262
|
+
*/
|
|
263
|
+
export const resolveSuggestions = (completion: Completion, query: string, ctx: SuggestContext, signal: AbortSignal): ResolveResult => {
|
|
264
|
+
const r = completion.suggest(query, ctx, signal);
|
|
265
|
+
if (r instanceof Promise) return { kind: "async", promise: r };
|
|
266
|
+
return { kind: "sync", data: r };
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Sync-only convenience: returns suggestions if the completion is
|
|
271
|
+
* synchronous, `null` otherwise (caller handles async via the
|
|
272
|
+
* debounced/abort path). Used by helpers like `collectKnownLabels`
|
|
273
|
+
* and the auto-expand lookup that can't wait for a promise.
|
|
274
|
+
*
|
|
275
|
+
* When the suggest returns a Promise, we abort the controller (so
|
|
276
|
+
* the user code can short-circuit if it checks the signal) AND
|
|
277
|
+
* attach a no-op `.catch` to suppress the eventual rejection. Without
|
|
278
|
+
* this, async suggests that reject would surface as unhandled
|
|
279
|
+
* promise rejections — in some runtimes (notably Bun during SSR
|
|
280
|
+
* rendering) this aborts the render and triggers a dev-server
|
|
281
|
+
* reload. The promise here is dead-end by design (we don't want
|
|
282
|
+
* its value), so silently dropping any rejection is correct.
|
|
283
|
+
*/
|
|
284
|
+
export const suggestSync = (completion: Completion, query: string, ctx: SuggestContext): Suggestion[] | null => {
|
|
285
|
+
const ctrl = new AbortController();
|
|
286
|
+
let result: Suggestion[] | Promise<Suggestion[]>;
|
|
287
|
+
try {
|
|
288
|
+
result = completion.suggest(query, ctx, ctrl.signal);
|
|
289
|
+
} catch {
|
|
290
|
+
// User suggest threw synchronously — treat as "no result".
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
if (Array.isArray(result)) return result;
|
|
294
|
+
ctrl.abort();
|
|
295
|
+
result.catch(() => {
|
|
296
|
+
/* intentionally swallowed — caller didn't await this promise */
|
|
297
|
+
});
|
|
298
|
+
return null;
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Pick the best ghost suggestion. Compares against the full typed
|
|
303
|
+
* string (`typed`), which for triggered completions INCLUDES the
|
|
304
|
+
* trigger char so the prefix check aligns with `suggestion.text`.
|
|
305
|
+
* Skips suggestions equal in length to the typed text (nothing to
|
|
306
|
+
* ghost).
|
|
307
|
+
*/
|
|
308
|
+
export const pickGhost = (suggestions: Suggestion[], typed: string): Suggestion | null => {
|
|
309
|
+
const lower = typed.toLowerCase();
|
|
310
|
+
for (const s of suggestions) {
|
|
311
|
+
if (s.text.length <= typed.length) continue;
|
|
312
|
+
if (s.text.toLowerCase().startsWith(lower)) return s;
|
|
313
|
+
}
|
|
314
|
+
return null;
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Walk all sync completions, ask each one for its full suggestion
|
|
319
|
+
* list (`suggest("")`), and return a flat set of `text` values. The
|
|
320
|
+
* MarkdownEditor uses this set to highlight known labels in the
|
|
321
|
+
* rendered preview. Async completions are silently skipped (we can't
|
|
322
|
+
* pause document rendering for a fetch).
|
|
323
|
+
*
|
|
324
|
+
* A "scan ctx" with empty `fullText`/`caret`/`tokenStart` is passed —
|
|
325
|
+
* `suggest` implementations should treat `query === ""` as a scan
|
|
326
|
+
* call.
|
|
327
|
+
*/
|
|
328
|
+
export const collectKnownLabels = (completions: Completion[] | undefined): Set<string> => {
|
|
329
|
+
const labels = new Set<string>();
|
|
330
|
+
if (!completions) return labels;
|
|
331
|
+
const scanCtx: SuggestContext = { fullText: "", caret: 0, tokenStart: 0 };
|
|
332
|
+
for (const c of completions) {
|
|
333
|
+
const result = suggestSync(c, "", scanCtx);
|
|
334
|
+
if (!result) continue;
|
|
335
|
+
for (const s of result) labels.add(s.text);
|
|
336
|
+
}
|
|
337
|
+
return labels;
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Resolve what to show for a suggestion in the dropdown row. Order
|
|
342
|
+
* of precedence:
|
|
343
|
+
* 1. Explicit `suggestion.label` if set (caller takes full control).
|
|
344
|
+
* 2. `suggestion.text` with the active completion's trigger char
|
|
345
|
+
* stripped, when present — so `(revenue` displays as `revenue`,
|
|
346
|
+
* `@alice` as `alice`. Avoids visually doubling the trigger
|
|
347
|
+
* since the user already typed it.
|
|
348
|
+
* 3. `suggestion.text` as-is when there's no trigger to strip.
|
|
349
|
+
*/
|
|
350
|
+
export const displayLabel = (suggestion: Suggestion, completion: Completion): string => {
|
|
351
|
+
if (suggestion.label !== undefined) return suggestion.label;
|
|
352
|
+
const trigger = completion.trigger;
|
|
353
|
+
if (trigger && suggestion.text.startsWith(trigger)) {
|
|
354
|
+
return suggestion.text.slice(trigger.length);
|
|
355
|
+
}
|
|
356
|
+
return suggestion.text;
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Build a `SuggestContext` from a textarea + query context. The
|
|
361
|
+
* editor builds this before calling `suggest` (sync or async) — the
|
|
362
|
+
* engine doesn't, because it doesn't own the textarea state.
|
|
363
|
+
*/
|
|
364
|
+
export const buildSuggestContext = (textarea: HTMLTextAreaElement, queryCtx: QueryContext): SuggestContext => ({
|
|
365
|
+
fullText: textarea.value,
|
|
366
|
+
caret: textarea.selectionStart,
|
|
367
|
+
tokenStart: queryCtx.start,
|
|
368
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic completion system used by both `<MarkdownEditor>` and
|
|
3
|
+
* `<AutocompleteEditor>`. Engine is pure logic, behaviours touch the
|
|
4
|
+
* DOM via `execCommand`, overlay renders ghost + anchor for editors
|
|
5
|
+
* that mirror their textarea in a preview div.
|
|
6
|
+
*
|
|
7
|
+
* For most callers, the high-level entrypoint is `<AutocompleteEditor>`
|
|
8
|
+
* (see `../input/AutocompleteEditor.tsx`). Direct engine access here
|
|
9
|
+
* is for editors that compose their own UI on top.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
type Suggestion,
|
|
14
|
+
type SuggestContext,
|
|
15
|
+
type Completion,
|
|
16
|
+
type QueryContext,
|
|
17
|
+
type DetectOptions,
|
|
18
|
+
type ResolveResult,
|
|
19
|
+
TRIGGER_CHARS,
|
|
20
|
+
WORD_CHAR,
|
|
21
|
+
GHOST_SENTINEL,
|
|
22
|
+
abbreviations,
|
|
23
|
+
detectQuery,
|
|
24
|
+
resolveSuggestions,
|
|
25
|
+
suggestSync,
|
|
26
|
+
pickGhost,
|
|
27
|
+
collectKnownLabels,
|
|
28
|
+
buildSuggestContext,
|
|
29
|
+
displayLabel,
|
|
30
|
+
} from "./engine";
|
|
31
|
+
|
|
32
|
+
export {
|
|
33
|
+
type TryExpandOptions,
|
|
34
|
+
resetCompletionState,
|
|
35
|
+
tryExpand,
|
|
36
|
+
tryRestore,
|
|
37
|
+
applySuggestion,
|
|
38
|
+
} from "./behaviors";
|
|
39
|
+
|
|
40
|
+
export { type RenderOptions, plainTextHighlight, renderWithOverlay } from "./overlay";
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Overlay-mode rendering helpers for editors that mirror their
|
|
3
|
+
* textarea in a preview div (the "overtype" pattern).
|
|
4
|
+
*
|
|
5
|
+
* The renderer injects a `GHOST_SENTINEL` PUA char at the caret in
|
|
6
|
+
* the source text BEFORE the caller's `highlight` function runs.
|
|
7
|
+
* After highlighting, the sentinel is replaced with either:
|
|
8
|
+
*
|
|
9
|
+
* - A `<span class="completion-ghost" data-completion-anchor>` containing
|
|
10
|
+
* the not-yet-typed tail of the active suggestion + a `→` arrow.
|
|
11
|
+
* - An invisible `<span class="completion-caret-anchor" data-completion-anchor>`
|
|
12
|
+
* used purely for positioning the dropdown when no ghost is shown.
|
|
13
|
+
*
|
|
14
|
+
* The `data-completion-anchor` attribute is what `positionDropdown`
|
|
15
|
+
* queries to find the caret's pixel position. The same attribute on
|
|
16
|
+
* both shapes lets the editor's positioning code stay agnostic of
|
|
17
|
+
* which shape is currently rendered.
|
|
18
|
+
*
|
|
19
|
+
* This module is markdown-agnostic. The `highlight` callback is the
|
|
20
|
+
* caller's responsibility — MarkdownEditor passes its markdown
|
|
21
|
+
* highlighter, AutocompleteEditor passes either an identity-escape
|
|
22
|
+
* or a user-provided syntax highlighter.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { GHOST_SENTINEL } from "./engine";
|
|
26
|
+
|
|
27
|
+
const escapeHtml = (s: string): string =>
|
|
28
|
+
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Identity highlighter — escapes HTML and preserves whitespace 1:1.
|
|
32
|
+
* Use this when the editor has no syntax-highlighting needs but
|
|
33
|
+
* still wants the overlay (for ghost preview / dropdown anchor).
|
|
34
|
+
*/
|
|
35
|
+
export const plainTextHighlight = (text: string): string => escapeHtml(text);
|
|
36
|
+
|
|
37
|
+
export type RenderOptions = {
|
|
38
|
+
/** Optional ghost preview to inject at `at` (the cursor offset in
|
|
39
|
+
* `text`). The wrapper carries `data-completion-anchor`. */
|
|
40
|
+
ghost?: { at: number; text: string };
|
|
41
|
+
/** Optional invisible anchor (used when a dropdown is open but no
|
|
42
|
+
* ghost is shown — e.g. typed text equals the highlighted row). */
|
|
43
|
+
anchor?: { at: number };
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Render text through `highlight`, then substitute the sentinel with
|
|
48
|
+
* either a ghost wrapper or an invisible anchor. Both carry
|
|
49
|
+
* `data-completion-anchor` so the editor can `querySelector` for
|
|
50
|
+
* positioning.
|
|
51
|
+
*
|
|
52
|
+
* The `highlight` function receives text that may contain the
|
|
53
|
+
* sentinel. The sentinel is a private-use codepoint with no special
|
|
54
|
+
* meaning to any markdown/HTML regex, so highlighters generally pass
|
|
55
|
+
* it through untouched — it ends up in the output verbatim, then
|
|
56
|
+
* gets substituted here.
|
|
57
|
+
*/
|
|
58
|
+
export const renderWithOverlay = (
|
|
59
|
+
text: string,
|
|
60
|
+
highlight: (text: string) => string,
|
|
61
|
+
options: RenderOptions = {},
|
|
62
|
+
): string => {
|
|
63
|
+
const { ghost, anchor } = options;
|
|
64
|
+
|
|
65
|
+
// Ghost wins over anchor: when both are conceptually present, the
|
|
66
|
+
// ghost wrapper already carries the anchor attribute, no separate
|
|
67
|
+
// marker needed. Only inject when either is requested.
|
|
68
|
+
const injection = ghost ?? anchor;
|
|
69
|
+
let workText = text;
|
|
70
|
+
if (injection) {
|
|
71
|
+
workText = text.slice(0, injection.at) + GHOST_SENTINEL + text.slice(injection.at);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let html = highlight(workText);
|
|
75
|
+
|
|
76
|
+
if (ghost) {
|
|
77
|
+
// Plain Unicode arrow rather than an icon-font glyph — overlays
|
|
78
|
+
// typically lock `font-family` to monospace (overtype pattern),
|
|
79
|
+
// so an icon font wouldn't take effect and we'd get a tofu box.
|
|
80
|
+
// `→` (U+2192) is available in every monospace font.
|
|
81
|
+
const ghostHtml = `<span class="completion-ghost" data-completion-anchor>${escapeHtml(ghost.text)}<span class="completion-ghost-arrow" aria-hidden="true">→</span></span>`;
|
|
82
|
+
html = html.split(GHOST_SENTINEL).join(ghostHtml);
|
|
83
|
+
} else if (anchor) {
|
|
84
|
+
// Zero-width inline-block so layout doesn't shift but
|
|
85
|
+
// `getBoundingClientRect()` returns the caret's pixel position.
|
|
86
|
+
// `` keeps the span "non-empty" for browsers that collapse
|
|
87
|
+
// empty inline elements to zero size.
|
|
88
|
+
const anchorHtml = `<span class="completion-caret-anchor" data-completion-anchor aria-hidden="true"></span>`;
|
|
89
|
+
html = html.split(GHOST_SENTINEL).join(anchorHtml);
|
|
90
|
+
}
|
|
91
|
+
return html;
|
|
92
|
+
};
|