@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,531 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { JSONContent } from "@tiptap/core";
|
|
3
|
+
import Collaboration from "@tiptap/extension-collaboration";
|
|
4
|
+
import CollaborationCursor from "@tiptap/extension-collaboration-cursor";
|
|
5
|
+
import { onDestroy, onMount } from "svelte";
|
|
6
|
+
import { createEditor, type Editor, EditorContent } from "svelte-tiptap";
|
|
7
|
+
import type { CollaborationSession } from "$lib/collaboration";
|
|
8
|
+
import * as m from "$lib/paraglide/messages.js";
|
|
9
|
+
import EditorToolbar from "./EditorToolbar.svelte";
|
|
10
|
+
import { editorExtensions } from "./editorExtensions";
|
|
11
|
+
import { markdownToJson } from "./markdown";
|
|
12
|
+
|
|
13
|
+
export type EditorOutput = { markdown: string; json: object };
|
|
14
|
+
|
|
15
|
+
const {
|
|
16
|
+
content = "",
|
|
17
|
+
contentJson,
|
|
18
|
+
placeholder = m.doc_content_placeholder(),
|
|
19
|
+
onUpdate = (_output: EditorOutput) => {},
|
|
20
|
+
editable = true,
|
|
21
|
+
collaboration = null,
|
|
22
|
+
documentId = "",
|
|
23
|
+
}: {
|
|
24
|
+
content?: string;
|
|
25
|
+
contentJson?: object;
|
|
26
|
+
placeholder?: string;
|
|
27
|
+
onUpdate?: (output: EditorOutput) => void;
|
|
28
|
+
editable?: boolean;
|
|
29
|
+
collaboration?: CollaborationSession | null;
|
|
30
|
+
documentId?: string;
|
|
31
|
+
} = $props();
|
|
32
|
+
|
|
33
|
+
let editorStore: ReturnType<typeof createEditor> | null = null;
|
|
34
|
+
let editor = $state<Editor | null>(null);
|
|
35
|
+
let ready = $state(false);
|
|
36
|
+
let suppressNextUpdate = false;
|
|
37
|
+
let internalUpdate = false;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolve the initial editor content. Prefer the persisted ProseMirror JSON
|
|
41
|
+
* when it is present. When it is missing but the markdown source is
|
|
42
|
+
* non-empty, parse the markdown into JSON in the browser so the wysiwyg
|
|
43
|
+
* view shows formatted content rather than the raw markdown source. An
|
|
44
|
+
* older version of the project did this server-side via
|
|
45
|
+
* `backend/src/lib/markdown-to-doc.ts`, but the markdown→JSON helper here
|
|
46
|
+
* uses the same TipTap extension set as the editor itself, so the result
|
|
47
|
+
* round-trips through `setContent` without node-mismatch errors. If the
|
|
48
|
+
* parsed JSON does not match the editor schema on some edge case, the
|
|
49
|
+
* `try/catch` falls back to rendering the raw markdown string — better
|
|
50
|
+
* than showing nothing.
|
|
51
|
+
*/
|
|
52
|
+
function resolveInitialContent(): string | JSONContent {
|
|
53
|
+
if (contentJson) return contentJson as JSONContent;
|
|
54
|
+
if (content && content.trim().length > 0) {
|
|
55
|
+
try {
|
|
56
|
+
return markdownToJson(content);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
console.warn(
|
|
59
|
+
"HiAiEditor: markdownToJson failed, falling back to raw markdown",
|
|
60
|
+
err,
|
|
61
|
+
);
|
|
62
|
+
return content;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return content;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
onMount(() => {
|
|
69
|
+
const extensions = [...editorExtensions];
|
|
70
|
+
|
|
71
|
+
if (collaboration?.doc) {
|
|
72
|
+
extensions.push(
|
|
73
|
+
Collaboration.configure({
|
|
74
|
+
document: collaboration.doc,
|
|
75
|
+
}) as unknown as (typeof extensions)[number],
|
|
76
|
+
CollaborationCursor.configure({
|
|
77
|
+
provider: collaboration.provider,
|
|
78
|
+
user: {
|
|
79
|
+
name: m.editor_anonymous(),
|
|
80
|
+
color: `#${Math.floor(Math.random() * 16777215)
|
|
81
|
+
.toString(16)
|
|
82
|
+
.padStart(6, "0")}`,
|
|
83
|
+
},
|
|
84
|
+
}) as unknown as (typeof extensions)[number],
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
editorStore = createEditor({
|
|
89
|
+
extensions,
|
|
90
|
+
content: collaboration?.doc ? undefined : resolveInitialContent(),
|
|
91
|
+
editable,
|
|
92
|
+
editorProps: {
|
|
93
|
+
attributes: {
|
|
94
|
+
"aria-label": m.editor_content_label(),
|
|
95
|
+
"aria-multiline": "true",
|
|
96
|
+
role: "textbox",
|
|
97
|
+
class: "tiptap-editor",
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
onUpdate: ({ editor: ed }) => {
|
|
101
|
+
if (suppressNextUpdate) {
|
|
102
|
+
suppressNextUpdate = false;
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (!collaboration) {
|
|
106
|
+
// The Markdown extension augments the editor with a `getMarkdown()`
|
|
107
|
+
// method at onBeforeCreate time (see @tiptap/markdown Extension.ts).
|
|
108
|
+
// The `markdown` storage is `{ manager }`, not a `getMarkdown`
|
|
109
|
+
// function, so reading it as one always returned undefined and the
|
|
110
|
+
// fallback path produced plain text.
|
|
111
|
+
const getMarkdown = (ed as { getMarkdown?: () => string }).getMarkdown;
|
|
112
|
+
// Guard the markdown serialization: a node type the markdown
|
|
113
|
+
// serializer doesn't recognize (e.g. tables on some versions)
|
|
114
|
+
// must not throw and abort the save — fall back to plain text.
|
|
115
|
+
let markdown: string;
|
|
116
|
+
try {
|
|
117
|
+
markdown = getMarkdown ? getMarkdown.call(ed) : ed.getText();
|
|
118
|
+
} catch (err) {
|
|
119
|
+
console.warn("HiAiEditor: getMarkdown failed, using plain text", err);
|
|
120
|
+
markdown = ed.getText();
|
|
121
|
+
}
|
|
122
|
+
const json = ed.getJSON() as object;
|
|
123
|
+
internalUpdate = true;
|
|
124
|
+
onUpdate({ markdown, json });
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const unsubscribe = editorStore.subscribe((ed) => {
|
|
130
|
+
editor = ed;
|
|
131
|
+
if (ed && !ready) {
|
|
132
|
+
ready = true;
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
return () => unsubscribe();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
let prevContent = $state("");
|
|
140
|
+
$effect(() => {
|
|
141
|
+
if (!editor || collaboration?.doc) return;
|
|
142
|
+
// Prefer the persisted ProseMirror JSON when available — the markdown
|
|
143
|
+
// view keeps it in sync on every keystroke, so we avoid re-parsing
|
|
144
|
+
// the markdown back into JSON on every mount. When the JSON is
|
|
145
|
+
// missing but the markdown source is present, parse the markdown
|
|
146
|
+
// client-side so the wysiwyg view still shows formatted content for
|
|
147
|
+
// documents that were saved via the regular (non-TipTap) save path.
|
|
148
|
+
const hasDocJson = contentJson != null;
|
|
149
|
+
const nextSource: string | JSONContent = hasDocJson
|
|
150
|
+
? (contentJson as JSONContent)
|
|
151
|
+
: content && content.trim().length > 0
|
|
152
|
+
? markdownToJson(content)
|
|
153
|
+
: content;
|
|
154
|
+
const nextSerialized = hasDocJson ? JSON.stringify(contentJson) : content;
|
|
155
|
+
if (internalUpdate) {
|
|
156
|
+
internalUpdate = false;
|
|
157
|
+
prevContent = nextSerialized;
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (nextSerialized !== prevContent) {
|
|
161
|
+
prevContent = nextSerialized;
|
|
162
|
+
suppressNextUpdate = true;
|
|
163
|
+
editor.commands.setContent(nextSource, { emitUpdate: false });
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
onDestroy(() => {
|
|
168
|
+
editor?.destroy?.();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Intercept clicks on `.doc-link` elements rendered by the editor.
|
|
173
|
+
*
|
|
174
|
+
* TipTap's `Link` extension is configured with `openOnClick: false`, so it
|
|
175
|
+
* does not handle link clicks itself. Without an explicit handler, the
|
|
176
|
+
* browser would follow the anchor's `href` and SvelteKit's link routing
|
|
177
|
+
* would rewrite `https://example.com` to the local `/s/example.com` share
|
|
178
|
+
* URL, breaking the link.
|
|
179
|
+
*
|
|
180
|
+
* We delegate from the wrapper so the listener is installed once per
|
|
181
|
+
* mount rather than once per link node, and we only act on left-clicks
|
|
182
|
+
* without modifier keys so middle-click / cmd-click / right-click still
|
|
183
|
+
* behave natively (open in new tab, context menu, etc.).
|
|
184
|
+
*/
|
|
185
|
+
function handleWrapperClick(event: MouseEvent) {
|
|
186
|
+
if (event.defaultPrevented) return;
|
|
187
|
+
if (event.button !== 0) return;
|
|
188
|
+
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
189
|
+
|
|
190
|
+
const target = event.target as Element | null;
|
|
191
|
+
const anchor = target?.closest("a.doc-link") as HTMLAnchorElement | null;
|
|
192
|
+
if (!anchor) return;
|
|
193
|
+
|
|
194
|
+
const href = anchor.getAttribute("href");
|
|
195
|
+
if (!href) return;
|
|
196
|
+
|
|
197
|
+
// External URLs (http/https/mailto/etc.) must open in a new tab.
|
|
198
|
+
// We treat anything else as an internal route the app should handle.
|
|
199
|
+
if (/^(https?:|mailto:|tel:)/i.test(href)) {
|
|
200
|
+
event.preventDefault();
|
|
201
|
+
window.open(href, "_blank", "noopener,noreferrer");
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Internal links (e.g. `/docs/:id`): let the browser follow the href.
|
|
206
|
+
// SvelteKit's link interception will pick it up and call `goto()` for us.
|
|
207
|
+
}
|
|
208
|
+
</script>
|
|
209
|
+
|
|
210
|
+
<div class="editor-wrapper" onclick={handleWrapperClick} role="presentation">
|
|
211
|
+
{#if ready && editor}
|
|
212
|
+
<EditorToolbar {editor} {documentId} />
|
|
213
|
+
<div class="editor-content">
|
|
214
|
+
<EditorContent {editor} />
|
|
215
|
+
</div>
|
|
216
|
+
{:else}
|
|
217
|
+
<div class="editor-skeleton">
|
|
218
|
+
<div class="skeleton-toolbar">
|
|
219
|
+
{#each Array(10) as _}
|
|
220
|
+
<div class="skeleton-icon"></div>
|
|
221
|
+
{/each}
|
|
222
|
+
</div>
|
|
223
|
+
<div class="skeleton-body">
|
|
224
|
+
<div class="skeleton-bar" style="width: 60%"></div>
|
|
225
|
+
<div class="skeleton-bar" style="width: 90%"></div>
|
|
226
|
+
<div class="skeleton-bar" style="width: 75%"></div>
|
|
227
|
+
<div class="skeleton-bar" style="width: 85%"></div>
|
|
228
|
+
<div class="skeleton-bar" style="width: 40%"></div>
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
{/if}
|
|
232
|
+
</div>
|
|
233
|
+
|
|
234
|
+
<style>
|
|
235
|
+
.editor-wrapper {
|
|
236
|
+
display: flex;
|
|
237
|
+
flex-direction: column;
|
|
238
|
+
flex: 1;
|
|
239
|
+
min-height: 0;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
.editor-content {
|
|
243
|
+
flex: 1;
|
|
244
|
+
padding: 24px;
|
|
245
|
+
overflow-y: auto;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
.editor-content :global(.tiptap) {
|
|
249
|
+
outline: none;
|
|
250
|
+
min-height: 300px;
|
|
251
|
+
font-size: 16px;
|
|
252
|
+
line-height: 1.7;
|
|
253
|
+
color: var(--foreground);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
.editor-content :global(.tiptap:focus-visible) {
|
|
257
|
+
outline: none;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.editor-content :global(.tiptap p.is-editor-empty:first-child::before) {
|
|
261
|
+
content: attr(data-placeholder);
|
|
262
|
+
float: left;
|
|
263
|
+
color: var(--muted-foreground);
|
|
264
|
+
pointer-events: none;
|
|
265
|
+
height: 0;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
.editor-content :global(.tiptap h1) {
|
|
269
|
+
font-size: 2rem;
|
|
270
|
+
font-weight: 800;
|
|
271
|
+
margin: 1.5rem 0 0.75rem;
|
|
272
|
+
letter-spacing: -0.025em;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
.editor-content :global(.tiptap h2) {
|
|
276
|
+
font-size: 1.5rem;
|
|
277
|
+
font-weight: 700;
|
|
278
|
+
margin: 1.25rem 0 0.5rem;
|
|
279
|
+
letter-spacing: -0.02em;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
.editor-content :global(.tiptap h3) {
|
|
283
|
+
font-size: 1.25rem;
|
|
284
|
+
font-weight: 600;
|
|
285
|
+
margin: 1rem 0 0.5rem;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
.editor-content :global(.tiptap p) {
|
|
289
|
+
margin: 0.5rem 0;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
.editor-content :global(.tiptap ul) {
|
|
293
|
+
padding-left: 1.5rem;
|
|
294
|
+
margin: 0.5rem 0;
|
|
295
|
+
list-style-type: disc;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
.editor-content :global(.tiptap ol) {
|
|
299
|
+
padding-left: 1.5rem;
|
|
300
|
+
margin: 0.5rem 0;
|
|
301
|
+
list-style-type: decimal;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
.editor-content :global(.tiptap li) {
|
|
305
|
+
margin: 0.25rem 0;
|
|
306
|
+
display: list-item;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/* Task lists — checkbox list. Override the default disc bullet and lay
|
|
310
|
+
each item out as [checkbox] [content]. */
|
|
311
|
+
.editor-content :global(.tiptap ul[data-type="taskList"]) {
|
|
312
|
+
list-style: none;
|
|
313
|
+
padding-left: 0.25rem;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
.editor-content :global(.tiptap ul[data-type="taskList"] li) {
|
|
317
|
+
display: flex;
|
|
318
|
+
align-items: flex-start;
|
|
319
|
+
gap: 0.5rem;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
.editor-content :global(.tiptap ul[data-type="taskList"] li > label) {
|
|
323
|
+
display: flex;
|
|
324
|
+
align-items: center;
|
|
325
|
+
justify-content: center;
|
|
326
|
+
user-select: none;
|
|
327
|
+
height: 1.7em;
|
|
328
|
+
margin: 0;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
.editor-content :global(.tiptap ul[data-type="taskList"] li > div) {
|
|
332
|
+
flex: 1 1 auto;
|
|
333
|
+
min-width: 0;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
.editor-content :global(.tiptap ul[data-type="taskList"] li > div > p) {
|
|
337
|
+
margin: 0;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
.editor-content :global(.tiptap ul[data-type="taskList"] input[type="checkbox"]) {
|
|
341
|
+
accent-color: var(--primary);
|
|
342
|
+
cursor: pointer;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
.editor-content :global(.tiptap blockquote) {
|
|
346
|
+
border-left: 3px solid var(--border);
|
|
347
|
+
padding-left: 1rem;
|
|
348
|
+
margin: 0.75rem 0;
|
|
349
|
+
color: var(--muted-foreground);
|
|
350
|
+
font-style: italic;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
.editor-content :global(.tiptap code) {
|
|
354
|
+
background: var(--muted);
|
|
355
|
+
padding: 0.125rem 0.375rem;
|
|
356
|
+
border-radius: 4px;
|
|
357
|
+
font-size: 0.875rem;
|
|
358
|
+
font-family: "Fira Code", "Consolas", monospace;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
.editor-content :global(.tiptap pre) {
|
|
362
|
+
background: var(--muted);
|
|
363
|
+
color: var(--foreground);
|
|
364
|
+
border: 1px solid var(--border);
|
|
365
|
+
padding: 1rem;
|
|
366
|
+
border-radius: 8px;
|
|
367
|
+
font-family: "Fira Code", "Consolas", monospace;
|
|
368
|
+
font-size: 0.875rem;
|
|
369
|
+
line-height: 1.6;
|
|
370
|
+
overflow-x: auto;
|
|
371
|
+
margin: 0.75rem 0;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
.editor-content :global(.tiptap pre code) {
|
|
375
|
+
background: transparent;
|
|
376
|
+
padding: 0;
|
|
377
|
+
border-radius: 0;
|
|
378
|
+
font-size: inherit;
|
|
379
|
+
color: inherit;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/* lowlight syntax highlighting (theme-aware via CSS variables) */
|
|
383
|
+
.editor-content :global(.tiptap pre .hljs-keyword) {
|
|
384
|
+
color: var(--hljs-keyword);
|
|
385
|
+
}
|
|
386
|
+
.editor-content :global(.tiptap pre .hljs-string) {
|
|
387
|
+
color: var(--hljs-string);
|
|
388
|
+
}
|
|
389
|
+
.editor-content :global(.tiptap pre .hljs-number) {
|
|
390
|
+
color: var(--hljs-number);
|
|
391
|
+
}
|
|
392
|
+
.editor-content :global(.tiptap pre .hljs-function) {
|
|
393
|
+
color: var(--hljs-function);
|
|
394
|
+
}
|
|
395
|
+
.editor-content :global(.tiptap pre .hljs-title) {
|
|
396
|
+
color: var(--hljs-title);
|
|
397
|
+
}
|
|
398
|
+
.editor-content :global(.tiptap pre .hljs-comment) {
|
|
399
|
+
color: var(--hljs-comment);
|
|
400
|
+
font-style: italic;
|
|
401
|
+
}
|
|
402
|
+
.editor-content :global(.tiptap pre .hljs-built_in) {
|
|
403
|
+
color: var(--hljs-built_in);
|
|
404
|
+
}
|
|
405
|
+
.editor-content :global(.tiptap pre .hljs-type) {
|
|
406
|
+
color: var(--hljs-type);
|
|
407
|
+
}
|
|
408
|
+
.editor-content :global(.tiptap pre .hljs-attr) {
|
|
409
|
+
color: var(--hljs-attr);
|
|
410
|
+
}
|
|
411
|
+
.editor-content :global(.tiptap pre .hljs-variable) {
|
|
412
|
+
color: var(--hljs-variable);
|
|
413
|
+
}
|
|
414
|
+
.editor-content :global(.tiptap pre .hljs-literal) {
|
|
415
|
+
color: var(--hljs-literal);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
.editor-content :global(.tiptap hr) {
|
|
419
|
+
border: none;
|
|
420
|
+
border-top: 2px solid var(--border);
|
|
421
|
+
margin: 1.5rem 0;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/* Tables */
|
|
425
|
+
.editor-content :global(.tiptap table) {
|
|
426
|
+
border-collapse: collapse;
|
|
427
|
+
width: 100%;
|
|
428
|
+
margin: 0.75rem 0;
|
|
429
|
+
table-layout: fixed;
|
|
430
|
+
overflow: hidden;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
.editor-content :global(.tiptap th),
|
|
434
|
+
.editor-content :global(.tiptap td) {
|
|
435
|
+
border: 1px solid var(--border);
|
|
436
|
+
padding: 0.4rem 0.6rem;
|
|
437
|
+
vertical-align: top;
|
|
438
|
+
text-align: left;
|
|
439
|
+
min-width: 3rem;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
.editor-content :global(.tiptap th) {
|
|
443
|
+
background: var(--muted);
|
|
444
|
+
font-weight: 600;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/* Active cell selection highlight (TipTap CellSelection) */
|
|
448
|
+
.editor-content :global(.tiptap .selectedCell::after) {
|
|
449
|
+
content: "";
|
|
450
|
+
position: absolute;
|
|
451
|
+
inset: 0;
|
|
452
|
+
background: color-mix(in srgb, var(--primary) 16%, transparent);
|
|
453
|
+
pointer-events: none;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
.editor-content :global(.tiptap td),
|
|
457
|
+
.editor-content :global(.tiptap th) {
|
|
458
|
+
position: relative;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/* Column resize handle */
|
|
462
|
+
.editor-content :global(.tiptap .column-resize-handle) {
|
|
463
|
+
position: absolute;
|
|
464
|
+
right: -2px;
|
|
465
|
+
top: 0;
|
|
466
|
+
bottom: 0;
|
|
467
|
+
width: 4px;
|
|
468
|
+
background: var(--primary);
|
|
469
|
+
cursor: col-resize;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
.editor-content :global(.tiptap .doc-link) {
|
|
473
|
+
color: var(--primary);
|
|
474
|
+
text-decoration: underline;
|
|
475
|
+
cursor: pointer;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
.editor-content :global(.tiptap mark) {
|
|
479
|
+
/* Inline `style="background-color: ..."` set by TipTap (multicolor mode)
|
|
480
|
+
wins via specificity; this is the fallback for marks without a color. */
|
|
481
|
+
background-color: var(--highlight-default, #fde68a);
|
|
482
|
+
border-radius: 2px;
|
|
483
|
+
padding: 0 2px;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/* Skeleton loading state */
|
|
487
|
+
.editor-skeleton {
|
|
488
|
+
display: flex;
|
|
489
|
+
flex-direction: column;
|
|
490
|
+
flex: 1;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
.skeleton-toolbar {
|
|
494
|
+
display: flex;
|
|
495
|
+
gap: 6px;
|
|
496
|
+
padding: 8px 12px;
|
|
497
|
+
border-bottom: 1px solid var(--border);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
.skeleton-icon {
|
|
501
|
+
width: 32px;
|
|
502
|
+
height: 32px;
|
|
503
|
+
border-radius: 6px;
|
|
504
|
+
background: var(--muted);
|
|
505
|
+
animation: pulse 1.5s ease-in-out infinite;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
.skeleton-body {
|
|
509
|
+
display: flex;
|
|
510
|
+
flex-direction: column;
|
|
511
|
+
gap: 12px;
|
|
512
|
+
padding: 24px;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
.skeleton-bar {
|
|
516
|
+
height: 16px;
|
|
517
|
+
border-radius: 4px;
|
|
518
|
+
background: var(--muted);
|
|
519
|
+
animation: pulse 1.5s ease-in-out infinite;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
@keyframes pulse {
|
|
523
|
+
0%,
|
|
524
|
+
100% {
|
|
525
|
+
opacity: 1;
|
|
526
|
+
}
|
|
527
|
+
50% {
|
|
528
|
+
opacity: 0.4;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
</style>
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
<!-- LinkDialog.svelte — Modal dialog to set/edit a link on the active Tiptap selection -->
|
|
2
|
+
<script lang="ts">
|
|
3
|
+
import { Button } from "@hiai-gg/hiai-ui/components/ui/button";
|
|
4
|
+
import {
|
|
5
|
+
Dialog,
|
|
6
|
+
DialogFooter,
|
|
7
|
+
DialogHeader,
|
|
8
|
+
DialogTitle,
|
|
9
|
+
} from "@hiai-gg/hiai-ui/components/ui/dialog";
|
|
10
|
+
import { Input } from "@hiai-gg/hiai-ui/components/ui/input";
|
|
11
|
+
import type { Editor } from "@tiptap/core";
|
|
12
|
+
import * as m from "$lib/paraglide/messages.js";
|
|
13
|
+
|
|
14
|
+
let {
|
|
15
|
+
open = $bindable(false),
|
|
16
|
+
editor = null,
|
|
17
|
+
}: {
|
|
18
|
+
open?: boolean;
|
|
19
|
+
editor?: Editor | null;
|
|
20
|
+
} = $props();
|
|
21
|
+
|
|
22
|
+
let url = $state("");
|
|
23
|
+
let inputEl = $state<HTMLInputElement | null>(null);
|
|
24
|
+
|
|
25
|
+
// Reset the input field with the existing link href (if any) whenever
|
|
26
|
+
// the dialog opens. Using $effect to react to `open` changes.
|
|
27
|
+
$effect(() => {
|
|
28
|
+
if (open && editor) {
|
|
29
|
+
const previousUrl = editor.getAttributes("link").href ?? "";
|
|
30
|
+
url = previousUrl;
|
|
31
|
+
// Defer focus to next tick so the input is mounted.
|
|
32
|
+
queueMicrotask(() => inputEl?.focus());
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
function close() {
|
|
37
|
+
open = false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function handleCancel() {
|
|
41
|
+
close();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function handleApply() {
|
|
45
|
+
if (!editor) {
|
|
46
|
+
close();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const trimmed = url.trim();
|
|
50
|
+
if (trimmed === "") {
|
|
51
|
+
editor.chain().focus().extendMarkRange("link").unsetLink().run();
|
|
52
|
+
} else {
|
|
53
|
+
// Bare domains like "google.com" would otherwise be treated as a
|
|
54
|
+
// relative path and routed internally (e.g. /s/google.com). Prepend
|
|
55
|
+
// https:// unless the value already has a scheme, anchor, or path.
|
|
56
|
+
const normalized = /^(https?:\/\/|mailto:|tel:|\/|#)/i.test(trimmed)
|
|
57
|
+
? trimmed
|
|
58
|
+
: `https://${trimmed}`;
|
|
59
|
+
|
|
60
|
+
const { from, to } = editor.state.selection;
|
|
61
|
+
if (from === to) {
|
|
62
|
+
// No text selected: `setLink` would add a mark with nothing to
|
|
63
|
+
// apply it to, so nothing visible would be saved. Insert the URL
|
|
64
|
+
// itself as the link text instead.
|
|
65
|
+
editor
|
|
66
|
+
.chain()
|
|
67
|
+
.focus()
|
|
68
|
+
.insertContent({
|
|
69
|
+
type: "text",
|
|
70
|
+
text: normalized,
|
|
71
|
+
marks: [{ type: "link", attrs: { href: normalized } }],
|
|
72
|
+
})
|
|
73
|
+
.run();
|
|
74
|
+
} else {
|
|
75
|
+
editor
|
|
76
|
+
.chain()
|
|
77
|
+
.focus()
|
|
78
|
+
.extendMarkRange("link")
|
|
79
|
+
.setLink({ href: normalized })
|
|
80
|
+
.run();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
close();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
87
|
+
if (e.key === "Enter") {
|
|
88
|
+
e.preventDefault();
|
|
89
|
+
handleApply();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
</script>
|
|
93
|
+
|
|
94
|
+
<Dialog bind:open>
|
|
95
|
+
<DialogHeader>
|
|
96
|
+
<DialogTitle>{m.editor_toolbar_link()}</DialogTitle>
|
|
97
|
+
</DialogHeader>
|
|
98
|
+
<div class="link-dialog-body">
|
|
99
|
+
<label for="link-url" class="link-dialog-label">
|
|
100
|
+
{m.editor_enter_url()}
|
|
101
|
+
</label>
|
|
102
|
+
<Input
|
|
103
|
+
id="link-url"
|
|
104
|
+
bind:ref={inputEl}
|
|
105
|
+
bind:value={url}
|
|
106
|
+
type="url"
|
|
107
|
+
placeholder="https://example.com"
|
|
108
|
+
onkeydown={handleKeydown}
|
|
109
|
+
/>
|
|
110
|
+
</div>
|
|
111
|
+
<DialogFooter>
|
|
112
|
+
<Button variant="outline" type="button" onclick={handleCancel}>
|
|
113
|
+
{m.action_cancel()}
|
|
114
|
+
</Button>
|
|
115
|
+
<Button type="button" onclick={handleApply}>
|
|
116
|
+
{m.action_save()}
|
|
117
|
+
</Button>
|
|
118
|
+
</DialogFooter>
|
|
119
|
+
</Dialog>
|
|
120
|
+
|
|
121
|
+
<style>
|
|
122
|
+
.link-dialog-body {
|
|
123
|
+
display: flex;
|
|
124
|
+
flex-direction: column;
|
|
125
|
+
gap: 0.5rem;
|
|
126
|
+
padding: 0 0 1rem 0;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.link-dialog-label {
|
|
130
|
+
font-size: 0.875rem;
|
|
131
|
+
font-weight: 500;
|
|
132
|
+
color: var(--foreground);
|
|
133
|
+
}
|
|
134
|
+
</style>
|