@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.
Files changed (194) 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 +116 -13
  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 +47 -7
  49. package/src/services/auth-flows/magic-link.ts +92 -20
  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/notifications/index.ts +82 -11
  64. package/src/services/oauth-tokens.ts +104 -0
  65. package/src/services/postgres.ts +21 -6
  66. package/src/services/providers/local/auth.test.ts +22 -0
  67. package/src/services/providers/local/auth.ts +46 -3
  68. package/src/services/secrets.ts +10 -0
  69. package/src/services/service-account-credentials.test.ts +210 -0
  70. package/src/services/service-account-credentials.ts +715 -0
  71. package/src/services/service-accounts.ts +188 -0
  72. package/src/services/session/index.ts +7 -8
  73. package/src/services/settings/app.ts +4 -20
  74. package/src/services/settings/defaults.ts +79 -22
  75. package/src/services/settings/store.ts +47 -0
  76. package/src/services/weather/forecast.ts +40 -7
  77. package/src/services/webauthn.test.ts +36 -0
  78. package/src/services/webauthn.ts +384 -0
  79. package/src/shared/icons.ts +391 -100
  80. package/src/shared/index.ts +7 -0
  81. package/src/shared/markdown/extensions/code.ts +38 -1
  82. package/src/shared/markdown/extensions/images.ts +39 -3
  83. package/src/shared/markdown/extensions/info-blocks.ts +5 -5
  84. package/src/shared/markdown/extensions/mark.ts +48 -0
  85. package/src/shared/markdown/extensions/sub-sup.ts +60 -0
  86. package/src/shared/markdown/extensions/tables.ts +79 -58
  87. package/src/shared/markdown/formula.test.ts +1089 -0
  88. package/src/shared/markdown/formula.ts +1187 -0
  89. package/src/shared/markdown/index.ts +76 -2
  90. package/src/shared/mock-cover.ts +130 -0
  91. package/src/shared/redirect.test.ts +58 -0
  92. package/src/shared/redirect.ts +56 -0
  93. package/src/shared/theme.test.ts +24 -0
  94. package/src/shared/theme.ts +68 -0
  95. package/src/shared/time.ts +13 -0
  96. package/src/ssr/AdminLayout.tsx +7 -3
  97. package/src/ssr/AdminSidebar.tsx +115 -49
  98. package/src/ssr/AppLaunchpad.island.tsx +176 -0
  99. package/src/ssr/Footer.island.tsx +3 -8
  100. package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
  101. package/src/ssr/GlobalSearchDialog.tsx +545 -117
  102. package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
  103. package/src/ssr/Layout.tsx +74 -66
  104. package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
  105. package/src/ssr/LayoutHelp.tsx +266 -0
  106. package/src/ssr/NavMenu.island.tsx +0 -39
  107. package/src/ssr/ThemeToggleRail.island.tsx +3 -3
  108. package/src/ssr/TimezoneCookie.island.tsx +23 -0
  109. package/src/ssr/islands/index.ts +13 -0
  110. package/src/styles/base-popover.css +5 -2
  111. package/src/styles/effects.css +87 -6
  112. package/src/styles/global.css +146 -9
  113. package/src/styles/input.css +3 -1
  114. package/src/styles/utilities-buttons.css +133 -27
  115. package/src/styles/utilities-code-display.css +67 -0
  116. package/src/styles/utilities-completion.css +223 -0
  117. package/src/styles/utilities-detail.css +73 -0
  118. package/src/styles/utilities-feedback.css +16 -15
  119. package/src/styles/utilities-layout.css +42 -2
  120. package/src/styles/utilities-markdown-editor.css +472 -0
  121. package/src/styles/utilities-navigation.css +63 -8
  122. package/src/styles/utilities-script.css +84 -0
  123. package/src/styles/utilities-table-tile.css +229 -0
  124. package/src/types/ambient.d.ts +9 -0
  125. package/src/ui/completion/behaviors.test.ts +95 -0
  126. package/src/ui/completion/behaviors.ts +205 -0
  127. package/src/ui/completion/engine.ts +368 -0
  128. package/src/ui/completion/index.ts +40 -0
  129. package/src/ui/completion/overlay.ts +92 -0
  130. package/src/ui/dialog-core.ts +173 -45
  131. package/src/ui/filter/FilterChip.tsx +42 -40
  132. package/src/ui/index.ts +11 -12
  133. package/src/ui/input/AutocompleteEditor.tsx +656 -0
  134. package/src/ui/input/CheckboxCard.tsx +91 -0
  135. package/src/ui/input/Combobox.tsx +375 -0
  136. package/src/ui/input/DatePicker.tsx +846 -0
  137. package/src/ui/input/DateTimeInput.tsx +29 -4
  138. package/src/ui/input/FileDropzone.tsx +116 -0
  139. package/src/ui/input/IconInput.tsx +116 -0
  140. package/src/ui/input/ImageInput.tsx +19 -2
  141. package/src/ui/input/MultiSelectInput.tsx +448 -0
  142. package/src/ui/input/NumberInput.tsx +417 -61
  143. package/src/ui/input/SegmentedControl.tsx +2 -2
  144. package/src/ui/input/Select.tsx +172 -10
  145. package/src/ui/input/Slider.tsx +3 -4
  146. package/src/ui/input/Switch.tsx +3 -2
  147. package/src/ui/input/TemplateEditor.tsx +212 -0
  148. package/src/ui/input/TextInput.tsx +144 -13
  149. package/src/ui/input/index.ts +53 -8
  150. package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
  151. package/src/ui/input/markdown/Toolbar.tsx +90 -0
  152. package/src/ui/input/markdown/actions.ts +233 -0
  153. package/src/ui/input/markdown/active-formats.ts +94 -0
  154. package/src/ui/input/markdown/behaviors.ts +193 -0
  155. package/src/ui/input/markdown/code-zone.ts +23 -0
  156. package/src/ui/input/markdown/highlight.ts +316 -0
  157. package/src/ui/layout.ts +22 -0
  158. package/src/ui/misc/AppOverview.tsx +105 -0
  159. package/src/ui/misc/AppWorkspace.tsx +607 -0
  160. package/src/ui/misc/Calendar.tsx +1291 -0
  161. package/src/ui/misc/Chart.tsx +162 -0
  162. package/src/ui/misc/CodeDisplay.tsx +54 -0
  163. package/src/ui/misc/ContextMenu.tsx +2 -2
  164. package/src/ui/misc/DataTable.tsx +269 -0
  165. package/src/ui/misc/DockWorkspace.tsx +425 -0
  166. package/src/ui/misc/Docs.tsx +153 -0
  167. package/src/ui/misc/Dropdown.tsx +2 -2
  168. package/src/ui/misc/EntitySearch.tsx +260 -129
  169. package/src/ui/misc/LinkCard.tsx +14 -2
  170. package/src/ui/misc/LogEntriesTable.tsx +34 -31
  171. package/src/ui/misc/Pagination.tsx +31 -12
  172. package/src/ui/misc/PanelDialog.tsx +109 -0
  173. package/src/ui/misc/Panes.tsx +873 -0
  174. package/src/ui/misc/PermissionEditor.tsx +358 -262
  175. package/src/ui/misc/Placeholder.tsx +40 -0
  176. package/src/ui/misc/ProgressBar.tsx +1 -1
  177. package/src/ui/misc/ResourceApiKeys.tsx +260 -0
  178. package/src/ui/misc/SettingsModal.tsx +150 -0
  179. package/src/ui/misc/StatCell.tsx +182 -40
  180. package/src/ui/misc/StatGrid.tsx +149 -0
  181. package/src/ui/misc/StructuredDataPreview.tsx +107 -0
  182. package/src/ui/misc/code-highlight.ts +213 -0
  183. package/src/ui/misc/index.ts +93 -12
  184. package/src/ui/prompts.tsx +362 -312
  185. package/src/ui/toast.ts +384 -0
  186. package/src/ui/widgets/Widget.tsx +12 -4
  187. package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
  188. package/src/ui/ipa/GroupView.tsx +0 -36
  189. package/src/ui/ipa/LoginBtn.tsx +0 -16
  190. package/src/ui/ipa/UserView.tsx +0 -58
  191. package/src/ui/ipa/index.ts +0 -4
  192. package/src/ui/navigation.ts +0 -32
  193. package/src/ui/sidebar.tsx +0 -468
  194. /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Toolbar button strip for the markdown editor. Mouse-only by design:
3
+ * each button has `tabIndex=-1` so Tab navigates AROUND the editor's
4
+ * toolbar straight to the textarea body. Keyboard users get the same
5
+ * actions via `Cmd/Ctrl + B/I/E/K` and the shift-digit shortcuts.
6
+ *
7
+ * Buttons display in an active state (blue tint + faint background)
8
+ * when `activeFormats` contains their `id` — feedback that the caret
9
+ * currently sits inside a styled span. The set is computed by
10
+ * `active-formats.ts` and pushed in from the host component.
11
+ */
12
+ import { For } from "solid-js";
13
+ import {
14
+ toggleBold,
15
+ toggleItalic,
16
+ toggleCode,
17
+ insertLink,
18
+ toggleHeading,
19
+ toggleBulletList,
20
+ toggleNumberedList,
21
+ toggleQuote,
22
+ } from "./actions";
23
+
24
+ type ToolbarProps = {
25
+ /** Reactive accessor returning the textarea element (or null before mount). */
26
+ textarea: () => HTMLTextAreaElement | null;
27
+ /** Reactive set of "currently active" format IDs at the caret. The
28
+ * button whose `id` is in the set renders in the active visual state
29
+ * (overtype convention — gives the user feedback that the cursor sits
30
+ * inside a styled span). */
31
+ activeFormats?: () => Set<string>;
32
+ disabled?: boolean;
33
+ };
34
+
35
+ type Tool =
36
+ | { kind: "btn"; id: string; icon: string; title: string; run: (ta: HTMLTextAreaElement) => void }
37
+ | { kind: "sep" };
38
+
39
+ const TOOLS: Tool[] = [
40
+ { kind: "btn", id: "bold", icon: "ti ti-bold", title: "Bold (Ctrl/Cmd+B)", run: toggleBold },
41
+ { kind: "btn", id: "italic", icon: "ti ti-italic", title: "Italic (Ctrl/Cmd+I)", run: toggleItalic },
42
+ { kind: "btn", id: "code", icon: "ti ti-code", title: "Inline code (Ctrl/Cmd+E)", run: toggleCode },
43
+ { kind: "btn", id: "link", icon: "ti ti-link", title: "Link (Ctrl/Cmd+K)", run: (ta) => insertLink(ta) },
44
+ { kind: "sep" },
45
+ { kind: "btn", id: "h1", icon: "ti ti-h-1", title: "Heading 1 (Ctrl/Cmd+Shift+1)", run: (ta) => toggleHeading(ta, 1) },
46
+ { kind: "btn", id: "h2", icon: "ti ti-h-2", title: "Heading 2 (Ctrl/Cmd+Shift+2)", run: (ta) => toggleHeading(ta, 2) },
47
+ { kind: "btn", id: "h3", icon: "ti ti-h-3", title: "Heading 3 (Ctrl/Cmd+Shift+3)", run: (ta) => toggleHeading(ta, 3) },
48
+ { kind: "sep" },
49
+ { kind: "btn", id: "bullet", icon: "ti ti-list", title: "Bullet list (Ctrl/Cmd+Shift+8)", run: toggleBulletList },
50
+ { kind: "btn", id: "ordered", icon: "ti ti-list-numbers", title: "Numbered list (Ctrl/Cmd+Shift+7)", run: toggleNumberedList },
51
+ { kind: "btn", id: "quote", icon: "ti ti-quote", title: "Quote", run: toggleQuote },
52
+ ];
53
+
54
+ export default function Toolbar(props: ToolbarProps) {
55
+ return (
56
+ <div class="md-editor-toolbar" role="toolbar" aria-label="Markdown formatting">
57
+ <For each={TOOLS}>
58
+ {(tool) =>
59
+ tool.kind === "sep" ? (
60
+ <span class="md-editor-tool-sep" aria-hidden="true" />
61
+ ) : (
62
+ <button
63
+ type="button"
64
+ class="md-editor-tool"
65
+ title={tool.title}
66
+ aria-label={tool.title}
67
+ aria-pressed={props.activeFormats?.().has(tool.id) ? "true" : undefined}
68
+ disabled={props.disabled}
69
+ // tabIndex=-1 so Tab doesn't land on 10 formatting buttons
70
+ // before reaching the editor body. Keyboard users have all
71
+ // the same actions available via Cmd/Ctrl shortcuts; the
72
+ // toolbar stays a mouse-friendly affordance.
73
+ tabIndex={-1}
74
+ // Prevent the button from stealing focus from the textarea —
75
+ // we want the textarea to stay focused so the cursor stays
76
+ // visible during action.
77
+ onMouseDown={(e) => e.preventDefault()}
78
+ onClick={() => {
79
+ const ta = props.textarea();
80
+ if (ta) tool.run(ta);
81
+ }}
82
+ >
83
+ <i class={tool.icon} />
84
+ </button>
85
+ )
86
+ }
87
+ </For>
88
+ </div>
89
+ );
90
+ }
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Text manipulations for the markdown editor toolbar / shortcuts.
3
+ *
4
+ * Every action operates on a real `<textarea>` element and uses
5
+ * `document.execCommand("insertText", …)` for the actual replacement.
6
+ * Although `execCommand` is technically deprecated, it remains the only
7
+ * way to mutate a textarea while keeping the browser's NATIVE undo
8
+ * stack intact — replacing `.value` directly clears the user's undo
9
+ * history, which is hostile to non-technical users editing prose. All
10
+ * actions are otherwise pure DOM ops with no Solid involvement, so they
11
+ * can be called from event handlers in any framework.
12
+ *
13
+ * Selection handling principles:
14
+ * - Read `selectionStart` / `selectionEnd` to capture state.
15
+ * - Compute the replacement string.
16
+ * - `textarea.setSelectionRange(start, end)` to widen the selection
17
+ * to cover what we want to replace.
18
+ * - `document.execCommand("insertText", false, replacement)`.
19
+ * - Set the cursor / selection to the desired final position.
20
+ * - Dispatch an `input` event so the host component re-renders the
21
+ * preview and notifies its `onInput` listener.
22
+ */
23
+
24
+ const replaceRange = (ta: HTMLTextAreaElement, start: number, end: number, replacement: string): void => {
25
+ ta.focus();
26
+ ta.setSelectionRange(start, end);
27
+ // `document.execCommand("insertText", …)` fires an `input` event on
28
+ // all major browsers — no manual dispatch needed on the happy path.
29
+ // It also keeps the textarea's native undo history intact.
30
+ const ok = document.execCommand("insertText", false, replacement);
31
+ if (!ok) {
32
+ // Fallback: direct assignment loses undo history AND does NOT
33
+ // fire input on its own, so we synthesise the event so consumers
34
+ // and the editor's onInput listener still see the change.
35
+ const before = ta.value.slice(0, start);
36
+ const after = ta.value.slice(end);
37
+ ta.value = before + replacement + after;
38
+ const caret = start + replacement.length;
39
+ ta.setSelectionRange(caret, caret);
40
+ ta.dispatchEvent(new Event("input", { bubbles: true }));
41
+ }
42
+ };
43
+
44
+ type LineInfo = { lineStart: number; lineEnd: number; line: string };
45
+
46
+ const lineAt = (value: string, pos: number): LineInfo => {
47
+ const lineStart = value.lastIndexOf("\n", pos - 1) + 1;
48
+ const nlAfter = value.indexOf("\n", pos);
49
+ const lineEnd = nlAfter === -1 ? value.length : nlAfter;
50
+ return { lineStart, lineEnd, line: value.slice(lineStart, lineEnd) };
51
+ };
52
+
53
+ /** Expand the selection to cover full lines for line-level toggles. */
54
+ const selectedLineRange = (ta: HTMLTextAreaElement): { start: number; end: number; lines: string[] } => {
55
+ const value = ta.value;
56
+ const selStart = ta.selectionStart;
57
+ const selEnd = ta.selectionEnd;
58
+ const startLine = lineAt(value, selStart);
59
+ // If selEnd is exactly at a line start (just after \n), don't expand
60
+ // forward to the next line — selecting "line1\n" then toggling should
61
+ // affect only line1.
62
+ const adjustedEnd = selEnd > selStart && selEnd > 0 && value[selEnd - 1] === "\n" ? selEnd - 1 : selEnd;
63
+ const endLine = lineAt(value, adjustedEnd);
64
+ const block = value.slice(startLine.lineStart, endLine.lineEnd);
65
+ return { start: startLine.lineStart, end: endLine.lineEnd, lines: block.split("\n") };
66
+ };
67
+
68
+ /* ────────────────────────────────────────────────────────────────────
69
+ * Inline wrappers (bold / italic / code)
70
+ * ──────────────────────────────────────────────────────────────────── */
71
+
72
+ const toggleInlineWrap = (ta: HTMLTextAreaElement, marker: string, placeholder: string): void => {
73
+ const value = ta.value;
74
+ const selStart = ta.selectionStart;
75
+ const selEnd = ta.selectionEnd;
76
+ const mlen = marker.length;
77
+
78
+ // Already wrapped? Check chars just outside the selection.
79
+ const outsideBefore = value.slice(Math.max(0, selStart - mlen), selStart);
80
+ const outsideAfter = value.slice(selEnd, selEnd + mlen);
81
+ if (outsideBefore === marker && outsideAfter === marker) {
82
+ // Unwrap: remove the marker on each side
83
+ replaceRange(ta, selStart - mlen, selEnd + mlen, value.slice(selStart, selEnd));
84
+ const newStart = selStart - mlen;
85
+ const newEnd = selEnd - mlen;
86
+ ta.setSelectionRange(newStart, newEnd);
87
+ return;
88
+ }
89
+
90
+ // Or inside-wrapped: selection contains the markers (e.g. user
91
+ // selected `**bold**` itself). Strip if the selection starts and
92
+ // ends with the marker.
93
+ const sel = value.slice(selStart, selEnd);
94
+ if (sel.length >= mlen * 2 && sel.startsWith(marker) && sel.endsWith(marker)) {
95
+ const stripped = sel.slice(mlen, sel.length - mlen);
96
+ replaceRange(ta, selStart, selEnd, stripped);
97
+ ta.setSelectionRange(selStart, selStart + stripped.length);
98
+ return;
99
+ }
100
+
101
+ // Wrap. If the selection is empty, insert `marker + placeholder +
102
+ // marker` and select the placeholder so the user can type to replace.
103
+ if (selStart === selEnd) {
104
+ const insert = `${marker}${placeholder}${marker}`;
105
+ replaceRange(ta, selStart, selEnd, insert);
106
+ ta.setSelectionRange(selStart + mlen, selStart + mlen + placeholder.length);
107
+ } else {
108
+ const wrapped = `${marker}${sel}${marker}`;
109
+ replaceRange(ta, selStart, selEnd, wrapped);
110
+ ta.setSelectionRange(selStart + mlen, selStart + mlen + sel.length);
111
+ }
112
+ };
113
+
114
+ export const toggleBold = (ta: HTMLTextAreaElement): void => toggleInlineWrap(ta, "**", "bold text");
115
+ export const toggleItalic = (ta: HTMLTextAreaElement): void => toggleInlineWrap(ta, "*", "italic text");
116
+ export const toggleCode = (ta: HTMLTextAreaElement): void => toggleInlineWrap(ta, "`", "code");
117
+
118
+ /* ────────────────────────────────────────────────────────────────────
119
+ * Links
120
+ * ──────────────────────────────────────────────────────────────────── */
121
+
122
+ /**
123
+ * Insert `[selection](url)` — or `[label](url)` when nothing's selected.
124
+ * After insertion the cursor lands inside the URL parens so the user
125
+ * can immediately paste / type the destination.
126
+ */
127
+ export const insertLink = (ta: HTMLTextAreaElement, url?: string): void => {
128
+ const value = ta.value;
129
+ const selStart = ta.selectionStart;
130
+ const selEnd = ta.selectionEnd;
131
+ const sel = value.slice(selStart, selEnd);
132
+ const label = sel || "link";
133
+ const finalUrl = url ?? "";
134
+ const insert = `[${label}](${finalUrl})`;
135
+ replaceRange(ta, selStart, selEnd, insert);
136
+ // Land the cursor inside the parens — between `(` and `)`.
137
+ const caret = selStart + label.length + 3 + finalUrl.length;
138
+ if (finalUrl) {
139
+ // URL was provided (e.g. from smart paste). Select the label so
140
+ // the user can keep typing if they want to rename it.
141
+ ta.setSelectionRange(selStart + 1, selStart + 1 + label.length);
142
+ } else {
143
+ ta.setSelectionRange(caret, caret);
144
+ }
145
+ };
146
+
147
+ /* ────────────────────────────────────────────────────────────────────
148
+ * Line-level toggles (headers, lists, quote)
149
+ * ──────────────────────────────────────────────────────────────────── */
150
+
151
+ /**
152
+ * Toggle a single-prefix line marker (`> `, `# `, etc.).
153
+ * - If every selected line already has the marker (after its indent),
154
+ * strip the marker but keep the indent.
155
+ * - Otherwise, insert the marker AFTER each line's leading whitespace
156
+ * so indented lines stay indented.
157
+ */
158
+ const togglePrefix = (ta: HTMLTextAreaElement, prefix: string): void => {
159
+ const { start, end, lines } = selectedLineRange(ta);
160
+ const splitIndent = (l: string): [string, string] => {
161
+ const m = /^(\s*)(.*)$/.exec(l)!;
162
+ return [m[1]!, m[2]!];
163
+ };
164
+ const allHave = lines.every((l) => {
165
+ const [, body] = splitIndent(l);
166
+ return body.startsWith(prefix);
167
+ });
168
+ const transformed = lines.map((l) => {
169
+ const [indent, body] = splitIndent(l);
170
+ return allHave ? indent + body.slice(prefix.length) : indent + prefix + body;
171
+ });
172
+ const replacement = transformed.join("\n");
173
+ replaceRange(ta, start, end, replacement);
174
+ ta.setSelectionRange(start, start + replacement.length);
175
+ };
176
+
177
+ /**
178
+ * Toggle a heading at the current line. `level` is 1, 2, or 3. If the
179
+ * line already has the exact level, strip it (toggle off). If it has a
180
+ * different heading level, replace it. Else prepend.
181
+ */
182
+ export const toggleHeading = (ta: HTMLTextAreaElement, level: 1 | 2 | 3): void => {
183
+ const value = ta.value;
184
+ const oldCaret = ta.selectionStart;
185
+ const { lineStart, lineEnd, line } = lineAt(value, oldCaret);
186
+ const want = "#".repeat(level) + " ";
187
+ const headerRe = /^(#{1,6})\s/;
188
+ const existing = headerRe.exec(line);
189
+ let newLine: string;
190
+ if (existing && existing[1]!.length === level) {
191
+ newLine = line.slice(existing[0].length);
192
+ } else if (existing) {
193
+ newLine = want + line.slice(existing[0].length);
194
+ } else {
195
+ newLine = want + line;
196
+ }
197
+ replaceRange(ta, lineStart, lineEnd, newLine);
198
+ // Caret math must use the PRE-mutation caret. `replaceRange` moves
199
+ // the selection to the end of the inserted text; reading
200
+ // selectionStart now would lose the user's original position. Clamp
201
+ // to the new line bounds so we never end up before the line start.
202
+ const delta = newLine.length - line.length;
203
+ const desired = Math.max(lineStart, oldCaret + delta);
204
+ const newCaret = Math.min(desired, lineStart + newLine.length);
205
+ ta.setSelectionRange(newCaret, newCaret);
206
+ };
207
+
208
+ export const toggleBulletList = (ta: HTMLTextAreaElement): void => togglePrefix(ta, "- ");
209
+ export const toggleQuote = (ta: HTMLTextAreaElement): void => togglePrefix(ta, "> ");
210
+
211
+ /**
212
+ * Numbered list toggle. Renumber from 1 across the selection when adding;
213
+ * strip the `N. ` prefix when removing. Leading indent is preserved on
214
+ * both code paths so indented lists keep their nesting.
215
+ */
216
+ export const toggleNumberedList = (ta: HTMLTextAreaElement): void => {
217
+ const { start, end, lines } = selectedLineRange(ta);
218
+ const lineRe = /^(\s*)(\d+\.\s)?(.*)$/;
219
+ const parts = lines.map((l) => {
220
+ const m = lineRe.exec(l)!;
221
+ return { indent: m[1] ?? "", existingMarker: m[2], rest: m[3] ?? "" };
222
+ });
223
+ const allNumbered = parts.every((p) => !!p.existingMarker);
224
+ let transformed: string[];
225
+ if (allNumbered) {
226
+ transformed = parts.map((p) => p.indent + p.rest);
227
+ } else {
228
+ transformed = parts.map((p, i) => `${p.indent}${i + 1}. ${p.rest}`);
229
+ }
230
+ const replacement = transformed.join("\n");
231
+ replaceRange(ta, start, end, replacement);
232
+ ta.setSelectionRange(start, start + replacement.length);
233
+ };
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Inspect the textarea's current line and cursor position, return the
3
+ * set of markdown formats currently "active" at the caret. The toolbar
4
+ * uses this to highlight matching buttons (overtype convention: blue
5
+ * tint on the icon when the cursor sits inside a styled span).
6
+ *
7
+ * Format IDs match the toolbar button IDs: `bold`, `italic`, `code`,
8
+ * `h1`, `h2`, `h3`, `bullet`, `ordered`, `quote`.
9
+ *
10
+ * Inline detection is heuristic — we count delimiter occurrences on
11
+ * the current line up to the caret. An odd count means we're "inside"
12
+ * that delimiter. This works for typical markdown but won't catch
13
+ * every CommonMark edge case (escaped asterisks, intra-word
14
+ * underscores, etc.). Good enough for live UI feedback.
15
+ */
16
+ /** True if the caret on this line sits inside an unclosed inline-code
17
+ * span (odd number of backticks before it). */
18
+ const inOpenCode = (beforeOnLine: string): boolean => (beforeOnLine.match(/`/g) ?? []).length % 2 !== 0;
19
+
20
+ /** True if the caret sits between `](` and `)` of a markdown link, i.e.
21
+ * inside the URL parens. We don't want to report bold/italic/code as
22
+ * active while editing a URL. */
23
+ const inLinkUrl = (beforeOnLine: string): boolean => {
24
+ // Scan from right-to-left for the first unbalanced "](" with no `)`
25
+ // between it and the caret.
26
+ const lastUrlOpen = beforeOnLine.lastIndexOf("](");
27
+ if (lastUrlOpen === -1) return false;
28
+ const afterOpen = beforeOnLine.slice(lastUrlOpen + 2);
29
+ return !afterOpen.includes(")");
30
+ };
31
+
32
+ /** Replace ranges between paired delimiters with spaces of equal length
33
+ * so the remaining string preserves character offsets but no longer
34
+ * contains "active" delimiter chars. Used to neutralise inline code
35
+ * spans and link URLs before counting bold/italic delimiters. */
36
+ const scrub = (s: string): string =>
37
+ s
38
+ // Inline code: closed spans (paired backtick runs)
39
+ .replace(/(?<!`)(`+)(?!`)([^\n]+?)\1(?!`)/g, (m) => " ".repeat(m.length))
40
+ // Link URL section `](…)` — keep `[label` intact so its asterisks
41
+ // are still counted; only the URL is scrubbed.
42
+ .replace(/\]\(([^)\n]*?)\)/g, (m) => " ".repeat(m.length));
43
+
44
+ export const computeActiveFormats = (textarea: HTMLTextAreaElement): Set<string> => {
45
+ const value = textarea.value;
46
+ const caret = textarea.selectionStart;
47
+ const active = new Set<string>();
48
+
49
+ const lineStart = value.lastIndexOf("\n", caret - 1) + 1;
50
+ const nextNl = value.indexOf("\n", caret);
51
+ const lineEnd = nextNl === -1 ? value.length : nextNl;
52
+ const line = value.slice(lineStart, lineEnd);
53
+ const beforeOnLine = value.slice(lineStart, caret);
54
+
55
+ // Line-level — at most one block format per line.
56
+ const header = /^(#{1,3})\s/.exec(line);
57
+ if (header) active.add(`h${header[1]!.length}`);
58
+ else if (/^\s*[-*+]\s/.test(line)) active.add("bullet");
59
+ else if (/^\s*\d+\.\s/.test(line)) active.add("ordered");
60
+ else if (/^>\s/.test(line)) active.add("quote");
61
+
62
+ // Caret inside an OPEN inline-code span — only "code" is meaningful;
63
+ // everything else inside code is verbatim text, not active markdown.
64
+ if (inOpenCode(beforeOnLine)) {
65
+ active.add("code");
66
+ return active;
67
+ }
68
+ // Caret inside a link URL — nothing inline is "active". The user is
69
+ // editing the URL, not styled prose.
70
+ if (inLinkUrl(beforeOnLine)) return active;
71
+
72
+ // Scrub closed code spans + link URLs so we don't count their inner
73
+ // asterisks/underscores as styling delimiters on this line.
74
+ const scrubbed = scrub(beforeOnLine);
75
+
76
+ // Bold (`**`): count occurrences; strip then count single asterisks
77
+ // for italic, so `**bold**` doesn't double-count its inner stars.
78
+ const boldPairs = (scrubbed.match(/\*\*/g) ?? []).length;
79
+ if (boldPairs % 2 !== 0) active.add("bold");
80
+
81
+ const scrubbedNoBold = scrubbed.replace(/\*\*/g, "");
82
+ const italicStars = (scrubbedNoBold.match(/\*/g) ?? []).length;
83
+ if (italicStars % 2 !== 0) active.add("italic");
84
+
85
+ // Underscore-bold (`__`) and underscore-italic (`_…_`). Mirror the
86
+ // highlighter so the toolbar reports active state for both delimiter
87
+ // styles. We strip `__` first to disambiguate single vs double, same
88
+ // as the asterisk branch.
89
+ if ((scrubbed.match(/__/g) ?? []).length % 2 !== 0) active.add("bold");
90
+ const scrubbedNoDouble = scrubbed.replace(/__/g, "");
91
+ if ((scrubbedNoDouble.match(/_/g) ?? []).length % 2 !== 0) active.add("italic");
92
+
93
+ return active;
94
+ };
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Editor input behaviours — pure event handlers that translate user
3
+ * gestures (keystrokes, paste) into the textarea-manipulation actions
4
+ * defined in `actions.ts`. None of these own state of their own; each
5
+ * is a small function the host component wires into the matching DOM
6
+ * event.
7
+ *
8
+ * Conventions:
9
+ *
10
+ * - Each handler returns `true` when it has consumed the event;
11
+ * the caller should then `preventDefault()` and stop further
12
+ * processing. `false` means "I'm not interested — fall through to
13
+ * the next handler or to the browser default".
14
+ * - All mutations go through `actions.ts` or `execCommand("insertText")`
15
+ * so the textarea's native undo history stays usable. We never
16
+ * assign `textarea.value` directly.
17
+ */
18
+ import {
19
+ toggleBold,
20
+ toggleItalic,
21
+ toggleCode,
22
+ toggleBulletList,
23
+ toggleNumberedList,
24
+ toggleHeading,
25
+ insertLink,
26
+ } from "./actions";
27
+
28
+ /* ────────────────────────────────────────────────────────────────────
29
+ * Keyboard shortcuts
30
+ * ──────────────────────────────────────────────────────────────────── */
31
+
32
+ const isMac = (): boolean => typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/i.test(navigator.platform);
33
+
34
+ /**
35
+ * Cmd/Ctrl + (B/I/E/K) + (Shift+1/2/3 for headings, Shift+7/8 for
36
+ * lists). Mac-aware (metaKey vs ctrlKey). Skips firing during IME
37
+ * composition (overtype issue #80 lesson). Layout-independent digit
38
+ * detection via `e.code === "DigitN"` so Shift+1 works on QWERTY,
39
+ * AZERTY, German, etc.
40
+ */
41
+ export const handleShortcut = (e: KeyboardEvent, ta: HTMLTextAreaElement): boolean => {
42
+ if (e.isComposing) return false;
43
+ // AltGr on Windows / Linux raises `altKey + ctrlKey` together to
44
+ // produce keys like `@`. Skip Ctrl-shortcuts when Alt is held so we
45
+ // don't hijack AltGr character entry.
46
+ if (e.altKey) return false;
47
+ const mod = isMac() ? e.metaKey : e.ctrlKey;
48
+ if (!mod) return false;
49
+
50
+ if (!e.shiftKey) {
51
+ switch (e.key.toLowerCase()) {
52
+ case "b":
53
+ toggleBold(ta);
54
+ return true;
55
+ case "i":
56
+ toggleItalic(ta);
57
+ return true;
58
+ case "e":
59
+ // GitHub / Slack / Notion convention for inline code.
60
+ toggleCode(ta);
61
+ return true;
62
+ case "k":
63
+ insertLink(ta);
64
+ return true;
65
+ }
66
+ }
67
+
68
+ if (e.shiftKey) {
69
+ // We check `e.code` (layout-independent physical key) rather than
70
+ // `e.key` (the produced character), so US Shift+1 (`!`),
71
+ // German Shift+1 (`!`), and AZERTY Shift+1 (`1`) all map to H1.
72
+ switch (e.code) {
73
+ case "Digit1":
74
+ toggleHeading(ta, 1);
75
+ return true;
76
+ case "Digit2":
77
+ toggleHeading(ta, 2);
78
+ return true;
79
+ case "Digit3":
80
+ toggleHeading(ta, 3);
81
+ return true;
82
+ case "Digit7":
83
+ toggleNumberedList(ta);
84
+ return true;
85
+ case "Digit8":
86
+ toggleBulletList(ta);
87
+ return true;
88
+ }
89
+ }
90
+
91
+ return false;
92
+ };
93
+
94
+ /* ────────────────────────────────────────────────────────────────────
95
+ * Smart list continuation on Enter
96
+ * ──────────────────────────────────────────────────────────────────── */
97
+
98
+ // `(\s*)` indent, marker (`-`, `*`, `+`, or `N.`), required whitespace,
99
+ // then the rest of the line content.
100
+ const LIST_RE = /^(\s*)([-*+]|\d+\.)(\s+)(.*)$/;
101
+
102
+ const insertViaExecCommand = (ta: HTMLTextAreaElement, start: number, end: number, replacement: string): void => {
103
+ ta.focus();
104
+ ta.setSelectionRange(start, end);
105
+ document.execCommand("insertText", false, replacement);
106
+ };
107
+
108
+ /**
109
+ * Enter inside a list item:
110
+ * - Non-empty item + caret at-or-after the marker → insert a new
111
+ * line with the same indent + marker (numbered lists bump by 1).
112
+ * - Empty item (marker only) → strip the marker, exit the list.
113
+ * - Caret BEFORE the marker (e.g. column 0) → defer to native Enter
114
+ * so we don't produce `\n- - item` nonsense.
115
+ */
116
+ export const handleListContinuation = (ta: HTMLTextAreaElement): boolean => {
117
+ const value = ta.value;
118
+ const caret = ta.selectionStart;
119
+ if (caret !== ta.selectionEnd) return false; // selection → defer to native
120
+
121
+ const lineStart = value.lastIndexOf("\n", caret - 1) + 1;
122
+ const nextNl = value.indexOf("\n", caret);
123
+ const lineEnd = nextNl === -1 ? value.length : nextNl;
124
+ const line = value.slice(lineStart, lineEnd);
125
+
126
+ const m = LIST_RE.exec(line);
127
+ if (!m) return false;
128
+
129
+ const [, indent, marker, spaces, content] = m;
130
+ const markerEndPos = lineStart + indent!.length + marker!.length + spaces!.length;
131
+
132
+ // Caret must be at or past the marker. Editing the indent itself
133
+ // (caret < markerEndPos) gets native Enter.
134
+ if (caret < markerEndPos) return false;
135
+
136
+ // Empty item → exit the list by clearing this line.
137
+ if (content!.trim() === "") {
138
+ insertViaExecCommand(ta, lineStart, lineEnd, "");
139
+ ta.dispatchEvent(new Event("input", { bubbles: true }));
140
+ return true;
141
+ }
142
+
143
+ // Non-empty item → continue with same indent + (renumbered) marker.
144
+ const numbered = /^(\d+)\.$/.exec(marker!);
145
+ const nextMarker = numbered ? `${parseInt(numbered[1]!, 10) + 1}.` : marker!;
146
+ const insert = `\n${indent}${nextMarker}${spaces}`;
147
+ insertViaExecCommand(ta, caret, caret, insert);
148
+ ta.dispatchEvent(new Event("input", { bubbles: true }));
149
+ return true;
150
+ };
151
+
152
+ /* ────────────────────────────────────────────────────────────────────
153
+ * Smart paste — URL on selection becomes a markdown link
154
+ * ──────────────────────────────────────────────────────────────────── */
155
+
156
+ const URL_RE = /^https?:\/\/\S+$/;
157
+
158
+ /** Markdown link destinations end at the first unescaped `)`. URLs in
159
+ * the wild often contain literal parens (Wikipedia disambiguation
160
+ * pages, Microsoft Docs, etc.). Percent-encode them before insertion
161
+ * so the rendered link syntax stays unambiguous. */
162
+ const escapeUrlForMarkdown = (url: string): string => url.replace(/\(/g, "%28").replace(/\)/g, "%29");
163
+
164
+ /** Reject strings that the loose regex accepts but `new URL()` can't
165
+ * parse (typos, mismatched-paren pastes, etc.). */
166
+ const isValidUrl = (raw: string): boolean => {
167
+ try {
168
+ new URL(raw);
169
+ return true;
170
+ } catch {
171
+ return false;
172
+ }
173
+ };
174
+
175
+ /**
176
+ * If the clipboard contains a single URL AND there's a non-empty
177
+ * selection, replace the selection with `[selection](escapedUrl)`.
178
+ * Otherwise, defer to the browser's native paste.
179
+ */
180
+ export const handleSmartPaste = (e: ClipboardEvent, ta: HTMLTextAreaElement): boolean => {
181
+ const clip = e.clipboardData;
182
+ if (!clip) return false;
183
+
184
+ const text = clip.getData("text/plain");
185
+ if (!text) return false;
186
+ const trimmed = text.trim();
187
+ if (!URL_RE.test(trimmed)) return false;
188
+ if (!isValidUrl(trimmed)) return false;
189
+ if (ta.selectionStart === ta.selectionEnd) return false;
190
+
191
+ insertLink(ta, escapeUrlForMarkdown(trimmed));
192
+ return true;
193
+ };
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Markdown-only: detect whether a position in the text falls inside
3
+ * an inline-code span (between backticks) or a fenced code block
4
+ * (between triple-backtick lines).
5
+ *
6
+ * Used by the MarkdownEditor to suppress completions and
7
+ * auto-expansion inside code regions — code content is supposed to
8
+ * be verbatim. Lives here (and not in the generic completion engine)
9
+ * because the rule is markdown-specific.
10
+ */
11
+ export const isInCodeZone = (text: string, pos: number): boolean => {
12
+ const before = text.slice(0, pos);
13
+
14
+ // Fenced code block: count "```" at line starts. Odd count → open.
15
+ const fenceMatches = before.match(/^```/gm);
16
+ if (fenceMatches && fenceMatches.length % 2 !== 0) return true;
17
+
18
+ // Inline code: odd number of `` ` `` on the current line before pos.
19
+ const lineStart = before.lastIndexOf("\n") + 1;
20
+ const lineBefore = before.slice(lineStart);
21
+ const tickCount = (lineBefore.match(/`/g) || []).length;
22
+ return tickCount % 2 !== 0;
23
+ };