@valentinkolb/cloud 0.4.0 → 0.5.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.
Files changed (193) hide show
  1. package/package.json +18 -6
  2. package/scripts/preload.ts +78 -23
  3. package/src/_internal/define-app.ts +53 -46
  4. package/src/api/accounts-entities.ts +4 -0
  5. package/src/api/admin-core-settings.ts +98 -0
  6. package/src/api/announcements.ts +131 -0
  7. package/src/api/auth/schemas.ts +24 -0
  8. package/src/api/auth.ts +113 -10
  9. package/src/api/index.ts +7 -2
  10. package/src/api/me.ts +203 -14
  11. package/src/api/search/schemas.ts +1 -0
  12. package/src/api/search.ts +62 -8
  13. package/src/config/ssr.ts +2 -9
  14. package/src/contracts/announcements.test.ts +37 -0
  15. package/src/contracts/announcements.ts +121 -0
  16. package/src/contracts/app.ts +2 -0
  17. package/src/contracts/index.ts +3 -2
  18. package/src/contracts/registry.ts +2 -0
  19. package/src/contracts/shared.ts +108 -1
  20. package/src/desktop/index.ts +704 -0
  21. package/src/desktop/solid.tsx +938 -0
  22. package/src/server/api/index.ts +1 -1
  23. package/src/server/api/respond.ts +50 -10
  24. package/src/server/index.ts +44 -38
  25. package/src/server/middleware/auth.ts +98 -9
  26. package/src/server/middleware/index.ts +2 -1
  27. package/src/server/middleware/settings.ts +26 -0
  28. package/src/server/services/access.test.ts +197 -0
  29. package/src/server/services/access.ts +254 -6
  30. package/src/server/services/index.ts +14 -11
  31. package/src/server/services/pagination.ts +22 -0
  32. package/src/server/time.ts +45 -0
  33. package/src/services/account-lifecycle/index.ts +142 -18
  34. package/src/services/accounts/app.ts +658 -170
  35. package/src/services/accounts/authz.test.ts +77 -0
  36. package/src/services/accounts/authz.ts +22 -0
  37. package/src/services/accounts/entities.ts +84 -5
  38. package/src/services/accounts/groups.ts +30 -24
  39. package/src/services/accounts/model.test.ts +30 -0
  40. package/src/services/accounts/switching.test.ts +14 -0
  41. package/src/services/accounts/switching.ts +15 -6
  42. package/src/services/accounts/users.ts +75 -52
  43. package/src/services/announcements/index.test.ts +32 -0
  44. package/src/services/announcements/index.ts +224 -0
  45. package/src/services/audit/index.test.ts +84 -0
  46. package/src/services/audit/index.ts +431 -0
  47. package/src/services/auth-flows/index.ts +9 -2
  48. package/src/services/auth-flows/ipa.ts +0 -2
  49. package/src/services/auth-flows/magic-link.ts +3 -2
  50. package/src/services/auth-flows/password-reset.ts +284 -0
  51. package/src/services/auth-flows/proxy-return.test.ts +24 -0
  52. package/src/services/auth-flows/proxy-return.ts +49 -0
  53. package/src/services/gateway.ts +162 -0
  54. package/src/services/index.ts +44 -2
  55. package/src/services/ipa/effective-groups.test.ts +33 -0
  56. package/src/services/ipa/effective-groups.ts +70 -0
  57. package/src/services/ipa/profile.ts +45 -3
  58. package/src/services/ipa/search.ts +3 -5
  59. package/src/services/ipa/service-account.ts +15 -0
  60. package/src/services/ipa/sync-planning.test.ts +32 -0
  61. package/src/services/ipa/sync-planning.ts +22 -0
  62. package/src/services/ipa/sync.ts +110 -38
  63. package/src/services/oauth-tokens.ts +104 -0
  64. package/src/services/postgres.ts +21 -6
  65. package/src/services/providers/local/auth.test.ts +22 -0
  66. package/src/services/providers/local/auth.ts +46 -3
  67. package/src/services/secrets.ts +10 -0
  68. package/src/services/service-account-credentials.test.ts +210 -0
  69. package/src/services/service-account-credentials.ts +715 -0
  70. package/src/services/service-accounts.ts +188 -0
  71. package/src/services/session/index.ts +7 -8
  72. package/src/services/settings/app.ts +4 -20
  73. package/src/services/settings/defaults.ts +64 -22
  74. package/src/services/settings/store.ts +47 -0
  75. package/src/services/weather/forecast.ts +40 -7
  76. package/src/services/webauthn.test.ts +36 -0
  77. package/src/services/webauthn.ts +384 -0
  78. package/src/shared/icons.ts +391 -100
  79. package/src/shared/index.ts +7 -0
  80. package/src/shared/markdown/extensions/code.ts +38 -1
  81. package/src/shared/markdown/extensions/images.ts +39 -3
  82. package/src/shared/markdown/extensions/info-blocks.ts +5 -5
  83. package/src/shared/markdown/extensions/mark.ts +48 -0
  84. package/src/shared/markdown/extensions/sub-sup.ts +60 -0
  85. package/src/shared/markdown/extensions/tables.ts +79 -58
  86. package/src/shared/markdown/formula.test.ts +1089 -0
  87. package/src/shared/markdown/formula.ts +1187 -0
  88. package/src/shared/markdown/index.ts +76 -2
  89. package/src/shared/mock-cover.ts +130 -0
  90. package/src/shared/redirect.test.ts +49 -0
  91. package/src/shared/redirect.ts +52 -0
  92. package/src/shared/theme.test.ts +24 -0
  93. package/src/shared/theme.ts +68 -0
  94. package/src/shared/time.ts +13 -0
  95. package/src/ssr/AdminLayout.tsx +7 -3
  96. package/src/ssr/AdminSidebar.tsx +115 -49
  97. package/src/ssr/AppLaunchpad.island.tsx +176 -0
  98. package/src/ssr/Footer.island.tsx +3 -8
  99. package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
  100. package/src/ssr/GlobalSearchDialog.tsx +545 -117
  101. package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
  102. package/src/ssr/Layout.tsx +74 -66
  103. package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
  104. package/src/ssr/LayoutHelp.tsx +266 -0
  105. package/src/ssr/NavMenu.island.tsx +0 -39
  106. package/src/ssr/ThemeToggleRail.island.tsx +3 -3
  107. package/src/ssr/TimezoneCookie.island.tsx +23 -0
  108. package/src/ssr/islands/index.ts +13 -0
  109. package/src/styles/base-popover.css +5 -2
  110. package/src/styles/effects.css +87 -6
  111. package/src/styles/global.css +146 -9
  112. package/src/styles/input.css +3 -1
  113. package/src/styles/utilities-buttons.css +133 -27
  114. package/src/styles/utilities-code-display.css +67 -0
  115. package/src/styles/utilities-completion.css +223 -0
  116. package/src/styles/utilities-detail.css +73 -0
  117. package/src/styles/utilities-feedback.css +16 -15
  118. package/src/styles/utilities-layout.css +42 -2
  119. package/src/styles/utilities-markdown-editor.css +472 -0
  120. package/src/styles/utilities-navigation.css +63 -8
  121. package/src/styles/utilities-script.css +84 -0
  122. package/src/styles/utilities-table-tile.css +229 -0
  123. package/src/types/ambient.d.ts +9 -0
  124. package/src/ui/completion/behaviors.test.ts +95 -0
  125. package/src/ui/completion/behaviors.ts +205 -0
  126. package/src/ui/completion/engine.ts +368 -0
  127. package/src/ui/completion/index.ts +40 -0
  128. package/src/ui/completion/overlay.ts +92 -0
  129. package/src/ui/dialog-core.ts +173 -45
  130. package/src/ui/filter/FilterChip.tsx +42 -40
  131. package/src/ui/index.ts +11 -12
  132. package/src/ui/input/AutocompleteEditor.tsx +656 -0
  133. package/src/ui/input/CheckboxCard.tsx +91 -0
  134. package/src/ui/input/Combobox.tsx +375 -0
  135. package/src/ui/input/DatePicker.tsx +846 -0
  136. package/src/ui/input/DateTimeInput.tsx +29 -4
  137. package/src/ui/input/FileDropzone.tsx +116 -0
  138. package/src/ui/input/IconInput.tsx +116 -0
  139. package/src/ui/input/ImageInput.tsx +19 -2
  140. package/src/ui/input/MultiSelectInput.tsx +448 -0
  141. package/src/ui/input/NumberInput.tsx +417 -61
  142. package/src/ui/input/SegmentedControl.tsx +2 -2
  143. package/src/ui/input/Select.tsx +172 -10
  144. package/src/ui/input/Slider.tsx +3 -4
  145. package/src/ui/input/Switch.tsx +3 -2
  146. package/src/ui/input/TemplateEditor.tsx +212 -0
  147. package/src/ui/input/TextInput.tsx +144 -13
  148. package/src/ui/input/index.ts +53 -8
  149. package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
  150. package/src/ui/input/markdown/Toolbar.tsx +90 -0
  151. package/src/ui/input/markdown/actions.ts +233 -0
  152. package/src/ui/input/markdown/active-formats.ts +94 -0
  153. package/src/ui/input/markdown/behaviors.ts +193 -0
  154. package/src/ui/input/markdown/code-zone.ts +23 -0
  155. package/src/ui/input/markdown/highlight.ts +316 -0
  156. package/src/ui/layout.ts +22 -0
  157. package/src/ui/misc/AppOverview.tsx +105 -0
  158. package/src/ui/misc/AppWorkspace.tsx +607 -0
  159. package/src/ui/misc/Calendar.tsx +1291 -0
  160. package/src/ui/misc/Chart.tsx +162 -0
  161. package/src/ui/misc/CodeDisplay.tsx +54 -0
  162. package/src/ui/misc/ContextMenu.tsx +2 -2
  163. package/src/ui/misc/DataTable.tsx +269 -0
  164. package/src/ui/misc/DockWorkspace.tsx +425 -0
  165. package/src/ui/misc/Docs.tsx +153 -0
  166. package/src/ui/misc/Dropdown.tsx +2 -2
  167. package/src/ui/misc/EntitySearch.tsx +260 -129
  168. package/src/ui/misc/LinkCard.tsx +14 -2
  169. package/src/ui/misc/LogEntriesTable.tsx +34 -31
  170. package/src/ui/misc/Pagination.tsx +31 -12
  171. package/src/ui/misc/PanelDialog.tsx +109 -0
  172. package/src/ui/misc/Panes.tsx +873 -0
  173. package/src/ui/misc/PermissionEditor.tsx +358 -262
  174. package/src/ui/misc/Placeholder.tsx +40 -0
  175. package/src/ui/misc/ProgressBar.tsx +1 -1
  176. package/src/ui/misc/ResourceApiKeys.tsx +260 -0
  177. package/src/ui/misc/SettingsModal.tsx +150 -0
  178. package/src/ui/misc/StatCell.tsx +182 -40
  179. package/src/ui/misc/StatGrid.tsx +149 -0
  180. package/src/ui/misc/StructuredDataPreview.tsx +107 -0
  181. package/src/ui/misc/code-highlight.ts +213 -0
  182. package/src/ui/misc/index.ts +93 -12
  183. package/src/ui/prompts.tsx +362 -312
  184. package/src/ui/toast.ts +384 -0
  185. package/src/ui/widgets/Widget.tsx +12 -4
  186. package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
  187. package/src/ui/ipa/GroupView.tsx +0 -36
  188. package/src/ui/ipa/LoginBtn.tsx +0 -16
  189. package/src/ui/ipa/UserView.tsx +0 -58
  190. package/src/ui/ipa/index.ts +0 -4
  191. package/src/ui/navigation.ts +0 -32
  192. package/src/ui/sidebar.tsx +0 -468
  193. /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
@@ -0,0 +1,774 @@
1
+ import { createSignal, createEffect, createMemo, onMount, onCleanup, untrack, For, Show } from "solid-js";
2
+ import { handleShortcut, handleListContinuation, handleSmartPaste } from "./behaviors";
3
+ import {
4
+ type Completion,
5
+ type QueryContext,
6
+ type Suggestion,
7
+ abbreviations as abbreviationsCompletion,
8
+ tryExpand,
9
+ tryRestore,
10
+ resetCompletionState,
11
+ detectQuery,
12
+ suggestSync,
13
+ collectKnownLabels,
14
+ applySuggestion,
15
+ renderWithOverlay,
16
+ buildSuggestContext,
17
+ displayLabel,
18
+ } from "../../completion";
19
+ import { highlightMarkdown } from "./highlight";
20
+ import { isInCodeZone } from "./code-zone";
21
+ import { computeActiveFormats } from "./active-formats";
22
+ import Toolbar from "./Toolbar";
23
+
24
+ export type { Completion, Suggestion, SuggestContext } from "../../completion";
25
+ export { abbreviations } from "../../completion";
26
+
27
+ export type MarkdownEditorProps = {
28
+ /** Reactive value accessor — current markdown text. */
29
+ value?: () => string | undefined | null;
30
+ /** Fired on every input event (use this for controlled state). */
31
+ onInput?: (value: string) => void;
32
+ /** Fired on textarea change (commit on blur). */
33
+ onChange?: (value: string) => void;
34
+ /** Fired on Ctrl/Cmd+Enter. Bare Enter never submits — markdown
35
+ * editing needs Enter for newlines and list continuation. */
36
+ onSubmit?: () => void;
37
+ placeholder?: string;
38
+ disabled?: boolean;
39
+ /** Approximate visible rows. The wrapper sets `--md-h` from this. */
40
+ lines?: number;
41
+ id?: string;
42
+ ariaLabel?: string;
43
+ ariaDescribedBy?: string;
44
+ ariaInvalid?: boolean;
45
+ ariaRequired?: boolean;
46
+ /** Hide the toolbar (shortcuts and smart features still work). */
47
+ noToolbar?: boolean;
48
+ /** Browser spellcheck. Defaults to ON — overtype issue #98 lesson:
49
+ * non-technical users expect spellcheck in a prose editor. */
50
+ spellcheck?: boolean;
51
+ name?: string;
52
+ maxLength?: number;
53
+ /**
54
+ * AutoText dictionary — convenience shortcut for the most common
55
+ * completion shape: a fixed `{ short: long }` map. Internally
56
+ * wrapped via `abbreviations()` and merged into `completions`.
57
+ * Matching is case-insensitive with exact-case preference; Cmd/Ctrl+Z
58
+ * or an immediate Backspace reverts the expansion. Expansions never
59
+ * fire inside code spans or fenced blocks.
60
+ */
61
+ abbreviations?: Record<string, string>;
62
+ /**
63
+ * Full completion definitions. Each completion provides a suggest
64
+ * function that returns ghost-previewable suggestions for a query,
65
+ * and optionally a `trigger` char (`#`, `@`, `:`) that activates the
66
+ * completion. Sync completions also feed the document-wide blue
67
+ * dotted-underline highlight of recognised labels. A `Suggestion`
68
+ * with an `expansion` field also participates in word-boundary
69
+ * auto-expand (same behaviour as `abbreviations`).
70
+ *
71
+ * If both `abbreviations` and `completions` are provided, they're
72
+ * concatenated — abbreviations first, so a triggered completion
73
+ * with no trigger conflict can sit alongside the abbr dict.
74
+ */
75
+ completions?: Completion[];
76
+ /** Truthy when the surrounding form considers this field invalid.
77
+ * Renders a red border on the editor wrapper. The error MESSAGE is
78
+ * still rendered by `InputWrapper` outside; this prop just signals
79
+ * the editor to visually reflect the state. */
80
+ error?: boolean;
81
+ /** Show the lines/words/chars footer. Defaults to ON. */
82
+ showStats?: boolean;
83
+ /** Visual variant. Defaults to the compact zinc input surface. */
84
+ variant?: "default" | "paper";
85
+ };
86
+
87
+ /**
88
+ * Markdown editor with the overtype-style invisible-textarea overlay.
89
+ *
90
+ * Two layers stacked inside `.md-editor-surface`:
91
+ * - `textarea.md-editor-input` (transparent text, visible cursor)
92
+ * - `div.md-editor-preview` (syntax-highlighted HTML mirror)
93
+ *
94
+ * Both layers share identical typography + box-model settings via the
95
+ * `.md-editor-layer` class so the textarea cursor lines up with the
96
+ * highlighted glyphs in the preview. Scrolling the textarea drives a
97
+ * scroll-sync into the preview.
98
+ *
99
+ * All "smart" behaviours (shortcuts, list continuation, URL paste) hook
100
+ * into the textarea's native events — they never replace `.value`
101
+ * directly, only `document.execCommand("insertText")`, so the browser's
102
+ * built-in undo history stays usable.
103
+ */
104
+ export default function MarkdownEditor(props: MarkdownEditorProps) {
105
+ let textareaEl: HTMLTextAreaElement | undefined;
106
+ let previewEl: HTMLDivElement | undefined;
107
+ // Popover (NOT a `<dialog>`) — `<dialog showModal()>` would make
108
+ // the rest of the page `inert`, including the textarea below,
109
+ // breaking typing + arrow-key navigation. The Popover API gives us
110
+ // the same top-layer rendering (modal-safe) WITHOUT focus capture,
111
+ // so the textarea keeps focus and continues to receive keystrokes.
112
+ let dropdownEl: HTMLDivElement | undefined;
113
+ const [taSignal, setTaSignal] = createSignal<HTMLTextAreaElement | null>(null);
114
+ const [activeFormats, setActiveFormats] = createSignal<Set<string>>(new Set());
115
+ // True while the user has an active IME composition (Japanese,
116
+ // Chinese, Korean, dead-key sequences). Writing `textarea.value`
117
+ // during composition collapses the in-progress glyph or cancels the
118
+ // composition outright; we defer external syncs until it ends.
119
+ const [composing, setComposing] = createSignal(false);
120
+
121
+ // Active completion run + matching suggestions + which row of the
122
+ // dropdown is highlighted. One state object so every consumer
123
+ // (ghost render, dropdown render, Tab handler, key navigation)
124
+ // reads from a single source of truth.
125
+ //
126
+ // Invariants:
127
+ // - `suggestions` is non-empty (we clear the whole state when
128
+ // `suggest()` returns nothing).
129
+ // - `selectedIndex` is in [0, suggestions.length).
130
+ // - `ctx.completion.dropdown === true` ⇒ the dropdown is shown;
131
+ // otherwise only the ghost preview renders.
132
+ // - The ghost preview always reflects `suggestions[selectedIndex]`
133
+ // — arrow keys in the dropdown cycle the index AND retarget the
134
+ // ghost in lockstep.
135
+ type CompletionState = {
136
+ ctx: QueryContext;
137
+ suggestions: Suggestion[];
138
+ selectedIndex: number;
139
+ };
140
+ const [completionState, setCompletionState] = createSignal<CompletionState | null>(null);
141
+
142
+ // Convenience: the currently-highlighted suggestion (drives both
143
+ // ghost rendering and Tab insertion).
144
+ const activeSuggestion = (): Suggestion | null => {
145
+ const s = completionState();
146
+ return s ? (s.suggestions[s.selectedIndex] ?? null) : null;
147
+ };
148
+
149
+ // Whether the dropdown should be visible. Independent signal so the
150
+ // open/close-side-effect (showModal / close) tracks only this
151
+ // boolean, not the full state object.
152
+ const [dropdownOpen, setDropdownOpen] = createSignal(false);
153
+ // Dark-class sync — mirrors the Select pattern: a dialog rendered
154
+ // into the top-layer doesn't inherit ancestor `.dark` classes
155
+ // automatically, so we re-apply the class on the dialog itself.
156
+ const [isDarkTheme, setIsDarkTheme] = createSignal(false);
157
+
158
+ // Merge `abbreviations` prop (sugar) with the explicit `completions`
159
+ // array. Memoised on identity of the props — re-runs on swap, not
160
+ // on every render.
161
+ const mergedCompletions = createMemo<Completion[] | undefined>(() => {
162
+ const abbr = props.abbreviations;
163
+ const comps = props.completions;
164
+ if (!abbr && !comps) return undefined;
165
+ const out: Completion[] = [];
166
+ if (abbr && Object.keys(abbr).length > 0) out.push(abbreviationsCompletion(abbr));
167
+ if (comps) out.push(...comps);
168
+ return out.length > 0 ? out : undefined;
169
+ });
170
+
171
+ // Set of plain-text labels (across all sync completions) to wrap in
172
+ // `.md-completion-match` during render. Async completions don't
173
+ // contribute here — their suggestions only appear as ghost previews.
174
+ const knownLabels = createMemo(() => collectKnownLabels(mergedCompletions()));
175
+
176
+ // Local source of truth for the textarea content. Drives both the
177
+ // textarea's bound value AND the preview. Why local rather than
178
+ // reading `props.value()` directly:
179
+ //
180
+ // - Uncontrolled mode (`onInput` provided, `value` not): the
181
+ // textarea would otherwise be bound to "" forever and the user's
182
+ // typing would only stay visible because of one-way binding
183
+ // timing — the preview would never reflect the typed text. With
184
+ // a local signal, both the bound textarea and the preview share
185
+ // the same truth.
186
+ // - Controlled mode (`value` + `onInput`): we sync from `props.value`
187
+ // on external change and from `onInput` on user typing. No double
188
+ // re-render storm, no stale-preview window between input event
189
+ // and parent signal update.
190
+ const [localValue, setLocalValue] = createSignal(props.value?.() ?? "");
191
+
192
+ // Sync incoming controlled value → local signal. CRITICAL: this
193
+ // effect must depend ONLY on `props.value()`, not on `localValue()`.
194
+ //
195
+ // Why: when the user types, we call `setLocalValue(newValue)` from
196
+ // onInput BEFORE `props.onInput` propagates the new value upward.
197
+ // If this effect tracked `localValue()`, it would re-run inside the
198
+ // same input cycle, read the still-stale `props.value()`, decide
199
+ // "they diverge", and revert localValue to the OLD parent value.
200
+ // The downstream imperative-write effect would then see DOM value
201
+ // (correct, new) vs target (stale, old), write target back into the
202
+ // textarea, and that programmatic assignment collapses the caret to
203
+ // the end on every major browser — i.e. the "Enter jumps to last
204
+ // line" bug. `untrack` reads localValue without registering it as
205
+ // a reactive dep, so this effect only fires when the PARENT pushes
206
+ // a new value (form reset, programmatic edit) — the exact contract
207
+ // we want.
208
+ createEffect(() => {
209
+ const incoming = props.value?.();
210
+ if (incoming === undefined || incoming === null) return;
211
+ if (incoming !== untrack(localValue)) {
212
+ setLocalValue(incoming);
213
+ }
214
+ });
215
+
216
+ // Preview is driven solely by the local signal + completion state
217
+ // + known labels. Single source of truth, single update path, no
218
+ // duplicated parse/escape work.
219
+ createEffect(() => {
220
+ if (!previewEl) return;
221
+ const state = completionState();
222
+ const active = activeSuggestion();
223
+
224
+ // Ghost preview text: only the tail the user hasn't typed yet.
225
+ // Slice by length (not by `startsWith`) so a case-insensitive
226
+ // match still produces the right cut — the visible characters
227
+ // before the ghost are the user's literal typing, the ghost
228
+ // tail is verbatim from the suggestion. Filter in
229
+ // `recomputeCompletion` guarantees tail.length > 0 when a
230
+ // completion is active, so we never end up with an empty ghost.
231
+ const ghostArg = state && active ? { at: state.ctx.end, text: active.text.slice(state.ctx.text.length) } : undefined;
232
+
233
+ // Highlight function for the overlay renderer. Wraps the markdown
234
+ // highlighter so the generic overlay module stays markdown-agnostic
235
+ // — `knownLabels` are markdown-specific (rendered into the preview
236
+ // with `.md-completion-match`), so we pass them through here. The
237
+ // ghost sentinel is injected at the cursor BEFORE highlight runs,
238
+ // travels through the markdown pipeline untouched (PUA char, no
239
+ // regex match), and gets substituted with the ghost span after.
240
+ const labels = knownLabels();
241
+ previewEl.innerHTML = renderWithOverlay(localValue(), (workText) => highlightMarkdown(workText, { knownLabels: labels }), {
242
+ ghost: ghostArg,
243
+ });
244
+ // Re-sync scroll AFTER the preview's content grows: when the user
245
+ // hits Enter at the bottom of the visible area, the browser auto-
246
+ // scrolls the textarea before the input event fires. At that
247
+ // moment, the preview's scrollHeight is still the pre-grow value
248
+ // so `previewEl.scrollTop = ta.scrollTop` gets clamped to a smaller
249
+ // number. Once we've grown the preview here, re-apply the sync so
250
+ // the clamp finally accepts the right value.
251
+ if (textareaEl) {
252
+ previewEl.scrollTop = textareaEl.scrollTop;
253
+ previewEl.scrollLeft = textareaEl.scrollLeft;
254
+ }
255
+ });
256
+
257
+ // Manage textarea.value IMPERATIVELY rather than via the JSX
258
+ // `value={x()}` binding. Solid's binding re-writes the DOM property
259
+ // on every reactive tick, even when the string is unchanged — that
260
+ // can disturb the caret in some browser/event-order edge cases (most
261
+ // visibly: every newline reset to the very end of the field). Overtype
262
+ // takes the same approach: it owns the textarea and only ever calls
263
+ // `textarea.value = …` when there's an actual divergence to repair.
264
+ //
265
+ // Guarded by `composing()`: writing the textarea's value during an
266
+ // active IME composition collapses the partially-formed glyph. We
267
+ // skip the write; the effect re-runs when `composing()` flips false
268
+ // (Solid tracks the read), at which point we apply any pending
269
+ // external sync.
270
+ createEffect(() => {
271
+ const target = localValue();
272
+ if (composing()) return;
273
+ if (textareaEl && textareaEl.value !== target) {
274
+ textareaEl.value = target;
275
+ }
276
+ });
277
+
278
+ const updateActive = (): void => {
279
+ if (textareaEl) setActiveFormats(computeActiveFormats(textareaEl));
280
+ };
281
+
282
+ /**
283
+ * Recompute completion state (ghost + dropdown) based on the
284
+ * current textarea position. Called from every code path that may
285
+ * have moved the caret or changed the content: input, key,
286
+ * focus-change, selection-change.
287
+ *
288
+ * Sync flow only — async `suggest` (returning a Promise) clears
289
+ * the state without waiting, matching the "fast or nothing" UX
290
+ * brief. Future work would hook a debounced fetch in here.
291
+ */
292
+ const recomputeCompletion = (): void => {
293
+ if (!textareaEl) return;
294
+ const ctx = detectQuery(textareaEl, mergedCompletions(), { isExcluded: isInCodeZone });
295
+ if (!ctx) {
296
+ setCompletionState(null);
297
+ closeDropdown();
298
+ return;
299
+ }
300
+ const list = suggestSync(ctx.completion, ctx.query, buildSuggestContext(textareaEl, ctx));
301
+ if (!list) {
302
+ setCompletionState(null);
303
+ closeDropdown();
304
+ return;
305
+ }
306
+
307
+ // Keep suggestions whose `text` is a strict prefix of the typed
308
+ // run (case-insensitive) AND strictly longer than what's been
309
+ // typed. Same filter for ghost-only and dropdown paths: once the
310
+ // user has fully typed a suggestion, there's nothing left to
311
+ // offer — so the dropdown closes too. If we kept equal-length
312
+ // matches the dropdown would linger empty-handedly after every
313
+ // accepted completion.
314
+ const lower = ctx.text.toLowerCase();
315
+ const usable = list.filter((s) => s.text.toLowerCase().startsWith(lower) && s.text.length > ctx.text.length);
316
+
317
+ if (usable.length === 0) {
318
+ setCompletionState(null);
319
+ closeDropdown();
320
+ return;
321
+ }
322
+
323
+ // Preserve the highlighted row across keystrokes when the same
324
+ // suggestion is still present — feels less jarring than always
325
+ // resetting to index 0 mid-typing.
326
+ const prev = completionState();
327
+ const prevSelected = prev?.suggestions[prev.selectedIndex]?.text;
328
+ const keptIndex = prevSelected ? usable.findIndex((s) => s.text === prevSelected) : -1;
329
+ const selectedIndex = keptIndex >= 0 ? keptIndex : 0;
330
+
331
+ setCompletionState({ ctx, suggestions: usable, selectedIndex });
332
+
333
+ if (ctx.completion.dropdown) {
334
+ // Open or re-position. Re-position even when already open so
335
+ // the dropdown stays glued to the caret as the user types
336
+ // across line wraps.
337
+ if (!dropdownOpen()) {
338
+ setDropdownOpen(true);
339
+ }
340
+ // Wait one microtask for the preview's createEffect to render
341
+ // the new anchor element before we measure.
342
+ queueMicrotask(() => positionDropdown());
343
+ } else if (dropdownOpen()) {
344
+ closeDropdown();
345
+ }
346
+ };
347
+
348
+ /**
349
+ * Anchor the dropdown to the caret position in the preview. Uses
350
+ * the `[data-md-caret-anchor]` element (either the visible ghost
351
+ * wrapper or the invisible 1px span — both placed exactly at the
352
+ * caret by the renderer). Falls back to the textarea bottom edge
353
+ * if the anchor isn't found.
354
+ *
355
+ * Auto-flip up/down by comparing viewport space against the max
356
+ * dropdown height — mirrors the Select dropdown pattern so the
357
+ * behaviour is recognisable across the codebase. `showModal()`
358
+ * lifts the dialog into the top-layer so it sits above any
359
+ * surrounding modal.
360
+ */
361
+ const positionDropdown = (): void => {
362
+ if (!dropdownEl || !previewEl || !textareaEl) return;
363
+ syncTheme();
364
+
365
+ const anchorEl = previewEl.querySelector<HTMLElement>("[data-md-caret-anchor]");
366
+ const anchorRect = anchorEl ? anchorEl.getBoundingClientRect() : textareaEl.getBoundingClientRect();
367
+
368
+ const dropdownMaxHeight = 260;
369
+ const spaceBelow = window.innerHeight - anchorRect.bottom;
370
+ const spaceAbove = anchorRect.top;
371
+ const openAbove = spaceBelow < dropdownMaxHeight && spaceAbove > spaceBelow;
372
+
373
+ const dropdownWidth = 280;
374
+ const margin = 8;
375
+ const left = Math.min(anchorRect.left, window.innerWidth - dropdownWidth - margin);
376
+
377
+ dropdownEl.style.left = `${Math.max(margin, left)}px`;
378
+ dropdownEl.style.width = `${dropdownWidth}px`;
379
+
380
+ if (openAbove) {
381
+ dropdownEl.style.top = "auto";
382
+ dropdownEl.style.bottom = `${window.innerHeight - anchorRect.top + 4}px`;
383
+ } else {
384
+ dropdownEl.style.top = `${anchorRect.bottom + 4}px`;
385
+ dropdownEl.style.bottom = "auto";
386
+ }
387
+
388
+ if (!dropdownEl.matches(":popover-open")) {
389
+ dropdownEl.showPopover();
390
+ }
391
+ };
392
+
393
+ const closeDropdown = (): void => {
394
+ if (dropdownEl?.matches(":popover-open")) dropdownEl.hidePopover();
395
+ if (dropdownOpen()) setDropdownOpen(false);
396
+ };
397
+
398
+ const syncTheme = (): void => {
399
+ if (typeof document === "undefined") return;
400
+ setIsDarkTheme(document.documentElement.classList.contains("dark") || document.body.classList.contains("dark"));
401
+ };
402
+
403
+ /** Insert the currently-active suggestion at the caret and clear
404
+ * completion state. Shared by Tab, Enter-on-dropdown, and click
405
+ * handlers so the insertion path stays in one place. */
406
+ const acceptActiveSuggestion = (): boolean => {
407
+ if (!textareaEl) return false;
408
+ const state = completionState();
409
+ const active = activeSuggestion();
410
+ if (!state || !active) return false;
411
+ if (active.text === state.ctx.text) {
412
+ // Nothing to insert (typed text already equals the suggestion).
413
+ closeDropdown();
414
+ setCompletionState(null);
415
+ return false;
416
+ }
417
+ applySuggestion(textareaEl, state.ctx, active);
418
+ closeDropdown();
419
+ setCompletionState(null);
420
+ return true;
421
+ };
422
+
423
+ /** Move the highlighted row inside the dropdown. The ghost
424
+ * preview reflects the new selection automatically because both
425
+ * read from `activeSuggestion()`. */
426
+ const moveSelection = (direction: 1 | -1): void => {
427
+ const state = completionState();
428
+ if (!state) return;
429
+ const len = state.suggestions.length;
430
+ if (len === 0) return;
431
+ const next = (state.selectedIndex + direction + len) % len;
432
+ setCompletionState({ ...state, selectedIndex: next });
433
+ };
434
+
435
+ // Lines / words / chars derived from the live local value.
436
+ const stats = createMemo(() => {
437
+ const v = localValue();
438
+ return {
439
+ lines: v.length === 0 ? 0 : v.split("\n").length,
440
+ words: v.match(/\S+/g)?.length ?? 0,
441
+ chars: v.length,
442
+ };
443
+ });
444
+
445
+ onMount(() => {
446
+ if (textareaEl) setTaSignal(textareaEl);
447
+ updateActive();
448
+
449
+ // Catch cursor moves via arrow keys / mouse click / programmatic
450
+ // setSelectionRange — none of these fire the textarea's `input`
451
+ // event. `selectionchange` on the document is the only reliable
452
+ // signal across all paths.
453
+ const onSelectionChange = (): void => {
454
+ if (document.activeElement === textareaEl) {
455
+ updateActive();
456
+ recomputeCompletion();
457
+ }
458
+ };
459
+ document.addEventListener("selectionchange", onSelectionChange);
460
+ onCleanup(() => document.removeEventListener("selectionchange", onSelectionChange));
461
+ // Belt-and-braces: ensure the dropdown dialog doesn't outlive
462
+ // the editor (e.g. fast unmount during route change while the
463
+ // dropdown is open).
464
+ onCleanup(() => {
465
+ if (dropdownEl?.matches(":popover-open")) dropdownEl.hidePopover();
466
+ });
467
+ });
468
+
469
+ const onInput = (e: InputEvent & { currentTarget: HTMLTextAreaElement }): void => {
470
+ // Abbreviation-style auto-expand fires first. If it triggers,
471
+ // the execCommand inside dispatches a fresh `input` event that
472
+ // re-enters this handler with the expanded value — skip the
473
+ // downstream work here so the consumer's onInput callback only
474
+ // sees the final state.
475
+ if (tryExpand(e.currentTarget, mergedCompletions(), { isExcluded: isInCodeZone })) return;
476
+ const v = e.currentTarget.value;
477
+ setLocalValue(v);
478
+ props.onInput?.(v);
479
+ updateActive();
480
+ recomputeCompletion();
481
+ };
482
+
483
+ const onChange = (e: Event & { currentTarget: HTMLTextAreaElement }): void => {
484
+ props.onChange?.(e.currentTarget.value);
485
+ };
486
+
487
+ const onKeyDown = (e: KeyboardEvent): void => {
488
+ if (!textareaEl) return;
489
+ // IME composition in progress — pass everything through. Most
490
+ // browsers fire keydown with `isComposing=true` for the trigger
491
+ // that completes a composition; intercepting it breaks IME UX.
492
+ if (e.isComposing) return;
493
+
494
+ // Backspace IMMEDIATELY after an abbreviation expansion reverts it
495
+ // to the original short form. This is the second escape hatch
496
+ // beyond Cmd/Ctrl+Z — non-technical users reach for Backspace
497
+ // when they want to "take that back".
498
+ if (e.key === "Backspace" && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) {
499
+ if (tryRestore(textareaEl)) {
500
+ e.preventDefault();
501
+ return;
502
+ }
503
+ }
504
+
505
+ // Tab / Enter / Escape / Arrows semantics depend on whether
506
+ // there's an active completion (ghost or dropdown) AND whether
507
+ // the editor has any completions registered:
508
+ //
509
+ // - Active completion → Tab/Enter inserts the highlighted row.
510
+ // ArrowDown/Up cycle the row (only meaningful with dropdown,
511
+ // but harmless otherwise). Escape closes the dropdown / ghost
512
+ // without inserting.
513
+ // - Completions configured but no active suggestion → Tab is
514
+ // swallowed (focus trap). Escape blurs the textarea.
515
+ // - No completions → native browser behaviour.
516
+ const hasCompletions = (mergedCompletions()?.length ?? 0) > 0;
517
+ const state = completionState();
518
+ const hasActive = state !== null;
519
+ const isDropdown = state?.ctx.completion.dropdown === true;
520
+
521
+ // Arrow keys cycle the dropdown selection when one is open. We
522
+ // also accept arrow keys when ONLY the ghost is showing — the
523
+ // ghost only ever has one entry there, so the cycle is a no-op,
524
+ // but it costs nothing and keeps the behaviour symmetric.
525
+ if (hasActive && isDropdown && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
526
+ e.preventDefault();
527
+ moveSelection(e.key === "ArrowDown" ? 1 : -1);
528
+ return;
529
+ }
530
+
531
+ // Enter inserts the active suggestion only when the dropdown is
532
+ // open. Bare ghost without dropdown keeps Enter for newline /
533
+ // list continuation — Tab remains the accept gesture there, so
534
+ // we don't pull Enter out from under the user mid-paragraph.
535
+ if (e.key === "Enter" && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey) {
536
+ if (hasActive && isDropdown && dropdownOpen()) {
537
+ e.preventDefault();
538
+ acceptActiveSuggestion();
539
+ return;
540
+ }
541
+ }
542
+
543
+ if (e.key === "Tab" && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey) {
544
+ if (hasActive) {
545
+ e.preventDefault();
546
+ acceptActiveSuggestion();
547
+ return;
548
+ }
549
+ if (hasCompletions) {
550
+ e.preventDefault();
551
+ return;
552
+ }
553
+ }
554
+
555
+ if (e.key === "Escape") {
556
+ if (hasActive) {
557
+ e.preventDefault();
558
+ closeDropdown();
559
+ setCompletionState(null);
560
+ return;
561
+ }
562
+ if (hasCompletions) {
563
+ // Blur escapes the trap. Browser default Escape on textarea
564
+ // doesn't move focus, so we do it explicitly. The user gets
565
+ // back to "regular" Tab behaviour from wherever focus lands
566
+ // (usually the document root → next Tab moves to body's
567
+ // first focusable child).
568
+ e.preventDefault();
569
+ textareaEl.blur();
570
+ return;
571
+ }
572
+ }
573
+
574
+ // Cmd/Ctrl+Enter submits — bare Enter is reserved for newlines /
575
+ // list continuation. User explicitly requested this convention so
576
+ // multi-line editing works without surprise form submits.
577
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
578
+ if (props.onSubmit) {
579
+ e.preventDefault();
580
+ props.onSubmit();
581
+ return;
582
+ }
583
+ }
584
+
585
+ // Shortcut dispatch (bold/italic/headings/etc).
586
+ if (handleShortcut(e, textareaEl)) {
587
+ e.preventDefault();
588
+ return;
589
+ }
590
+
591
+ // Smart list continuation on bare Enter.
592
+ if (e.key === "Enter" && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey) {
593
+ if (handleListContinuation(textareaEl)) {
594
+ e.preventDefault();
595
+ return;
596
+ }
597
+ }
598
+ };
599
+
600
+ const onPaste = (e: ClipboardEvent): void => {
601
+ if (!textareaEl) return;
602
+ if (handleSmartPaste(e, textareaEl)) {
603
+ e.preventDefault();
604
+ }
605
+ };
606
+
607
+ const onScroll = (e: Event & { currentTarget: HTMLTextAreaElement }): void => {
608
+ if (!previewEl) return;
609
+ previewEl.scrollTop = e.currentTarget.scrollTop;
610
+ previewEl.scrollLeft = e.currentTarget.scrollLeft;
611
+ };
612
+
613
+ // Tab semantics (handled in onKeyDown above):
614
+ // - Ghost active → insert it.
615
+ // - Completions configured but no ghost → swallow Tab (focus trap).
616
+ // - No completions → native behaviour, moves focus to next field.
617
+ // Escape releases the trap (blurs the textarea). Indentation by
618
+ // Tab is never inserted — lists auto-continue on Enter instead.
619
+
620
+ // Height derived from `lines` prop. We use rem because line-height is
621
+ // 1.55 — close enough to 1em-ish; rem keeps it predictable.
622
+ const surfaceStyle = (): string => {
623
+ const lines = props.lines ?? 6;
624
+ return `--md-h: ${lines * 1.5}rem`;
625
+ };
626
+
627
+ return (
628
+ <div
629
+ class="md-editor"
630
+ data-disabled={props.disabled ? "true" : undefined}
631
+ data-error={props.error ? "true" : undefined}
632
+ data-variant={props.variant === "paper" ? "paper" : undefined}
633
+ >
634
+ <Show when={!props.noToolbar}>
635
+ <Toolbar textarea={taSignal} activeFormats={activeFormats} disabled={props.disabled} />
636
+ </Show>
637
+ <div class="md-editor-surface" style={surfaceStyle()}>
638
+ <Show when={!localValue() && props.placeholder}>
639
+ <div class="md-editor-placeholder" aria-hidden="true">
640
+ {props.placeholder}
641
+ </div>
642
+ </Show>
643
+ <div ref={(el) => (previewEl = el)} class="md-editor-layer md-editor-preview" aria-hidden="true" />
644
+ <textarea
645
+ ref={(el) => (textareaEl = el)}
646
+ id={props.id}
647
+ name={props.name}
648
+ class="md-editor-layer md-editor-input"
649
+ // value is managed imperatively via the effect above —
650
+ // see the "imperatively rather than via JSX binding" comment.
651
+ // No `value={…}` prop here.
652
+ onInput={onInput}
653
+ onChange={onChange}
654
+ onKeyDown={onKeyDown}
655
+ onPaste={onPaste}
656
+ onScroll={onScroll}
657
+ onCompositionStart={() => setComposing(true)}
658
+ onCompositionEnd={() => setComposing(false)}
659
+ onBlur={(e) => {
660
+ // Don't tear down completion state if focus moved into
661
+ // the dropdown dialog — that's a normal interaction
662
+ // (clicking a row). The dialog itself is in the
663
+ // top-layer; `relatedTarget` will be the clicked option
664
+ // (or the dialog) and we'll get focus back via the
665
+ // option-click handler.
666
+ const next = e.relatedTarget as HTMLElement | null;
667
+ if (next && dropdownEl && dropdownEl.contains(next)) return;
668
+ resetCompletionState();
669
+ // Drop completion state on real blur — keeping it would
670
+ // leave a dim suggestion lingering after the user moved
671
+ // focus, which reads as a glitch rather than a hint.
672
+ setCompletionState(null);
673
+ closeDropdown();
674
+ }}
675
+ disabled={props.disabled}
676
+ spellcheck={props.spellcheck ?? true}
677
+ maxLength={props.maxLength}
678
+ aria-label={props.ariaLabel}
679
+ aria-describedby={props.ariaDescribedBy}
680
+ aria-invalid={props.ariaInvalid}
681
+ aria-required={props.ariaRequired}
682
+ />
683
+ </div>
684
+ <Show when={(props.showStats ?? true) && !props.disabled}>
685
+ {/* Render the row even when empty so the editor's overall
686
+ height never changes — just toggle visibility. Hiding via
687
+ `display: none` would jolt every other field below when the
688
+ user starts/stops typing. */}
689
+ <div class="md-editor-stats" aria-hidden="true" data-empty={stats().chars === 0 ? "true" : undefined}>
690
+ <span>
691
+ {stats().lines} {stats().lines === 1 ? "line" : "lines"}
692
+ </span>
693
+ <span>
694
+ {stats().words} {stats().words === 1 ? "word" : "words"}
695
+ </span>
696
+ <span>
697
+ {stats().chars} {stats().chars === 1 ? "char" : "chars"}
698
+ </span>
699
+ </div>
700
+ </Show>
701
+ {/* Completion dropdown — uses the Popover API instead of
702
+ `<dialog showModal>`. Reasoning: `showModal` makes the rest
703
+ of the page `inert`, which kills typing + arrow-key
704
+ navigation in the textarea. The Popover API renders in the
705
+ same top-layer (so we still sit above modals) but DOES NOT
706
+ capture focus, so the textarea keeps it.
707
+
708
+ `popover="manual"` because we drive open/close ourselves —
709
+ arrow keys, click outside (via blur), Tab, Esc are all
710
+ handled in the textarea's onKeyDown so we don't want the
711
+ browser's built-in light-dismiss to fight us.
712
+
713
+ Mounted only when there IS a completion state, so the
714
+ element + its `[popover]` close-transition don't outlive
715
+ the data. Otherwise the global `[popover]` close transition
716
+ (~200ms `display allow-discrete`) would leave an empty box
717
+ fading out after the last completion vanished. Ref binds
718
+ on mount via the `(el) => …` callback. `positionDropdown`
719
+ runs the next microtask, so the ref is ready in time.
720
+
721
+ `popup` provides the paper surface; `dark` class is mirrored
722
+ from the host theme because top-layer elements don't inherit
723
+ ancestor classes. `inset-auto` clears the UA default
724
+ `inset: 0` so our explicit left/top take effect (otherwise
725
+ the popover would stretch to viewport edges). */}
726
+ <Show when={completionState()}>
727
+ {(state) => (
728
+ <div
729
+ ref={(el) => (dropdownEl = el)}
730
+ popover="manual"
731
+ class="popup fixed inset-auto m-0 border border-zinc-200 p-1 dark:border-zinc-700"
732
+ classList={{ dark: isDarkTheme() }}
733
+ role="presentation"
734
+ aria-label="Completion suggestions"
735
+ >
736
+ <div class="flex max-h-60 flex-col gap-0.5 overflow-y-auto" role="listbox" aria-label="Suggestions">
737
+ <For each={state().suggestions}>
738
+ {(suggestion, index) => {
739
+ const isSelected = () => index() === state().selectedIndex;
740
+ return (
741
+ <div
742
+ // mousedown rather than click so we beat the
743
+ // textarea's blur — clicking an option must
744
+ // insert + keep editor focus, not blur away.
745
+ onMouseDown={(e) => {
746
+ e.preventDefault();
747
+ setCompletionState({ ...state(), selectedIndex: index() });
748
+ acceptActiveSuggestion();
749
+ textareaEl?.focus();
750
+ }}
751
+ onMouseEnter={() => setCompletionState({ ...state(), selectedIndex: index() })}
752
+ role="option"
753
+ aria-selected={isSelected()}
754
+ class={`group flex cursor-pointer select-none items-center gap-2 rounded px-2 py-1.5 text-sm transition-colors ${
755
+ isSelected()
756
+ ? "bg-blue-50 text-blue-700 dark:bg-blue-950/40 dark:text-blue-300"
757
+ : "text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800"
758
+ }`}
759
+ >
760
+ <span class="font-mono truncate">{displayLabel(suggestion, state().ctx.completion)}</span>
761
+ <Show when={suggestion.hint}>
762
+ <span class="ml-auto text-xs text-zinc-500 dark:text-zinc-400 truncate">{suggestion.hint}</span>
763
+ </Show>
764
+ </div>
765
+ );
766
+ }}
767
+ </For>
768
+ </div>
769
+ </div>
770
+ )}
771
+ </Show>
772
+ </div>
773
+ );
774
+ }