@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,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
|
+
}
|