@hiai-gg/hiai-docs 0.0.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 (216) hide show
  1. package/.all-contributorsrc +18 -0
  2. package/.claude/settings.local.json +61 -0
  3. package/.dockerignore +113 -0
  4. package/.env.example +68 -0
  5. package/.github/FUNDING.yml +5 -0
  6. package/.github/ISSUE_TEMPLATE/bug_report.md +74 -0
  7. package/.github/ISSUE_TEMPLATE/feature_request.md +78 -0
  8. package/.github/dependabot.yml +136 -0
  9. package/.github/pull_request_template.md +96 -0
  10. package/.github/workflows/ci.yml +283 -0
  11. package/AGENTS.md +237 -0
  12. package/CODE_OF_CONDUCT.md +134 -0
  13. package/CONTRIBUTING.md +77 -0
  14. package/Caddyfile +50 -0
  15. package/Dockerfile.backend +60 -0
  16. package/LICENSE +21 -0
  17. package/README.md +284 -0
  18. package/RELEASE_CHECKLIST.md +34 -0
  19. package/SECURITY.md +60 -0
  20. package/backend/package.json +43 -0
  21. package/backend/src/__tests__/auth-helpers.test.ts +51 -0
  22. package/backend/src/__tests__/chunker.test.ts +65 -0
  23. package/backend/src/__tests__/config.test.ts +91 -0
  24. package/backend/src/__tests__/csrf.test.ts +91 -0
  25. package/backend/src/__tests__/embedding.test.ts +48 -0
  26. package/backend/src/__tests__/rate-limit.test.ts +46 -0
  27. package/backend/src/__tests__/routes.test.ts +38 -0
  28. package/backend/src/__tests__/schema.test.ts +31 -0
  29. package/backend/src/__tests__/validation.test.ts +556 -0
  30. package/backend/src/api/middleware/auth.ts +56 -0
  31. package/backend/src/api/middleware/csrf.ts +91 -0
  32. package/backend/src/api/middleware/rate-limit.ts +77 -0
  33. package/backend/src/api/middleware/webhook-verify.ts +22 -0
  34. package/backend/src/api/routes/attachments.ts +280 -0
  35. package/backend/src/api/routes/auth.ts +52 -0
  36. package/backend/src/api/routes/collaboration.ts +121 -0
  37. package/backend/src/api/routes/documents.ts +664 -0
  38. package/backend/src/api/routes/folders.ts +226 -0
  39. package/backend/src/api/routes/search.ts +354 -0
  40. package/backend/src/api/routes/share.ts +512 -0
  41. package/backend/src/api/routes/tags.ts +247 -0
  42. package/backend/src/api/routes/versions.ts +99 -0
  43. package/backend/src/api/routes/webhooks.ts +43 -0
  44. package/backend/src/embedding/chunker.ts +74 -0
  45. package/backend/src/embedding/index.ts +117 -0
  46. package/backend/src/embedding/providers/ollama.ts +63 -0
  47. package/backend/src/embedding/providers/openrouter.ts +71 -0
  48. package/backend/src/embedding/utils.ts +13 -0
  49. package/backend/src/embedding/worker.ts +89 -0
  50. package/backend/src/index.ts +147 -0
  51. package/backend/src/lib/auth-helpers.ts +27 -0
  52. package/backend/src/lib/auth.ts +35 -0
  53. package/backend/src/lib/config.ts +73 -0
  54. package/backend/src/lib/db.ts +7 -0
  55. package/backend/src/lib/embedding-queue.ts +12 -0
  56. package/backend/src/lib/logger.ts +18 -0
  57. package/backend/src/lib/markdown-to-doc.ts +45 -0
  58. package/backend/src/lib/minio.ts +46 -0
  59. package/backend/src/lib/redis.ts +19 -0
  60. package/backend/src/lib/yjs-provider.ts +182 -0
  61. package/backend/tests/integration/_harness.ts +754 -0
  62. package/backend/tests/integration/auth.test.ts +296 -0
  63. package/backend/tests/integration/routes.documents.test.ts +459 -0
  64. package/backend/tests/integration/routes.folders.test.ts +337 -0
  65. package/backend/tests/integration/routes.search.test.ts +322 -0
  66. package/backend/tests/integration/routes.share.test.ts +773 -0
  67. package/backend/tests/integration/routes.tags.test.ts +425 -0
  68. package/backend/tests/integration/routes.versions.test.ts +233 -0
  69. package/backend/tsconfig.json +18 -0
  70. package/docker-compose.yml +218 -0
  71. package/docs/API.md +328 -0
  72. package/docs/ARCHITECTURE.md +75 -0
  73. package/docs/DEPLOYMENT.md +113 -0
  74. package/docs/PRODUCTION_STATUS.md +61 -0
  75. package/docs/openapi.json +385 -0
  76. package/frontend/.svelte-kit.old/ambient.d.ts +230 -0
  77. package/frontend/.svelte-kit.old/env.d.ts +1 -0
  78. package/frontend/.svelte-kit.old/generated/client/app.js +46 -0
  79. package/frontend/.svelte-kit.old/generated/client/matchers.js +1 -0
  80. package/frontend/.svelte-kit.old/generated/client/nodes/0.js +3 -0
  81. package/frontend/.svelte-kit.old/generated/client/nodes/1.js +1 -0
  82. package/frontend/.svelte-kit.old/generated/client/nodes/10.js +3 -0
  83. package/frontend/.svelte-kit.old/generated/client/nodes/2.js +1 -0
  84. package/frontend/.svelte-kit.old/generated/client/nodes/3.js +1 -0
  85. package/frontend/.svelte-kit.old/generated/client/nodes/4.js +1 -0
  86. package/frontend/.svelte-kit.old/generated/client/nodes/5.js +3 -0
  87. package/frontend/.svelte-kit.old/generated/client/nodes/6.js +1 -0
  88. package/frontend/.svelte-kit.old/generated/client/nodes/7.js +3 -0
  89. package/frontend/.svelte-kit.old/generated/client/nodes/8.js +1 -0
  90. package/frontend/.svelte-kit.old/generated/client/nodes/9.js +3 -0
  91. package/frontend/.svelte-kit.old/generated/root.js +3 -0
  92. package/frontend/.svelte-kit.old/generated/root.svelte +80 -0
  93. package/frontend/.svelte-kit.old/generated/server/internal.js +55 -0
  94. package/frontend/.svelte-kit.old/non-ambient.d.ts +59 -0
  95. package/frontend/.svelte-kit.old/tsconfig.json +59 -0
  96. package/frontend/.svelte-kit.old/types/route_meta_data.json +40 -0
  97. package/frontend/.svelte-kit.old/types/src/routes/$types.d.ts +21 -0
  98. package/frontend/.svelte-kit.old/types/src/routes/(app)/$types.d.ts +30 -0
  99. package/frontend/.svelte-kit.old/types/src/routes/(app)/docs/[id]/$types.d.ts +27 -0
  100. package/frontend/.svelte-kit.old/types/src/routes/(app)/docs/[id]/proxy+page.ts +25 -0
  101. package/frontend/.svelte-kit.old/types/src/routes/api/[...path]/$types.d.ts +10 -0
  102. package/frontend/.svelte-kit.old/types/src/routes/folders/[id]/$types.d.ts +27 -0
  103. package/frontend/.svelte-kit.old/types/src/routes/folders/[id]/proxy+page.ts +15 -0
  104. package/frontend/.svelte-kit.old/types/src/routes/login/$types.d.ts +17 -0
  105. package/frontend/.svelte-kit.old/types/src/routes/register/$types.d.ts +17 -0
  106. package/frontend/.svelte-kit.old/types/src/routes/s/[token]/$types.d.ts +20 -0
  107. package/frontend/.svelte-kit.old/types/src/routes/s/[token]/proxy+page.ts +6 -0
  108. package/frontend/.svelte-kit.old/types/src/routes/search/$types.d.ts +19 -0
  109. package/frontend/.svelte-kit.old/types/src/routes/search/proxy+page.ts +26 -0
  110. package/frontend/.svelte-kit.old/types/src/routes/settings/$types.d.ts +17 -0
  111. package/frontend/Dockerfile +44 -0
  112. package/frontend/biome.json +40 -0
  113. package/frontend/components.json +18 -0
  114. package/frontend/messages/en.json +434 -0
  115. package/frontend/package.json +70 -0
  116. package/frontend/project.inlang/settings.json +12 -0
  117. package/frontend/src/app.css +6 -0
  118. package/frontend/src/app.d.ts +13 -0
  119. package/frontend/src/app.html +30 -0
  120. package/frontend/src/hooks.server.ts +10 -0
  121. package/frontend/src/hooks.ts +10 -0
  122. package/frontend/src/lib/api/attachments.ts +45 -0
  123. package/frontend/src/lib/api/client.test.ts +15 -0
  124. package/frontend/src/lib/api/client.ts +57 -0
  125. package/frontend/src/lib/api/documents.ts +83 -0
  126. package/frontend/src/lib/api/folders.ts +180 -0
  127. package/frontend/src/lib/api/search.test.ts +52 -0
  128. package/frontend/src/lib/api/search.ts +128 -0
  129. package/frontend/src/lib/api/settings.ts +95 -0
  130. package/frontend/src/lib/api/share.ts +71 -0
  131. package/frontend/src/lib/api/tags.test.ts +91 -0
  132. package/frontend/src/lib/api/tags.ts +87 -0
  133. package/frontend/src/lib/auth-client.ts +10 -0
  134. package/frontend/src/lib/collaboration.ts +63 -0
  135. package/frontend/src/lib/components/AttachmentUpload.svelte +110 -0
  136. package/frontend/src/lib/components/DatePicker.svelte +322 -0
  137. package/frontend/src/lib/components/DocumentCard.svelte +166 -0
  138. package/frontend/src/lib/components/EmptyState.svelte +49 -0
  139. package/frontend/src/lib/components/FolderCard.svelte +93 -0
  140. package/frontend/src/lib/components/ScrollToTop.svelte +72 -0
  141. package/frontend/src/lib/components/SearchBar.svelte +47 -0
  142. package/frontend/src/lib/components/SearchResult.svelte +115 -0
  143. package/frontend/src/lib/components/SettingsDialog.svelte +271 -0
  144. package/frontend/src/lib/components/ShareDialog.svelte +158 -0
  145. package/frontend/src/lib/components/ShareLink.svelte +98 -0
  146. package/frontend/src/lib/components/TagCreateDialog.svelte +284 -0
  147. package/frontend/src/lib/components/VersionDiff.svelte +55 -0
  148. package/frontend/src/lib/components/VersionHistory.svelte +96 -0
  149. package/frontend/src/lib/components/editor/DocumentTitle.svelte +87 -0
  150. package/frontend/src/lib/components/editor/EditorToolbar.svelte +1367 -0
  151. package/frontend/src/lib/components/editor/HiAiEditor.svelte +531 -0
  152. package/frontend/src/lib/components/editor/LinkDialog.svelte +134 -0
  153. package/frontend/src/lib/components/editor/MarkdownToggle.svelte +88 -0
  154. package/frontend/src/lib/components/editor/editorExtensions.ts +53 -0
  155. package/frontend/src/lib/components/editor/markdown.ts +38 -0
  156. package/frontend/src/lib/components/sidebar/FolderTree.svelte +731 -0
  157. package/frontend/src/lib/components/sidebar/RecentDocs.svelte +311 -0
  158. package/frontend/src/lib/components/sidebar/Sidebar.svelte +156 -0
  159. package/frontend/src/lib/components/sidebar/TagList.svelte +200 -0
  160. package/frontend/src/lib/components/ui/confirm-dialog/ConfirmDialog.svelte +76 -0
  161. package/frontend/src/lib/components/ui/confirm-dialog/index.ts +1 -0
  162. package/frontend/src/lib/stores/tag-store.svelte.ts +56 -0
  163. package/frontend/src/lib/stores/theme.svelte.ts +97 -0
  164. package/frontend/src/lib/svelte.d.ts +6 -0
  165. package/frontend/src/lib/types.ts +44 -0
  166. package/frontend/src/lib/utils/clipboard.ts +17 -0
  167. package/frontend/src/lib/utils/strip-markdown.ts +59 -0
  168. package/frontend/src/lib/utils.ts +33 -0
  169. package/frontend/src/routes/(app)/+layout.svelte +17 -0
  170. package/frontend/src/routes/(app)/+page.server.ts +10 -0
  171. package/frontend/src/routes/(app)/+page.svelte +303 -0
  172. package/frontend/src/routes/(app)/docs/[id]/+page.server.ts +10 -0
  173. package/frontend/src/routes/(app)/docs/[id]/+page.svelte +1108 -0
  174. package/frontend/src/routes/(app)/docs/[id]/+page.ts +24 -0
  175. package/frontend/src/routes/(app)/search/+page.svelte +593 -0
  176. package/frontend/src/routes/(app)/search/+page.ts +25 -0
  177. package/frontend/src/routes/+error.svelte +12 -0
  178. package/frontend/src/routes/+layout.svelte +18 -0
  179. package/frontend/src/routes/+layout.ts +2 -0
  180. package/frontend/src/routes/api/[...path]/+server.ts +111 -0
  181. package/frontend/src/routes/folders/[id]/+page.server.ts +10 -0
  182. package/frontend/src/routes/folders/[id]/+page.svelte +319 -0
  183. package/frontend/src/routes/folders/[id]/+page.ts +14 -0
  184. package/frontend/src/routes/login/+page.svelte +90 -0
  185. package/frontend/src/routes/register/+page.svelte +97 -0
  186. package/frontend/src/routes/s/[token]/+page.svelte +496 -0
  187. package/frontend/src/routes/s/[token]/+page.ts +5 -0
  188. package/frontend/src/routes/settings/+page.svelte +175 -0
  189. package/frontend/static/favicon.png +0 -0
  190. package/frontend/static/logo.png +0 -0
  191. package/frontend/svelte.config.js +15 -0
  192. package/frontend/tsconfig.json +15 -0
  193. package/frontend/vite.config.ts +25 -0
  194. package/init.sql +9 -0
  195. package/logo.png +0 -0
  196. package/package.json +39 -0
  197. package/package.public.json +39 -0
  198. package/packages/db/drizzle.config.ts +10 -0
  199. package/packages/db/package.json +30 -0
  200. package/packages/db/src/client.ts +9 -0
  201. package/packages/db/src/index.ts +2 -0
  202. package/packages/db/src/migrations/0000_nice_bedlam.sql +165 -0
  203. package/packages/db/src/migrations/0001_w2_3_test.sql +5 -0
  204. package/packages/db/src/migrations/0002_rename_content_json.sql +2 -0
  205. package/packages/db/src/migrations/meta/0000_snapshot.json +1331 -0
  206. package/packages/db/src/migrations/meta/0001_snapshot.json +1399 -0
  207. package/packages/db/src/migrations/meta/0002_snapshot.json +1399 -0
  208. package/packages/db/src/migrations/meta/_journal.json +27 -0
  209. package/packages/db/src/schema.ts +378 -0
  210. package/packages/db/tsconfig.json +17 -0
  211. package/scripts/export-openapi.ts +37 -0
  212. package/scripts/health-check.sh +75 -0
  213. package/scripts/migrate.sh +135 -0
  214. package/scripts/prework_backup.sh +25 -0
  215. package/scripts/release.sh +83 -0
  216. package/tsconfig.json +25 -0
@@ -0,0 +1,1367 @@
1
+ <!-- EditorToolbar.svelte — Formatting toolbar for TipTap editor -->
2
+ <script lang="ts">
3
+ import type { Editor } from "@tiptap/core";
4
+ // biome-ignore lint/style/useImportType: Bold is used as a value in the Svelte template
5
+ import {
6
+ AlignCenter,
7
+ AlignJustify,
8
+ AlignLeft,
9
+ AlignRight,
10
+ Bold,
11
+ Check,
12
+ ChevronDown,
13
+ Code2,
14
+ Copy,
15
+ Heading1,
16
+ Heading2,
17
+ Heading3,
18
+ Highlighter,
19
+ Image as ImageIcon,
20
+ Italic,
21
+ Link as LinkIcon,
22
+ List,
23
+ ListChecks,
24
+ ListOrdered,
25
+ Loader2,
26
+ Minus,
27
+ Quote,
28
+ Redo,
29
+ Smile,
30
+ Table as TableIcon,
31
+ Type,
32
+ Underline,
33
+ Undo,
34
+ } from "lucide-svelte";
35
+ import {
36
+ isFileSizeAllowed,
37
+ isImageFile,
38
+ uploadAttachment,
39
+ } from "$lib/api/attachments";
40
+ import * as m from "$lib/paraglide/messages.js";
41
+ import LinkDialog from "./LinkDialog.svelte";
42
+
43
+ const {
44
+ editor = null,
45
+ documentId = "",
46
+ }: {
47
+ editor?: Editor | null;
48
+ documentId?: string;
49
+ } = $props();
50
+
51
+ interface ToolbarAction {
52
+ icon: typeof Bold;
53
+ label: string;
54
+ // Lookup key into the `activeStates` record so the template can
55
+ // read a fresh boolean without calling `editor.isActive(...)` itself
56
+ // (which would skip Svelte's reactive graph).
57
+ key: string;
58
+ onClick: () => void;
59
+ }
60
+
61
+ // 8 preset highlight colors, keyed to the swatches shown in the popover.
62
+ const HIGHLIGHT_COLORS = [
63
+ { name: m.editor_highlight_yellow(), value: "#fde68a" },
64
+ { name: m.editor_highlight_orange(), value: "#fed7aa" },
65
+ { name: m.editor_highlight_red(), value: "#fecaca" },
66
+ { name: m.editor_highlight_green(), value: "#bbf7d0" },
67
+ { name: m.editor_highlight_blue(), value: "#bfdbfe" },
68
+ { name: m.editor_highlight_purple(), value: "#e9d5ff" },
69
+ { name: m.editor_highlight_pink(), value: "#fbcfe8" },
70
+ { name: m.editor_highlight_gray(), value: "#e5e7eb" },
71
+ ] as const;
72
+
73
+ type HighlightColor = (typeof HIGHLIGHT_COLORS)[number]["value"];
74
+
75
+ // Curated list of 20 common emojis shown in the picker popover. Kept short
76
+ // and useful (faces, gestures, common objects) instead of an exhaustive
77
+ // catalog — the native OS emoji picker is still one click away on most
78
+ // platforms.
79
+ const EMOJIS = [
80
+ "😀",
81
+ "😂",
82
+ "😍",
83
+ "🤔",
84
+ "😎",
85
+ "😢",
86
+ "😡",
87
+ "🥳",
88
+ "👍",
89
+ "👏",
90
+ "🙏",
91
+ "🔥",
92
+ "⭐",
93
+ "✅",
94
+ "❌",
95
+ "❤️",
96
+ "🎉",
97
+ "💡",
98
+ "📌",
99
+ "🚀",
100
+ ] as const;
101
+
102
+ type TextAlignValue = "left" | "center" | "right" | "justify";
103
+
104
+ // Dropdown open flags + popover roots (one per dropdown).
105
+ let linkDialogOpen = $state(false);
106
+ let highlightPickerOpen = $state(false);
107
+ let highlightPickerRoot = $state<HTMLDivElement | null>(null);
108
+ let emojiPickerOpen = $state(false);
109
+ let emojiPickerRoot = $state<HTMLDivElement | null>(null);
110
+ let tablePickerOpen = $state(false);
111
+ let tablePickerRoot = $state<HTMLDivElement | null>(null);
112
+ // Hovered cell extent in the table size-picker grid (1-based; 0 = none).
113
+ let tableHoverRows = $state(0);
114
+ let tableHoverCols = $state(0);
115
+ let headingDropdownOpen = $state(false);
116
+ let headingDropdownRoot = $state<HTMLDivElement | null>(null);
117
+ let listDropdownOpen = $state(false);
118
+ let listDropdownRoot = $state<HTMLDivElement | null>(null);
119
+ let alignDropdownOpen = $state(false);
120
+ let alignDropdownRoot = $state<HTMLDivElement | null>(null);
121
+ let copyConfirmation = $state(false);
122
+
123
+ // TipTap mutates its internal state during transactions but doesn't bump
124
+ // Svelte's reactive graph, so template calls to `editor.isActive(...)` would
125
+ // only re-evaluate when something *else* in the script changes. We track a
126
+ // monotonic revision counter on selection/mark changes and read it from
127
+ // deriveds/template expressions so the toolbar re-renders in sync with the
128
+ // editor.
129
+ let editorRevision = $state(0);
130
+ const readEditorRevision = $derived(editorRevision);
131
+
132
+ $effect(() => {
133
+ if (!editor) return;
134
+ const bump = () => {
135
+ editorRevision++;
136
+ };
137
+ editor.on("selectionUpdate", bump);
138
+ editor.on("transaction", bump);
139
+ return () => {
140
+ editor.off("selectionUpdate", bump);
141
+ editor.off("transaction", bump);
142
+ };
143
+ });
144
+
145
+ // Active-state snapshot for the current selection. Recomputed whenever
146
+ // the editor fires `selectionUpdate`/`transaction` so the toolbar
147
+ // buttons track the caret (or programmatic changes) without a manual
148
+ // rerender. Plain boolean — `class:active` and `aria-pressed` only
149
+ // care about true/false.
150
+ type ActiveStates = Partial<Record<string, boolean>>;
151
+ const activeStates = $derived.by<ActiveStates>(() => {
152
+ // Read `readEditorRevision` for its reactive dependency, not its
153
+ // value. Each key in the returned record corresponds to a toolbar
154
+ // action's name, so the template can look up the right state in O(1).
155
+ void readEditorRevision;
156
+ if (!editor) return {};
157
+ return {
158
+ bold: editor.isActive("bold"),
159
+ italic: editor.isActive("italic"),
160
+ underline: editor.isActive("underline"),
161
+ heading1: editor.isActive("heading", { level: 1 }),
162
+ heading2: editor.isActive("heading", { level: 2 }),
163
+ heading3: editor.isActive("heading", { level: 3 }),
164
+ bulletList: editor.isActive("bulletList"),
165
+ orderedList: editor.isActive("orderedList"),
166
+ taskList: editor.isActive("taskList"),
167
+ blockquote: editor.isActive("blockquote"),
168
+ codeBlock: editor.isActive("codeBlock"),
169
+ link: editor.isActive("link"),
170
+ highlight: editor.isActive("highlight"),
171
+ alignLeft: editor.isActive({ textAlign: "left" }),
172
+ alignCenter: editor.isActive({ textAlign: "center" }),
173
+ alignRight: editor.isActive({ textAlign: "right" }),
174
+ alignJustify: editor.isActive({ textAlign: "justify" }),
175
+ };
176
+ });
177
+
178
+ // Resolve the current heading level (1/2/3) or null if the selection is in
179
+ // a paragraph. Used to drive the Heading dropdown trigger label and icon.
180
+ const activeHeadingLevel = $derived.by<1 | 2 | 3 | null>(() => {
181
+ void readEditorRevision;
182
+ if (!editor) return null;
183
+ if (editor.isActive("heading", { level: 1 })) return 1;
184
+ if (editor.isActive("heading", { level: 2 })) return 2;
185
+ if (editor.isActive("heading", { level: 3 })) return 3;
186
+ return null;
187
+ });
188
+
189
+ // Resolve the current text alignment. Defaults to "left" because that's
190
+ // the editor's default for new content.
191
+ const activeAlignment = $derived.by<TextAlignValue>(() => {
192
+ void readEditorRevision;
193
+ if (!editor) return "left";
194
+ if (editor.isActive({ textAlign: "center" })) return "center";
195
+ if (editor.isActive({ textAlign: "right" })) return "right";
196
+ if (editor.isActive({ textAlign: "justify" })) return "justify";
197
+ return "left";
198
+ });
199
+
200
+ // Resolve the active highlight color from the current selection, if any.
201
+ const activeHighlightColor = $derived.by<HighlightColor | null>(() => {
202
+ if (!editor) return null;
203
+ // Re-run when the editor publishes a selection/transaction so the swatch
204
+ // and the `.active` class on the highlight button track the caret.
205
+ void readEditorRevision;
206
+ if (!editor.isActive("highlight")) return null;
207
+ const attrs = editor.getAttributes("highlight");
208
+ const color = (attrs.color ?? "") as string;
209
+ const match = HIGHLIGHT_COLORS.find((c) => c.value === color);
210
+ return (match?.value as HighlightColor) ?? null;
211
+ });
212
+
213
+ function isDisabled(): boolean {
214
+ if (!editor) return true;
215
+ return !editor.isEditable;
216
+ }
217
+
218
+ function toggleHighlightPicker() {
219
+ highlightPickerOpen = !highlightPickerOpen;
220
+ }
221
+
222
+ function applyHighlight(color: HighlightColor) {
223
+ if (!editor) return;
224
+ editor.chain().focus().toggleHighlight({ color }).run();
225
+ highlightPickerOpen = false;
226
+ }
227
+
228
+ function clearHighlight() {
229
+ if (!editor) return;
230
+ editor.chain().focus().unsetHighlight().run();
231
+ highlightPickerOpen = false;
232
+ }
233
+
234
+ function toggleEmojiPicker() {
235
+ emojiPickerOpen = !emojiPickerOpen;
236
+ }
237
+
238
+ function insertEmoji(emoji: string) {
239
+ if (!editor) return;
240
+ editor.chain().focus().insertContent(emoji).run();
241
+ emojiPickerOpen = false;
242
+ }
243
+
244
+ // Table size-picker: an 8×8 grid where the user hovers to choose how many
245
+ // rows/columns and clicks to insert the table (with a header row).
246
+ const TABLE_GRID_MAX = 8;
247
+
248
+ function toggleTablePicker() {
249
+ tablePickerOpen = !tablePickerOpen;
250
+ tableHoverRows = 0;
251
+ tableHoverCols = 0;
252
+ }
253
+
254
+ function insertTable(rows: number, cols: number) {
255
+ if (!editor) return;
256
+ editor.chain().focus().insertTable({ rows, cols, withHeaderRow: true }).run();
257
+ tablePickerOpen = false;
258
+ }
259
+
260
+ function toggleHeadingDropdown() {
261
+ headingDropdownOpen = !headingDropdownOpen;
262
+ }
263
+
264
+ function applyHeading(level: 1 | 2 | 3 | null) {
265
+ if (!editor) return;
266
+ if (level === null) {
267
+ editor.chain().focus().setParagraph().run();
268
+ } else {
269
+ editor.chain().focus().toggleHeading({ level }).run();
270
+ }
271
+ headingDropdownOpen = false;
272
+ }
273
+
274
+ function toggleListDropdown() {
275
+ listDropdownOpen = !listDropdownOpen;
276
+ }
277
+
278
+ function applyList(kind: "bullet" | "ordered" | "task") {
279
+ if (!editor) return;
280
+ if (kind === "bullet") {
281
+ editor.chain().focus().toggleBulletList().run();
282
+ } else if (kind === "ordered") {
283
+ editor.chain().focus().toggleOrderedList().run();
284
+ } else {
285
+ editor.chain().focus().toggleTaskList().run();
286
+ }
287
+ listDropdownOpen = false;
288
+ }
289
+
290
+ function toggleBlockquote() {
291
+ if (!editor) return;
292
+ editor.chain().focus().toggleBlockquote().run();
293
+ }
294
+
295
+ function insertHorizontalRule() {
296
+ if (!editor) return;
297
+ editor.chain().focus().setHorizontalRule().run();
298
+ }
299
+
300
+ function toggleAlignDropdown() {
301
+ alignDropdownOpen = !alignDropdownOpen;
302
+ }
303
+
304
+ function applyAlignment(value: TextAlignValue) {
305
+ if (!editor) return;
306
+ editor.chain().focus().setTextAlign(value).run();
307
+ alignDropdownOpen = false;
308
+ }
309
+
310
+ function undo() {
311
+ if (!editor) return;
312
+ editor.chain().focus().undo().run();
313
+ }
314
+
315
+ function redo() {
316
+ if (!editor) return;
317
+ editor.chain().focus().redo().run();
318
+ }
319
+
320
+ // Copy the editor's current markdown (falling back to plain text when the
321
+ // Markdown extension's getMarkdown() helper is not available, e.g. while a
322
+ // collaboration doc is mounted without the markdown plugin). The Copy
323
+ // button shows a brief "Copied" confirmation next to the icon for ~1.5s.
324
+ async function copyContent() {
325
+ if (!editor) return;
326
+ const ed = editor as Editor & { getMarkdown?: () => string };
327
+ const content = ed.getMarkdown ? ed.getMarkdown() : editor.getText();
328
+ try {
329
+ await navigator.clipboard.writeText(content);
330
+ copyConfirmation = true;
331
+ setTimeout(() => {
332
+ copyConfirmation = false;
333
+ }, 1500);
334
+ } catch (_err) {
335
+ // Clipboard API can throw if the page is not focused or the
336
+ // permission was denied; swallow — the user can retry or copy
337
+ // manually from the document body.
338
+ }
339
+ }
340
+
341
+ // --- Image upload state ---
342
+ let imageFileInput = $state<HTMLInputElement | null>(null);
343
+ let imageUploading = $state(false);
344
+ let imageError = $state<string | null>(null);
345
+
346
+ function formatMegabytes(bytes: number): string {
347
+ return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
348
+ }
349
+
350
+ function triggerImageUpload() {
351
+ if (imageUploading) return;
352
+ imageError = null;
353
+ imageFileInput?.click();
354
+ }
355
+
356
+ async function handleImageSelected(event: Event) {
357
+ const input = event.currentTarget as HTMLInputElement;
358
+ const file = input.files?.[0];
359
+ // Always reset the input so the same file can be re-selected.
360
+ input.value = "";
361
+ if (!file || !editor) return;
362
+
363
+ if (!isImageFile(file)) {
364
+ imageError = m.attachment_types_hint();
365
+ return;
366
+ }
367
+ if (!isFileSizeAllowed(file)) {
368
+ imageError = m.attachment_file_too_large({
369
+ size: formatMegabytes(file.size),
370
+ });
371
+ return;
372
+ }
373
+ if (!documentId) {
374
+ imageError = m.error_generic();
375
+ return;
376
+ }
377
+
378
+ imageUploading = true;
379
+ imageError = null;
380
+ try {
381
+ const attachment = await uploadAttachment(documentId, file);
382
+ editor
383
+ .chain()
384
+ .focus()
385
+ .setImage({ src: attachment.url, alt: attachment.filename })
386
+ .run();
387
+ } catch (_err) {
388
+ imageError = m.error_server();
389
+ } finally {
390
+ imageUploading = false;
391
+ }
392
+ }
393
+
394
+ // Close all popovers/dropdowns when clicking outside their root element.
395
+ // Each popover shares the same outside-pointer + Escape dismissal logic;
396
+ // the only difference is which open flag and root element they read.
397
+ $effect(() => {
398
+ if (
399
+ !highlightPickerOpen &&
400
+ !emojiPickerOpen &&
401
+ !tablePickerOpen &&
402
+ !headingDropdownOpen &&
403
+ !listDropdownOpen &&
404
+ !alignDropdownOpen
405
+ ) {
406
+ return;
407
+ }
408
+ function onDocPointer(e: PointerEvent) {
409
+ const target = e.target as Node | null;
410
+ if (!target) return;
411
+ if (highlightPickerOpen) {
412
+ const root = highlightPickerRoot;
413
+ if (root && !root.contains(target)) {
414
+ highlightPickerOpen = false;
415
+ }
416
+ }
417
+ if (emojiPickerOpen) {
418
+ const root = emojiPickerRoot;
419
+ if (root && !root.contains(target)) {
420
+ emojiPickerOpen = false;
421
+ }
422
+ }
423
+ if (tablePickerOpen) {
424
+ const root = tablePickerRoot;
425
+ if (root && !root.contains(target)) {
426
+ tablePickerOpen = false;
427
+ }
428
+ }
429
+ if (headingDropdownOpen) {
430
+ const root = headingDropdownRoot;
431
+ if (root && !root.contains(target)) {
432
+ headingDropdownOpen = false;
433
+ }
434
+ }
435
+ if (listDropdownOpen) {
436
+ const root = listDropdownRoot;
437
+ if (root && !root.contains(target)) {
438
+ listDropdownOpen = false;
439
+ }
440
+ }
441
+ if (alignDropdownOpen) {
442
+ const root = alignDropdownRoot;
443
+ if (root && !root.contains(target)) {
444
+ alignDropdownOpen = false;
445
+ }
446
+ }
447
+ }
448
+ function onKey(e: KeyboardEvent) {
449
+ if (e.key === "Escape") {
450
+ highlightPickerOpen = false;
451
+ emojiPickerOpen = false;
452
+ tablePickerOpen = false;
453
+ headingDropdownOpen = false;
454
+ listDropdownOpen = false;
455
+ alignDropdownOpen = false;
456
+ }
457
+ }
458
+ document.addEventListener("pointerdown", onDocPointer);
459
+ document.addEventListener("keydown", onKey);
460
+ return () => {
461
+ document.removeEventListener("pointerdown", onDocPointer);
462
+ document.removeEventListener("keydown", onKey);
463
+ };
464
+ });
465
+ </script>
466
+
467
+ {#if editor}
468
+ <div class="toolbar" role="toolbar" aria-label={m.editor_toolbar_text_formatting()}>
469
+ <!-- Undo / Redo (leftmost) -->
470
+ <button
471
+ class="toolbar-btn"
472
+ disabled={isDisabled()}
473
+ onclick={undo}
474
+ title={m.editor_toolbar_undo()}
475
+ aria-label={m.editor_toolbar_undo()}
476
+ type="button"
477
+ >
478
+ <Undo size={16} />
479
+ </button>
480
+ <button
481
+ class="toolbar-btn"
482
+ disabled={isDisabled()}
483
+ onclick={redo}
484
+ title={m.editor_toolbar_redo()}
485
+ aria-label={m.editor_toolbar_redo()}
486
+ type="button"
487
+ >
488
+ <Redo size={16} />
489
+ </button>
490
+
491
+ <div class="toolbar-divider" aria-hidden="true"></div>
492
+
493
+ <!-- Inline marks: Bold, Italic, Underline -->
494
+ <button
495
+ class="toolbar-btn"
496
+ class:active={activeStates.bold ?? false}
497
+ disabled={isDisabled()}
498
+ onclick={() => editor?.chain().focus().toggleBold().run()}
499
+ title={m.editor_toolbar_bold()}
500
+ aria-label={m.editor_toolbar_bold()}
501
+ aria-pressed={activeStates.bold ?? false}
502
+ type="button"
503
+ >
504
+ <Bold size={16} />
505
+ </button>
506
+ <button
507
+ class="toolbar-btn"
508
+ class:active={activeStates.italic ?? false}
509
+ disabled={isDisabled()}
510
+ onclick={() => editor?.chain().focus().toggleItalic().run()}
511
+ title={m.editor_toolbar_italic()}
512
+ aria-label={m.editor_toolbar_italic()}
513
+ aria-pressed={activeStates.italic ?? false}
514
+ type="button"
515
+ >
516
+ <Italic size={16} />
517
+ </button>
518
+ <button
519
+ class="toolbar-btn"
520
+ class:active={activeStates.underline ?? false}
521
+ disabled={isDisabled()}
522
+ onclick={() => editor?.chain().focus().toggleUnderline().run()}
523
+ title={m.editor_toolbar_underline()}
524
+ aria-label={m.editor_toolbar_underline()}
525
+ aria-pressed={activeStates.underline ?? false}
526
+ type="button"
527
+ >
528
+ <Underline size={16} />
529
+ </button>
530
+
531
+ <div class="toolbar-divider" aria-hidden="true"></div>
532
+
533
+ <!-- Heading dropdown: Paragraph / Heading 1 / Heading 2 / Heading 3 -->
534
+ <div class="dropdown" bind:this={headingDropdownRoot}>
535
+ <button
536
+ class="toolbar-btn dropdown-trigger"
537
+ class:active={activeHeadingLevel !== null}
538
+ disabled={isDisabled()}
539
+ onclick={toggleHeadingDropdown}
540
+ title={m.editor_toolbar_heading()}
541
+ aria-label={m.editor_toolbar_heading()}
542
+ aria-haspopup="true"
543
+ aria-expanded={headingDropdownOpen}
544
+ type="button"
545
+ >
546
+ {#if activeHeadingLevel !== null}
547
+ <Heading1 size={16} />
548
+ {:else}
549
+ <Type size={16} />
550
+ {/if}
551
+ <ChevronDown size={14} class="dropdown-chevron" />
552
+ </button>
553
+
554
+ {#if headingDropdownOpen}
555
+ <div class="dropdown-popover" role="menu" aria-label={m.editor_toolbar_heading()}>
556
+ <button
557
+ type="button"
558
+ class="dropdown-item"
559
+ class:selected={activeHeadingLevel === null}
560
+ role="menuitem"
561
+ onclick={() => applyHeading(null)}
562
+ >
563
+ <Type size={16} />
564
+ <span>{m.editor_toolbar_paragraph()}</span>
565
+ </button>
566
+ <button
567
+ type="button"
568
+ class="dropdown-item"
569
+ class:selected={activeHeadingLevel === 1}
570
+ role="menuitem"
571
+ onclick={() => applyHeading(1)}
572
+ >
573
+ <Heading1 size={16} />
574
+ <span>{m.editor_toolbar_heading_1()}</span>
575
+ </button>
576
+ <button
577
+ type="button"
578
+ class="dropdown-item"
579
+ class:selected={activeHeadingLevel === 2}
580
+ role="menuitem"
581
+ onclick={() => applyHeading(2)}
582
+ >
583
+ <Heading2 size={16} />
584
+ <span>{m.editor_toolbar_heading_2()}</span>
585
+ </button>
586
+ <button
587
+ type="button"
588
+ class="dropdown-item"
589
+ class:selected={activeHeadingLevel === 3}
590
+ role="menuitem"
591
+ onclick={() => applyHeading(3)}
592
+ >
593
+ <Heading3 size={16} />
594
+ <span>{m.editor_toolbar_heading_3()}</span>
595
+ </button>
596
+ </div>
597
+ {/if}
598
+ </div>
599
+
600
+ <div class="toolbar-divider" aria-hidden="true"></div>
601
+
602
+ <!-- List dropdown: Bullet / Numbered -->
603
+ <div class="dropdown" bind:this={listDropdownRoot}>
604
+ <button
605
+ class="toolbar-btn dropdown-trigger"
606
+ class:active={(activeStates.bulletList ?? false) || (activeStates.orderedList ?? false)}
607
+ disabled={isDisabled()}
608
+ onclick={toggleListDropdown}
609
+ title={m.editor_toolbar_list()}
610
+ aria-label={m.editor_toolbar_list()}
611
+ aria-haspopup="true"
612
+ aria-expanded={listDropdownOpen}
613
+ type="button"
614
+ >
615
+ <List size={16} />
616
+ <ChevronDown size={14} class="dropdown-chevron" />
617
+ </button>
618
+
619
+ {#if listDropdownOpen}
620
+ <div class="dropdown-popover" role="menu" aria-label={m.editor_toolbar_list()}>
621
+ <button
622
+ type="button"
623
+ class="dropdown-item"
624
+ class:selected={activeStates.bulletList ?? false}
625
+ role="menuitem"
626
+ onclick={() => applyList("bullet")}
627
+ >
628
+ <List size={16} />
629
+ <span>{m.editor_toolbar_bullet_list()}</span>
630
+ </button>
631
+ <button
632
+ type="button"
633
+ class="dropdown-item"
634
+ class:selected={activeStates.orderedList ?? false}
635
+ role="menuitem"
636
+ onclick={() => applyList("ordered")}
637
+ >
638
+ <ListOrdered size={16} />
639
+ <span>{m.editor_toolbar_ordered_list()}</span>
640
+ </button>
641
+ <button
642
+ type="button"
643
+ class="dropdown-item"
644
+ class:selected={activeStates.taskList ?? false}
645
+ role="menuitem"
646
+ onclick={() => applyList("task")}
647
+ >
648
+ <ListChecks size={16} />
649
+ <span>Task list</span>
650
+ </button>
651
+ </div>
652
+ {/if}
653
+ </div>
654
+
655
+ <div class="toolbar-divider" aria-hidden="true"></div>
656
+
657
+ <!-- Block marks: Code block, Quote, Horizontal rule, Link -->
658
+ <button
659
+ class="toolbar-btn"
660
+ class:active={activeStates.codeBlock ?? false}
661
+ disabled={isDisabled()}
662
+ onclick={() => editor?.chain().focus().toggleCodeBlock().run()}
663
+ title={m.editor_toolbar_code_block()}
664
+ aria-label={m.editor_toolbar_code_block()}
665
+ aria-pressed={activeStates.codeBlock ?? false}
666
+ type="button"
667
+ >
668
+ <Code2 size={16} />
669
+ </button>
670
+ <button
671
+ class="toolbar-btn"
672
+ class:active={activeStates.blockquote ?? false}
673
+ disabled={isDisabled()}
674
+ onclick={toggleBlockquote}
675
+ title="Quote"
676
+ aria-label="Quote"
677
+ aria-pressed={activeStates.blockquote ?? false}
678
+ type="button"
679
+ >
680
+ <Quote size={16} />
681
+ </button>
682
+ <button
683
+ class="toolbar-btn"
684
+ disabled={isDisabled()}
685
+ onclick={insertHorizontalRule}
686
+ title={m.editor_toolbar_horizontal_rule()}
687
+ aria-label={m.editor_toolbar_horizontal_rule()}
688
+ type="button"
689
+ >
690
+ <Minus size={16} />
691
+ </button>
692
+ <button
693
+ class="toolbar-btn"
694
+ class:active={activeStates.link ?? false}
695
+ disabled={isDisabled()}
696
+ onclick={() => (linkDialogOpen = true)}
697
+ title={m.editor_toolbar_link()}
698
+ aria-label={m.editor_toolbar_link()}
699
+ aria-pressed={activeStates.link ?? false}
700
+ type="button"
701
+ >
702
+ <LinkIcon size={16} />
703
+ </button>
704
+
705
+ <div class="toolbar-divider" aria-hidden="true"></div>
706
+
707
+ <!-- Highlight color picker -->
708
+ <div class="highlight-picker" bind:this={highlightPickerRoot}>
709
+ <button
710
+ class="toolbar-btn highlight-btn"
711
+ class:active={activeStates.highlight ?? false}
712
+ disabled={isDisabled()}
713
+ onclick={toggleHighlightPicker}
714
+ title={m.editor_toolbar_highlight()}
715
+ aria-label={m.editor_toolbar_highlight()}
716
+ aria-pressed={activeStates.highlight ?? false}
717
+ aria-haspopup="true"
718
+ aria-expanded={highlightPickerOpen}
719
+ type="button"
720
+ >
721
+ <Highlighter size={16} />
722
+ <span
723
+ class="highlight-dot"
724
+ class:visible={activeHighlightColor !== null}
725
+ style:background-color={activeHighlightColor ?? "transparent"}
726
+ aria-hidden="true"
727
+ ></span>
728
+ </button>
729
+
730
+ {#if highlightPickerOpen}
731
+ <div class="highlight-popover" role="menu" aria-label={m.editor_toolbar_highlight()}>
732
+ <div class="highlight-swatch-grid">
733
+ {#each HIGHLIGHT_COLORS as color (color.value)}
734
+ <button
735
+ type="button"
736
+ class="highlight-swatch"
737
+ class:selected={activeHighlightColor === color.value}
738
+ style:background-color={color.value}
739
+ title={color.name}
740
+ aria-label={color.name}
741
+ role="menuitem"
742
+ onclick={() => applyHighlight(color.value)}
743
+ ></button>
744
+ {/each}
745
+ </div>
746
+ {#if activeHighlightColor !== null}
747
+ <button
748
+ type="button"
749
+ class="highlight-clear"
750
+ role="menuitem"
751
+ onclick={clearHighlight}
752
+ >
753
+ {m.action_cancel()}
754
+ </button>
755
+ {/if}
756
+ </div>
757
+ {/if}
758
+ </div>
759
+
760
+ <div class="toolbar-divider" aria-hidden="true"></div>
761
+
762
+ <!-- Align dropdown: Left / Center / Right / Justify -->
763
+ <div class="dropdown" bind:this={alignDropdownRoot}>
764
+ <button
765
+ class="toolbar-btn dropdown-trigger"
766
+ class:active={activeAlignment !== "left"}
767
+ disabled={isDisabled()}
768
+ onclick={toggleAlignDropdown}
769
+ title={m.editor_toolbar_align()}
770
+ aria-label={m.editor_toolbar_align()}
771
+ aria-haspopup="true"
772
+ aria-expanded={alignDropdownOpen}
773
+ type="button"
774
+ >
775
+ {#if activeAlignment === "center"}
776
+ <AlignCenter size={16} />
777
+ {:else if activeAlignment === "right"}
778
+ <AlignRight size={16} />
779
+ {:else if activeAlignment === "justify"}
780
+ <AlignJustify size={16} />
781
+ {:else}
782
+ <AlignLeft size={16} />
783
+ {/if}
784
+ <ChevronDown size={14} class="dropdown-chevron" />
785
+ </button>
786
+
787
+ {#if alignDropdownOpen}
788
+ <div class="dropdown-popover" role="menu" aria-label={m.editor_toolbar_align()}>
789
+ <button
790
+ type="button"
791
+ class="dropdown-item"
792
+ class:selected={activeAlignment === "left"}
793
+ role="menuitem"
794
+ onclick={() => applyAlignment("left")}
795
+ >
796
+ <AlignLeft size={16} />
797
+ <span>{m.editor_toolbar_align_left()}</span>
798
+ </button>
799
+ <button
800
+ type="button"
801
+ class="dropdown-item"
802
+ class:selected={activeAlignment === "center"}
803
+ role="menuitem"
804
+ onclick={() => applyAlignment("center")}
805
+ >
806
+ <AlignCenter size={16} />
807
+ <span>{m.editor_toolbar_align_center()}</span>
808
+ </button>
809
+ <button
810
+ type="button"
811
+ class="dropdown-item"
812
+ class:selected={activeAlignment === "right"}
813
+ role="menuitem"
814
+ onclick={() => applyAlignment("right")}
815
+ >
816
+ <AlignRight size={16} />
817
+ <span>{m.editor_toolbar_align_right()}</span>
818
+ </button>
819
+ <button
820
+ type="button"
821
+ class="dropdown-item"
822
+ class:selected={activeAlignment === "justify"}
823
+ role="menuitem"
824
+ onclick={() => applyAlignment("justify")}
825
+ >
826
+ <AlignJustify size={16} />
827
+ <span>{m.editor_toolbar_align_justify()}</span>
828
+ </button>
829
+ </div>
830
+ {/if}
831
+ </div>
832
+
833
+ <div class="toolbar-divider" aria-hidden="true"></div>
834
+
835
+ <!-- Image upload -->
836
+ <button
837
+ class="toolbar-btn image-btn"
838
+ class:uploading={imageUploading}
839
+ disabled={isDisabled() || imageUploading}
840
+ onclick={triggerImageUpload}
841
+ title={imageError ?? m.editor_toolbar_image()}
842
+ aria-label={m.editor_toolbar_image()}
843
+ type="button"
844
+ >
845
+ {#if imageUploading}
846
+ <Loader2 size={16} class="animate-spin" />
847
+ {:else}
848
+ <ImageIcon size={16} />
849
+ {/if}
850
+ </button>
851
+
852
+ <input
853
+ bind:this={imageFileInput}
854
+ type="file"
855
+ accept="image/*"
856
+ class="visually-hidden-file-input"
857
+ onchange={handleImageSelected}
858
+ />
859
+
860
+ <div class="toolbar-divider" aria-hidden="true"></div>
861
+
862
+ <!-- Emoji picker -->
863
+ <div class="emoji-picker" bind:this={emojiPickerRoot}>
864
+ <button
865
+ class="toolbar-btn"
866
+ disabled={isDisabled()}
867
+ onclick={toggleEmojiPicker}
868
+ title={m.editor_toolbar_emoji()}
869
+ aria-label={m.editor_toolbar_emoji()}
870
+ aria-haspopup="true"
871
+ aria-expanded={emojiPickerOpen}
872
+ type="button"
873
+ >
874
+ <Smile size={16} />
875
+ </button>
876
+
877
+ {#if emojiPickerOpen}
878
+ <div class="emoji-popover" role="menu" aria-label={m.editor_toolbar_emoji()}>
879
+ <div class="emoji-grid">
880
+ {#each EMOJIS as emoji (emoji)}
881
+ <button
882
+ type="button"
883
+ class="emoji-button"
884
+ role="menuitem"
885
+ onclick={() => insertEmoji(emoji)}
886
+ aria-label={emoji}
887
+ >
888
+ {emoji}
889
+ </button>
890
+ {/each}
891
+ </div>
892
+ </div>
893
+ {/if}
894
+ </div>
895
+
896
+ <div class="toolbar-divider" aria-hidden="true"></div>
897
+
898
+ <!-- Table insert with size picker -->
899
+ <div class="table-picker" bind:this={tablePickerRoot}>
900
+ <button
901
+ class="toolbar-btn"
902
+ disabled={isDisabled()}
903
+ onclick={toggleTablePicker}
904
+ title="Insert table"
905
+ aria-label="Insert table"
906
+ aria-haspopup="true"
907
+ aria-expanded={tablePickerOpen}
908
+ type="button"
909
+ >
910
+ <TableIcon size={16} />
911
+ </button>
912
+
913
+ {#if tablePickerOpen}
914
+ <div class="table-popover" role="menu" aria-label="Insert table">
915
+ <div class="table-grid" role="presentation">
916
+ {#each Array(TABLE_GRID_MAX) as _, r}
917
+ {#each Array(TABLE_GRID_MAX) as _, c}
918
+ <button
919
+ type="button"
920
+ class="table-cell"
921
+ class:active={r < tableHoverRows && c < tableHoverCols}
922
+ onmouseenter={() => {
923
+ tableHoverRows = r + 1;
924
+ tableHoverCols = c + 1;
925
+ }}
926
+ onfocus={() => {
927
+ tableHoverRows = r + 1;
928
+ tableHoverCols = c + 1;
929
+ }}
930
+ onclick={() => insertTable(r + 1, c + 1)}
931
+ aria-label={`${r + 1} × ${c + 1}`}
932
+ ></button>
933
+ {/each}
934
+ {/each}
935
+ </div>
936
+ <div class="table-grid-label">
937
+ {tableHoverRows > 0
938
+ ? `${tableHoverRows} × ${tableHoverCols}`
939
+ : "Insert table"}
940
+ </div>
941
+ </div>
942
+ {/if}
943
+ </div>
944
+
945
+ <div class="toolbar-divider" aria-hidden="true"></div>
946
+
947
+ <button
948
+ class="toolbar-btn copy-btn"
949
+ class:copied={copyConfirmation}
950
+ disabled={isDisabled()}
951
+ onclick={copyContent}
952
+ title={copyConfirmation ? m.editor_toolbar_copied() : m.editor_toolbar_copy()}
953
+ aria-label={m.editor_toolbar_copy()}
954
+ type="button"
955
+ >
956
+ {#if copyConfirmation}
957
+ <Check size={16} />
958
+ {:else}
959
+ <Copy size={16} />
960
+ {/if}
961
+ </button>
962
+ </div>
963
+
964
+ {#if imageError}
965
+ <div class="image-error" role="alert">
966
+ <span>{imageError}</span>
967
+ <button
968
+ type="button"
969
+ class="image-error-dismiss"
970
+ onclick={() => (imageError = null)}
971
+ aria-label={m.error_dismiss()}
972
+ >
973
+ &times;
974
+ </button>
975
+ </div>
976
+ {/if}
977
+
978
+ <LinkDialog bind:open={linkDialogOpen} {editor} />
979
+ {/if}
980
+
981
+ <style>
982
+ .toolbar {
983
+ display: flex;
984
+ align-items: center;
985
+ gap: 1px;
986
+ padding: 6px 10px;
987
+ border-bottom: 1px solid var(--border);
988
+ background: var(--card);
989
+ flex-wrap: wrap;
990
+ position: sticky;
991
+ top: 0;
992
+ z-index: 10;
993
+ }
994
+
995
+ .toolbar-divider {
996
+ width: 1px;
997
+ height: 18px;
998
+ background: var(--border);
999
+ margin: 0 2px;
1000
+ }
1001
+
1002
+ .toolbar-btn {
1003
+ display: inline-flex;
1004
+ align-items: center;
1005
+ justify-content: center;
1006
+ min-width: 34px;
1007
+ min-height: 34px;
1008
+ border: none;
1009
+ border-radius: 6px;
1010
+ background: transparent;
1011
+ color: var(--muted-foreground);
1012
+ cursor: pointer;
1013
+ transition: all 0.15s ease;
1014
+ }
1015
+
1016
+ .toolbar-btn:hover:not(:disabled) {
1017
+ background: var(--accent);
1018
+ color: var(--accent-foreground);
1019
+ }
1020
+
1021
+ .toolbar-btn.active {
1022
+ background: var(--primary);
1023
+ color: var(--primary-foreground);
1024
+ }
1025
+
1026
+ .toolbar-btn:disabled {
1027
+ opacity: 0.4;
1028
+ cursor: not-allowed;
1029
+ }
1030
+
1031
+ /* Dropdown trigger (heading / list / align) — keeps the trigger aligned
1032
+ with regular toolbar buttons but adds a small chevron to signal that
1033
+ a menu will open. */
1034
+ .dropdown {
1035
+ position: relative;
1036
+ display: inline-flex;
1037
+ align-items: center;
1038
+ justify-content: center;
1039
+ }
1040
+
1041
+ .dropdown-trigger {
1042
+ gap: 2px;
1043
+ padding: 0 6px;
1044
+ }
1045
+
1046
+ .dropdown-trigger-label {
1047
+ font-size: 0.8rem;
1048
+ font-weight: 500;
1049
+ line-height: 1;
1050
+ }
1051
+
1052
+ :global(.dropdown-chevron) {
1053
+ opacity: 0.6;
1054
+ }
1055
+
1056
+ .dropdown-popover {
1057
+ position: absolute;
1058
+ top: calc(100% + 6px);
1059
+ left: 0;
1060
+ z-index: 50;
1061
+ display: flex;
1062
+ flex-direction: column;
1063
+ gap: 2px;
1064
+ padding: 6px;
1065
+ background: var(--popover);
1066
+ color: var(--popover-foreground);
1067
+ border: 1px solid var(--border);
1068
+ border-radius: 8px;
1069
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
1070
+ min-width: 180px;
1071
+ }
1072
+
1073
+ .dropdown-item {
1074
+ display: inline-flex;
1075
+ align-items: center;
1076
+ gap: 10px;
1077
+ padding: 6px 10px;
1078
+ min-height: 32px;
1079
+ font-size: 0.875rem;
1080
+ background: transparent;
1081
+ border: none;
1082
+ border-radius: 4px;
1083
+ color: var(--popover-foreground);
1084
+ cursor: pointer;
1085
+ text-align: left;
1086
+ transition: background 0.1s ease;
1087
+ }
1088
+
1089
+ .dropdown-item:hover {
1090
+ background: var(--accent);
1091
+ color: var(--accent-foreground);
1092
+ }
1093
+
1094
+ .dropdown-item.selected {
1095
+ background: color-mix(in srgb, var(--primary) 18%, transparent);
1096
+ color: var(--foreground);
1097
+ }
1098
+
1099
+ /* Highlight popover trigger button + indicator */
1100
+ .highlight-picker {
1101
+ position: relative;
1102
+ display: inline-flex;
1103
+ align-items: center;
1104
+ justify-content: center;
1105
+ }
1106
+
1107
+ .highlight-btn {
1108
+ position: relative;
1109
+ }
1110
+
1111
+ .highlight-dot {
1112
+ position: absolute;
1113
+ bottom: 6px;
1114
+ right: 6px;
1115
+ width: 8px;
1116
+ height: 8px;
1117
+ border-radius: 50%;
1118
+ border: 1px solid var(--border);
1119
+ opacity: 0;
1120
+ transform: scale(0.6);
1121
+ transition: opacity 0.15s ease, transform 0.15s ease;
1122
+ pointer-events: none;
1123
+ }
1124
+
1125
+ .highlight-dot.visible {
1126
+ opacity: 1;
1127
+ transform: scale(1);
1128
+ }
1129
+
1130
+ /* Popover panel */
1131
+ .highlight-popover {
1132
+ position: absolute;
1133
+ top: calc(100% + 6px);
1134
+ left: 0;
1135
+ z-index: 50;
1136
+ display: flex;
1137
+ flex-direction: column;
1138
+ gap: 6px;
1139
+ padding: 8px;
1140
+ background: var(--popover);
1141
+ color: var(--popover-foreground);
1142
+ border: 1px solid var(--border);
1143
+ border-radius: 8px;
1144
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
1145
+ min-width: 180px;
1146
+ }
1147
+
1148
+ .highlight-swatch-grid {
1149
+ display: grid;
1150
+ grid-template-columns: repeat(4, 1fr);
1151
+ gap: 6px;
1152
+ }
1153
+
1154
+ .highlight-swatch {
1155
+ width: 28px;
1156
+ height: 28px;
1157
+ border-radius: 6px;
1158
+ border: 1px solid var(--border);
1159
+ cursor: pointer;
1160
+ padding: 0;
1161
+ transition: transform 0.1s ease, box-shadow 0.1s ease;
1162
+ }
1163
+
1164
+ .highlight-swatch:hover {
1165
+ transform: scale(1.08);
1166
+ box-shadow: 0 0 0 2px var(--ring);
1167
+ }
1168
+
1169
+ .highlight-swatch.selected {
1170
+ box-shadow: 0 0 0 2px var(--ring);
1171
+ }
1172
+
1173
+ .highlight-clear {
1174
+ display: inline-flex;
1175
+ align-items: center;
1176
+ justify-content: center;
1177
+ padding: 4px 8px;
1178
+ font-size: 0.75rem;
1179
+ color: var(--muted-foreground);
1180
+ background: transparent;
1181
+ border: 1px solid var(--border);
1182
+ border-radius: 4px;
1183
+ cursor: pointer;
1184
+ }
1185
+
1186
+ .highlight-clear:hover {
1187
+ background: var(--accent);
1188
+ color: var(--accent-foreground);
1189
+ }
1190
+
1191
+ /* Hidden file input (still keyboard-focusable for screen readers, but visually hidden) */
1192
+ .visually-hidden-file-input {
1193
+ position: absolute;
1194
+ width: 1px;
1195
+ height: 1px;
1196
+ padding: 0;
1197
+ margin: -1px;
1198
+ overflow: hidden;
1199
+ clip: rect(0, 0, 0, 0);
1200
+ white-space: nowrap;
1201
+ border: 0;
1202
+ }
1203
+
1204
+ /* Image upload button — uses the same base as .toolbar-btn but needs
1205
+ a small visual cue while uploading so users know it's working. */
1206
+ .image-btn.uploading {
1207
+ color: var(--ring);
1208
+ }
1209
+
1210
+ .image-btn:disabled:not(.uploading) {
1211
+ opacity: 0.4;
1212
+ cursor: not-allowed;
1213
+ }
1214
+
1215
+ @keyframes spin {
1216
+ from {
1217
+ transform: rotate(0deg);
1218
+ }
1219
+ to {
1220
+ transform: rotate(360deg);
1221
+ }
1222
+ }
1223
+
1224
+ .image-btn :global(.animate-spin) {
1225
+ animation: spin 1s linear infinite;
1226
+ }
1227
+
1228
+ /* Inline error banner shown under the toolbar when an upload fails or
1229
+ the file is rejected by client-side validation. */
1230
+ .image-error {
1231
+ display: flex;
1232
+ align-items: center;
1233
+ justify-content: space-between;
1234
+ gap: 8px;
1235
+ padding: 6px 12px;
1236
+ font-size: 12px;
1237
+ background: color-mix(in srgb, var(--destructive) 10%, transparent);
1238
+ color: var(--destructive);
1239
+ border-bottom: 1px solid color-mix(in srgb, var(--destructive) 20%, transparent);
1240
+ }
1241
+
1242
+ .image-error-dismiss {
1243
+ background: none;
1244
+ border: none;
1245
+ color: var(--destructive);
1246
+ cursor: pointer;
1247
+ font-size: 16px;
1248
+ line-height: 1;
1249
+ padding: 0 4px;
1250
+ }
1251
+
1252
+ /* Emoji picker — same popover pattern as the highlight picker, but the
1253
+ grid cells render an emoji glyph instead of a color swatch. */
1254
+ .emoji-picker {
1255
+ position: relative;
1256
+ display: inline-flex;
1257
+ align-items: center;
1258
+ justify-content: center;
1259
+ }
1260
+
1261
+ .emoji-popover {
1262
+ position: absolute;
1263
+ top: calc(100% + 6px);
1264
+ left: 0;
1265
+ z-index: 70;
1266
+ padding: 8px;
1267
+ background: var(--popover);
1268
+ color: var(--popover-foreground);
1269
+ border: 1px solid var(--border);
1270
+ border-radius: 8px;
1271
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
1272
+ min-width: 220px;
1273
+ }
1274
+
1275
+ .emoji-grid {
1276
+ display: grid;
1277
+ grid-template-columns: repeat(5, 1fr);
1278
+ gap: 4px;
1279
+ }
1280
+
1281
+ .emoji-button {
1282
+ display: inline-flex;
1283
+ align-items: center;
1284
+ justify-content: center;
1285
+ width: 36px;
1286
+ height: 36px;
1287
+ font-size: 1.25rem;
1288
+ line-height: 1;
1289
+ background: transparent;
1290
+ border: 1px solid transparent;
1291
+ border-radius: 6px;
1292
+ cursor: pointer;
1293
+ padding: 0;
1294
+ transition: background 0.1s ease, transform 0.1s ease;
1295
+ }
1296
+
1297
+ .emoji-button:hover {
1298
+ background: var(--accent);
1299
+ transform: scale(1.08);
1300
+ }
1301
+
1302
+ .emoji-button:focus-visible {
1303
+ outline: 2px solid var(--ring);
1304
+ outline-offset: 1px;
1305
+ }
1306
+
1307
+ /* Table size-picker — hover the grid to choose rows × columns. */
1308
+ .table-picker {
1309
+ position: relative;
1310
+ display: inline-flex;
1311
+ align-items: center;
1312
+ justify-content: center;
1313
+ }
1314
+
1315
+ .table-popover {
1316
+ position: absolute;
1317
+ top: calc(100% + 6px);
1318
+ left: 0;
1319
+ z-index: 70;
1320
+ padding: 8px;
1321
+ background: var(--popover);
1322
+ color: var(--popover-foreground);
1323
+ border: 1px solid var(--border);
1324
+ border-radius: 8px;
1325
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
1326
+ }
1327
+
1328
+ .table-grid {
1329
+ display: grid;
1330
+ grid-template-columns: repeat(8, 18px);
1331
+ grid-auto-rows: 18px;
1332
+ gap: 3px;
1333
+ }
1334
+
1335
+ .table-cell {
1336
+ width: 18px;
1337
+ height: 18px;
1338
+ padding: 0;
1339
+ border: 1px solid var(--border);
1340
+ border-radius: 3px;
1341
+ background: var(--background);
1342
+ cursor: pointer;
1343
+ transition: background 0.08s ease, border-color 0.08s ease;
1344
+ }
1345
+
1346
+ .table-cell:hover {
1347
+ border-color: var(--primary);
1348
+ }
1349
+
1350
+ .table-cell.active {
1351
+ background: color-mix(in srgb, var(--primary) 35%, transparent);
1352
+ border-color: var(--primary);
1353
+ }
1354
+
1355
+ .table-grid-label {
1356
+ margin-top: 8px;
1357
+ text-align: center;
1358
+ font-size: 0.75rem;
1359
+ color: var(--muted-foreground);
1360
+ }
1361
+
1362
+ /* Copy button — turn the icon green briefly when the clipboard write
1363
+ succeeded so users get a clear visual confirmation. */
1364
+ .copy-btn.copied {
1365
+ color: var(--primary);
1366
+ }
1367
+ </style>