@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.
- package/.all-contributorsrc +18 -0
- package/.claude/settings.local.json +61 -0
- package/.dockerignore +113 -0
- package/.env.example +68 -0
- package/.github/FUNDING.yml +5 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +74 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +78 -0
- package/.github/dependabot.yml +136 -0
- package/.github/pull_request_template.md +96 -0
- package/.github/workflows/ci.yml +283 -0
- package/AGENTS.md +237 -0
- package/CODE_OF_CONDUCT.md +134 -0
- package/CONTRIBUTING.md +77 -0
- package/Caddyfile +50 -0
- package/Dockerfile.backend +60 -0
- package/LICENSE +21 -0
- package/README.md +284 -0
- package/RELEASE_CHECKLIST.md +34 -0
- package/SECURITY.md +60 -0
- package/backend/package.json +43 -0
- package/backend/src/__tests__/auth-helpers.test.ts +51 -0
- package/backend/src/__tests__/chunker.test.ts +65 -0
- package/backend/src/__tests__/config.test.ts +91 -0
- package/backend/src/__tests__/csrf.test.ts +91 -0
- package/backend/src/__tests__/embedding.test.ts +48 -0
- package/backend/src/__tests__/rate-limit.test.ts +46 -0
- package/backend/src/__tests__/routes.test.ts +38 -0
- package/backend/src/__tests__/schema.test.ts +31 -0
- package/backend/src/__tests__/validation.test.ts +556 -0
- package/backend/src/api/middleware/auth.ts +56 -0
- package/backend/src/api/middleware/csrf.ts +91 -0
- package/backend/src/api/middleware/rate-limit.ts +77 -0
- package/backend/src/api/middleware/webhook-verify.ts +22 -0
- package/backend/src/api/routes/attachments.ts +280 -0
- package/backend/src/api/routes/auth.ts +52 -0
- package/backend/src/api/routes/collaboration.ts +121 -0
- package/backend/src/api/routes/documents.ts +664 -0
- package/backend/src/api/routes/folders.ts +226 -0
- package/backend/src/api/routes/search.ts +354 -0
- package/backend/src/api/routes/share.ts +512 -0
- package/backend/src/api/routes/tags.ts +247 -0
- package/backend/src/api/routes/versions.ts +99 -0
- package/backend/src/api/routes/webhooks.ts +43 -0
- package/backend/src/embedding/chunker.ts +74 -0
- package/backend/src/embedding/index.ts +117 -0
- package/backend/src/embedding/providers/ollama.ts +63 -0
- package/backend/src/embedding/providers/openrouter.ts +71 -0
- package/backend/src/embedding/utils.ts +13 -0
- package/backend/src/embedding/worker.ts +89 -0
- package/backend/src/index.ts +147 -0
- package/backend/src/lib/auth-helpers.ts +27 -0
- package/backend/src/lib/auth.ts +35 -0
- package/backend/src/lib/config.ts +73 -0
- package/backend/src/lib/db.ts +7 -0
- package/backend/src/lib/embedding-queue.ts +12 -0
- package/backend/src/lib/logger.ts +18 -0
- package/backend/src/lib/markdown-to-doc.ts +45 -0
- package/backend/src/lib/minio.ts +46 -0
- package/backend/src/lib/redis.ts +19 -0
- package/backend/src/lib/yjs-provider.ts +182 -0
- package/backend/tests/integration/_harness.ts +754 -0
- package/backend/tests/integration/auth.test.ts +296 -0
- package/backend/tests/integration/routes.documents.test.ts +459 -0
- package/backend/tests/integration/routes.folders.test.ts +337 -0
- package/backend/tests/integration/routes.search.test.ts +322 -0
- package/backend/tests/integration/routes.share.test.ts +773 -0
- package/backend/tests/integration/routes.tags.test.ts +425 -0
- package/backend/tests/integration/routes.versions.test.ts +233 -0
- package/backend/tsconfig.json +18 -0
- package/docker-compose.yml +218 -0
- package/docs/API.md +328 -0
- package/docs/ARCHITECTURE.md +75 -0
- package/docs/DEPLOYMENT.md +113 -0
- package/docs/PRODUCTION_STATUS.md +61 -0
- package/docs/openapi.json +385 -0
- package/frontend/.svelte-kit.old/ambient.d.ts +230 -0
- package/frontend/.svelte-kit.old/env.d.ts +1 -0
- package/frontend/.svelte-kit.old/generated/client/app.js +46 -0
- package/frontend/.svelte-kit.old/generated/client/matchers.js +1 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/0.js +3 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/1.js +1 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/10.js +3 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/2.js +1 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/3.js +1 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/4.js +1 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/5.js +3 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/6.js +1 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/7.js +3 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/8.js +1 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/9.js +3 -0
- package/frontend/.svelte-kit.old/generated/root.js +3 -0
- package/frontend/.svelte-kit.old/generated/root.svelte +80 -0
- package/frontend/.svelte-kit.old/generated/server/internal.js +55 -0
- package/frontend/.svelte-kit.old/non-ambient.d.ts +59 -0
- package/frontend/.svelte-kit.old/tsconfig.json +59 -0
- package/frontend/.svelte-kit.old/types/route_meta_data.json +40 -0
- package/frontend/.svelte-kit.old/types/src/routes/$types.d.ts +21 -0
- package/frontend/.svelte-kit.old/types/src/routes/(app)/$types.d.ts +30 -0
- package/frontend/.svelte-kit.old/types/src/routes/(app)/docs/[id]/$types.d.ts +27 -0
- package/frontend/.svelte-kit.old/types/src/routes/(app)/docs/[id]/proxy+page.ts +25 -0
- package/frontend/.svelte-kit.old/types/src/routes/api/[...path]/$types.d.ts +10 -0
- package/frontend/.svelte-kit.old/types/src/routes/folders/[id]/$types.d.ts +27 -0
- package/frontend/.svelte-kit.old/types/src/routes/folders/[id]/proxy+page.ts +15 -0
- package/frontend/.svelte-kit.old/types/src/routes/login/$types.d.ts +17 -0
- package/frontend/.svelte-kit.old/types/src/routes/register/$types.d.ts +17 -0
- package/frontend/.svelte-kit.old/types/src/routes/s/[token]/$types.d.ts +20 -0
- package/frontend/.svelte-kit.old/types/src/routes/s/[token]/proxy+page.ts +6 -0
- package/frontend/.svelte-kit.old/types/src/routes/search/$types.d.ts +19 -0
- package/frontend/.svelte-kit.old/types/src/routes/search/proxy+page.ts +26 -0
- package/frontend/.svelte-kit.old/types/src/routes/settings/$types.d.ts +17 -0
- package/frontend/Dockerfile +44 -0
- package/frontend/biome.json +40 -0
- package/frontend/components.json +18 -0
- package/frontend/messages/en.json +434 -0
- package/frontend/package.json +70 -0
- package/frontend/project.inlang/settings.json +12 -0
- package/frontend/src/app.css +6 -0
- package/frontend/src/app.d.ts +13 -0
- package/frontend/src/app.html +30 -0
- package/frontend/src/hooks.server.ts +10 -0
- package/frontend/src/hooks.ts +10 -0
- package/frontend/src/lib/api/attachments.ts +45 -0
- package/frontend/src/lib/api/client.test.ts +15 -0
- package/frontend/src/lib/api/client.ts +57 -0
- package/frontend/src/lib/api/documents.ts +83 -0
- package/frontend/src/lib/api/folders.ts +180 -0
- package/frontend/src/lib/api/search.test.ts +52 -0
- package/frontend/src/lib/api/search.ts +128 -0
- package/frontend/src/lib/api/settings.ts +95 -0
- package/frontend/src/lib/api/share.ts +71 -0
- package/frontend/src/lib/api/tags.test.ts +91 -0
- package/frontend/src/lib/api/tags.ts +87 -0
- package/frontend/src/lib/auth-client.ts +10 -0
- package/frontend/src/lib/collaboration.ts +63 -0
- package/frontend/src/lib/components/AttachmentUpload.svelte +110 -0
- package/frontend/src/lib/components/DatePicker.svelte +322 -0
- package/frontend/src/lib/components/DocumentCard.svelte +166 -0
- package/frontend/src/lib/components/EmptyState.svelte +49 -0
- package/frontend/src/lib/components/FolderCard.svelte +93 -0
- package/frontend/src/lib/components/ScrollToTop.svelte +72 -0
- package/frontend/src/lib/components/SearchBar.svelte +47 -0
- package/frontend/src/lib/components/SearchResult.svelte +115 -0
- package/frontend/src/lib/components/SettingsDialog.svelte +271 -0
- package/frontend/src/lib/components/ShareDialog.svelte +158 -0
- package/frontend/src/lib/components/ShareLink.svelte +98 -0
- package/frontend/src/lib/components/TagCreateDialog.svelte +284 -0
- package/frontend/src/lib/components/VersionDiff.svelte +55 -0
- package/frontend/src/lib/components/VersionHistory.svelte +96 -0
- package/frontend/src/lib/components/editor/DocumentTitle.svelte +87 -0
- package/frontend/src/lib/components/editor/EditorToolbar.svelte +1367 -0
- package/frontend/src/lib/components/editor/HiAiEditor.svelte +531 -0
- package/frontend/src/lib/components/editor/LinkDialog.svelte +134 -0
- package/frontend/src/lib/components/editor/MarkdownToggle.svelte +88 -0
- package/frontend/src/lib/components/editor/editorExtensions.ts +53 -0
- package/frontend/src/lib/components/editor/markdown.ts +38 -0
- package/frontend/src/lib/components/sidebar/FolderTree.svelte +731 -0
- package/frontend/src/lib/components/sidebar/RecentDocs.svelte +311 -0
- package/frontend/src/lib/components/sidebar/Sidebar.svelte +156 -0
- package/frontend/src/lib/components/sidebar/TagList.svelte +200 -0
- package/frontend/src/lib/components/ui/confirm-dialog/ConfirmDialog.svelte +76 -0
- package/frontend/src/lib/components/ui/confirm-dialog/index.ts +1 -0
- package/frontend/src/lib/stores/tag-store.svelte.ts +56 -0
- package/frontend/src/lib/stores/theme.svelte.ts +97 -0
- package/frontend/src/lib/svelte.d.ts +6 -0
- package/frontend/src/lib/types.ts +44 -0
- package/frontend/src/lib/utils/clipboard.ts +17 -0
- package/frontend/src/lib/utils/strip-markdown.ts +59 -0
- package/frontend/src/lib/utils.ts +33 -0
- package/frontend/src/routes/(app)/+layout.svelte +17 -0
- package/frontend/src/routes/(app)/+page.server.ts +10 -0
- package/frontend/src/routes/(app)/+page.svelte +303 -0
- package/frontend/src/routes/(app)/docs/[id]/+page.server.ts +10 -0
- package/frontend/src/routes/(app)/docs/[id]/+page.svelte +1108 -0
- package/frontend/src/routes/(app)/docs/[id]/+page.ts +24 -0
- package/frontend/src/routes/(app)/search/+page.svelte +593 -0
- package/frontend/src/routes/(app)/search/+page.ts +25 -0
- package/frontend/src/routes/+error.svelte +12 -0
- package/frontend/src/routes/+layout.svelte +18 -0
- package/frontend/src/routes/+layout.ts +2 -0
- package/frontend/src/routes/api/[...path]/+server.ts +111 -0
- package/frontend/src/routes/folders/[id]/+page.server.ts +10 -0
- package/frontend/src/routes/folders/[id]/+page.svelte +319 -0
- package/frontend/src/routes/folders/[id]/+page.ts +14 -0
- package/frontend/src/routes/login/+page.svelte +90 -0
- package/frontend/src/routes/register/+page.svelte +97 -0
- package/frontend/src/routes/s/[token]/+page.svelte +496 -0
- package/frontend/src/routes/s/[token]/+page.ts +5 -0
- package/frontend/src/routes/settings/+page.svelte +175 -0
- package/frontend/static/favicon.png +0 -0
- package/frontend/static/logo.png +0 -0
- package/frontend/svelte.config.js +15 -0
- package/frontend/tsconfig.json +15 -0
- package/frontend/vite.config.ts +25 -0
- package/init.sql +9 -0
- package/logo.png +0 -0
- package/package.json +39 -0
- package/package.public.json +39 -0
- package/packages/db/drizzle.config.ts +10 -0
- package/packages/db/package.json +30 -0
- package/packages/db/src/client.ts +9 -0
- package/packages/db/src/index.ts +2 -0
- package/packages/db/src/migrations/0000_nice_bedlam.sql +165 -0
- package/packages/db/src/migrations/0001_w2_3_test.sql +5 -0
- package/packages/db/src/migrations/0002_rename_content_json.sql +2 -0
- package/packages/db/src/migrations/meta/0000_snapshot.json +1331 -0
- package/packages/db/src/migrations/meta/0001_snapshot.json +1399 -0
- package/packages/db/src/migrations/meta/0002_snapshot.json +1399 -0
- package/packages/db/src/migrations/meta/_journal.json +27 -0
- package/packages/db/src/schema.ts +378 -0
- package/packages/db/tsconfig.json +17 -0
- package/scripts/export-openapi.ts +37 -0
- package/scripts/health-check.sh +75 -0
- package/scripts/migrate.sh +135 -0
- package/scripts/prework_backup.sh +25 -0
- package/scripts/release.sh +83 -0
- 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
|
+
×
|
|
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>
|