wiki-viewer 1.0.0
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/.next/standalone/.github/workflows/publish.yml +37 -0
- package/.next/standalone/DESIGN.md +521 -0
- package/.next/standalone/README.md +283 -0
- package/.next/standalone/bin/wiki-viewer.js +158 -0
- package/.next/standalone/next.config.ts +11 -0
- package/.next/standalone/package.json +120 -0
- package/.next/standalone/pnpm-lock.yaml +5654 -0
- package/.next/standalone/pnpm-workspace.yaml +2 -0
- package/.next/standalone/postcss.config.mjs +2 -0
- package/.next/standalone/public/logo.svg +11 -0
- package/.next/standalone/public/public/logo.svg +11 -0
- package/.next/standalone/server.js +44 -0
- package/.next/standalone/src/app/api/app-proxy/[...path]/route.ts +247 -0
- package/.next/standalone/src/app/api/assets/[...path]/route.ts +70 -0
- package/.next/standalone/src/app/api/system/browse/route.ts +78 -0
- package/.next/standalone/src/app/api/system/clear-root/route.ts +9 -0
- package/.next/standalone/src/app/api/system/config/route.ts +10 -0
- package/.next/standalone/src/app/api/system/pins/route.ts +31 -0
- package/.next/standalone/src/app/api/system/reveal/route.ts +28 -0
- package/.next/standalone/src/app/api/system/root-status/route.ts +9 -0
- package/.next/standalone/src/app/api/system/set-root/route.ts +23 -0
- package/.next/standalone/src/app/api/upload/[...path]/route.ts +80 -0
- package/.next/standalone/src/app/api/wiki/app/route.ts +41 -0
- package/.next/standalone/src/app/api/wiki/content/route.ts +60 -0
- package/.next/standalone/src/app/api/wiki/file/route.ts +52 -0
- package/.next/standalone/src/app/api/wiki/folder/route.ts +26 -0
- package/.next/standalone/src/app/api/wiki/move/route.ts +45 -0
- package/.next/standalone/src/app/api/wiki/page/route.ts +84 -0
- package/.next/standalone/src/app/api/wiki/route.ts +98 -0
- package/.next/standalone/src/app/api/wiki/slugs/route.ts +50 -0
- package/.next/standalone/src/app/api/wiki/upload/route.ts +87 -0
- package/.next/standalone/src/app/api/wiki/watch/route.ts +86 -0
- package/.next/standalone/src/app/globals.css +716 -0
- package/.next/standalone/src/app/icon.svg +11 -0
- package/.next/standalone/src/app/layout.tsx +35 -0
- package/.next/standalone/src/app/page.tsx +1509 -0
- package/.next/standalone/src/components/confirm-dialog.tsx +56 -0
- package/.next/standalone/src/components/dir-picker.tsx +393 -0
- package/.next/standalone/src/components/editor/bubble-menu.tsx +286 -0
- package/.next/standalone/src/components/editor/callout-extension.ts +36 -0
- package/.next/standalone/src/components/editor/csv-viewer.tsx +335 -0
- package/.next/standalone/src/components/editor/editor-toolbar.tsx +564 -0
- package/.next/standalone/src/components/editor/editor.tsx +633 -0
- package/.next/standalone/src/components/editor/extensions/drag-handle.ts +325 -0
- package/.next/standalone/src/components/editor/extensions/heading-anchors.ts +55 -0
- package/.next/standalone/src/components/editor/extensions/resizable-image.tsx +159 -0
- package/.next/standalone/src/components/editor/extensions.ts +148 -0
- package/.next/standalone/src/components/editor/file-fallback-viewer.tsx +74 -0
- package/.next/standalone/src/components/editor/folder-index.tsx +235 -0
- package/.next/standalone/src/components/editor/google-doc-viewer.tsx +73 -0
- package/.next/standalone/src/components/editor/image-viewer.tsx +56 -0
- package/.next/standalone/src/components/editor/link-popover.tsx +111 -0
- package/.next/standalone/src/components/editor/media-popover.tsx +211 -0
- package/.next/standalone/src/components/editor/media-viewer.tsx +69 -0
- package/.next/standalone/src/components/editor/mermaid-viewer.tsx +285 -0
- package/.next/standalone/src/components/editor/node-app-viewer.tsx +269 -0
- package/.next/standalone/src/components/editor/notebook-viewer.tsx +370 -0
- package/.next/standalone/src/components/editor/office/docx-viewer.tsx +92 -0
- package/.next/standalone/src/components/editor/office/office-chrome.tsx +82 -0
- package/.next/standalone/src/components/editor/office/pptx-viewer.tsx +103 -0
- package/.next/standalone/src/components/editor/office/xlsx-viewer.tsx +116 -0
- package/.next/standalone/src/components/editor/pdf-viewer.tsx +31 -0
- package/.next/standalone/src/components/editor/slash-commands.tsx +454 -0
- package/.next/standalone/src/components/editor/source-viewer.tsx +212 -0
- package/.next/standalone/src/components/editor/table-menu.tsx +232 -0
- package/.next/standalone/src/components/editor/website-viewer.tsx +74 -0
- package/.next/standalone/src/components/editor/wiki-link-create-dialog.tsx +188 -0
- package/.next/standalone/src/components/editor/wiki-link-decorator.ts +79 -0
- package/.next/standalone/src/components/editor/wiki-link-extension.ts +120 -0
- package/.next/standalone/src/components/editor/wiki-link-picker.tsx +250 -0
- package/.next/standalone/src/components/layout/viewer-toolbar.tsx +54 -0
- package/.next/standalone/src/components/theme-provider.tsx +16 -0
- package/.next/standalone/src/components/theme-toggle.tsx +24 -0
- package/.next/standalone/src/components/ui/badge.tsx +35 -0
- package/.next/standalone/src/components/ui/button.tsx +56 -0
- package/.next/standalone/src/components/ui/card.tsx +63 -0
- package/.next/standalone/src/components/ui/collapsible.tsx +9 -0
- package/.next/standalone/src/components/ui/command.tsx +150 -0
- package/.next/standalone/src/components/ui/context-menu.tsx +191 -0
- package/.next/standalone/src/components/ui/dialog.tsx +119 -0
- package/.next/standalone/src/components/ui/dropdown-menu.tsx +194 -0
- package/.next/standalone/src/components/ui/input.tsx +22 -0
- package/.next/standalone/src/components/ui/label.tsx +25 -0
- package/.next/standalone/src/components/ui/popover.tsx +33 -0
- package/.next/standalone/src/components/ui/scroll-area.tsx +47 -0
- package/.next/standalone/src/components/ui/select.tsx +156 -0
- package/.next/standalone/src/components/ui/separator.tsx +30 -0
- package/.next/standalone/src/components/ui/sheet.tsx +140 -0
- package/.next/standalone/src/components/ui/skeleton.tsx +15 -0
- package/.next/standalone/src/components/ui/switch.tsx +28 -0
- package/.next/standalone/src/components/ui/tabs.tsx +54 -0
- package/.next/standalone/src/components/ui/textarea.tsx +21 -0
- package/.next/standalone/src/components/ui/tip.tsx +23 -0
- package/.next/standalone/src/components/ui/toggle-group.tsx +41 -0
- package/.next/standalone/src/components/ui/tooltip.tsx +27 -0
- package/.next/standalone/src/components/wiki/frontmatter-header.tsx +164 -0
- package/.next/standalone/src/lib/app-runner.ts +231 -0
- package/.next/standalone/src/lib/cabinets/tree.ts +75 -0
- package/.next/standalone/src/lib/config.ts +32 -0
- package/.next/standalone/src/lib/embeds/detect.ts +142 -0
- package/.next/standalone/src/lib/google/detect.ts +107 -0
- package/.next/standalone/src/lib/markdown/parse-frontmatter.ts +84 -0
- package/.next/standalone/src/lib/markdown/remark-wikilinks.ts +100 -0
- package/.next/standalone/src/lib/markdown/to-html.ts +168 -0
- package/.next/standalone/src/lib/markdown/to-markdown.ts +238 -0
- package/.next/standalone/src/lib/root-dir.ts +35 -0
- package/.next/standalone/src/lib/toast.ts +9 -0
- package/.next/standalone/src/lib/utils.ts +6 -0
- package/.next/standalone/src/lib/wiki-helpers.ts +36 -0
- package/.next/standalone/src/stores/ai-panel-store.ts +23 -0
- package/.next/standalone/src/stores/editor-store.ts +244 -0
- package/.next/standalone/src/stores/tree-store.ts +52 -0
- package/.next/standalone/src/stores/wiki-slugs-store.ts +74 -0
- package/.next/standalone/src/types/index.ts +47 -0
- package/.next/standalone/tailwind.config.ts +201 -0
- package/.next/standalone/tsconfig.json +41 -0
- package/README.md +283 -0
- package/bin/wiki-viewer.js +158 -0
- package/package.json +120 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
cellAround,
|
|
5
|
+
isInTable,
|
|
6
|
+
moveTableColumn,
|
|
7
|
+
moveTableRow,
|
|
8
|
+
selectedRect,
|
|
9
|
+
} from "@tiptap/pm/tables";
|
|
10
|
+
import type { Editor } from "@tiptap/react";
|
|
11
|
+
import { BubbleMenu } from "@tiptap/react/menus";
|
|
12
|
+
import {
|
|
13
|
+
ArrowDown,
|
|
14
|
+
ArrowLeft,
|
|
15
|
+
ArrowRight,
|
|
16
|
+
ArrowUp,
|
|
17
|
+
Columns3,
|
|
18
|
+
PanelLeft,
|
|
19
|
+
PanelTop,
|
|
20
|
+
Rows3,
|
|
21
|
+
Table,
|
|
22
|
+
Trash2,
|
|
23
|
+
} from "lucide-react";
|
|
24
|
+
import { cn } from "@/lib/utils";
|
|
25
|
+
|
|
26
|
+
interface TableMenuProps {
|
|
27
|
+
editor: Editor | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface TableButtonProps {
|
|
31
|
+
label: string;
|
|
32
|
+
icon: React.ComponentType<{ className?: string }>;
|
|
33
|
+
disabled?: boolean;
|
|
34
|
+
active?: boolean;
|
|
35
|
+
danger?: boolean;
|
|
36
|
+
onAction: () => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function TableButton({
|
|
40
|
+
label,
|
|
41
|
+
icon: Icon,
|
|
42
|
+
disabled,
|
|
43
|
+
active,
|
|
44
|
+
danger,
|
|
45
|
+
onAction,
|
|
46
|
+
}: TableButtonProps) {
|
|
47
|
+
return (
|
|
48
|
+
<button
|
|
49
|
+
type="button"
|
|
50
|
+
aria-label={label}
|
|
51
|
+
title={label}
|
|
52
|
+
disabled={disabled}
|
|
53
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
54
|
+
onClick={(event) => {
|
|
55
|
+
event.preventDefault();
|
|
56
|
+
onAction();
|
|
57
|
+
}}
|
|
58
|
+
className={cn(
|
|
59
|
+
"h-7 w-7 inline-flex items-center justify-center rounded text-foreground/80 hover:bg-accent transition-colors disabled:pointer-events-none disabled:opacity-35",
|
|
60
|
+
active && "bg-accent text-foreground",
|
|
61
|
+
danger && "text-destructive hover:bg-destructive/10",
|
|
62
|
+
)}
|
|
63
|
+
>
|
|
64
|
+
<Icon className="h-3.5 w-3.5" />
|
|
65
|
+
</button>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function Separator() {
|
|
70
|
+
return <div className="mx-1 h-5 w-px bg-border" />;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function TableMenu({ editor }: TableMenuProps) {
|
|
74
|
+
if (!editor) return null;
|
|
75
|
+
|
|
76
|
+
const getRect = () => {
|
|
77
|
+
if (!isInTable(editor.state)) return null;
|
|
78
|
+
try {
|
|
79
|
+
return selectedRect(editor.state);
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const run = (action: () => boolean) => {
|
|
86
|
+
const ok = action();
|
|
87
|
+
if (ok) {
|
|
88
|
+
editor.commands.focus();
|
|
89
|
+
editor.commands.fixTables();
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const moveRow = (direction: -1 | 1) => {
|
|
94
|
+
const rect = getRect();
|
|
95
|
+
if (!rect) return;
|
|
96
|
+
const from = rect.top;
|
|
97
|
+
const to = from + direction;
|
|
98
|
+
if (to < 0 || to >= rect.map.height) return;
|
|
99
|
+
moveTableRow({ from, to })(editor.state, editor.view.dispatch);
|
|
100
|
+
editor.commands.focus();
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const moveColumn = (direction: -1 | 1) => {
|
|
104
|
+
const rect = getRect();
|
|
105
|
+
if (!rect) return;
|
|
106
|
+
const from = rect.left;
|
|
107
|
+
const to = from + direction;
|
|
108
|
+
if (to < 0 || to >= rect.map.width) return;
|
|
109
|
+
moveTableColumn({ from, to })(editor.state, editor.view.dispatch);
|
|
110
|
+
editor.commands.focus();
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const selectCellText = () => {
|
|
114
|
+
const $cell = cellAround(editor.state.selection.$from);
|
|
115
|
+
const cell = $cell?.nodeAfter;
|
|
116
|
+
if (!$cell || !cell) return;
|
|
117
|
+
editor
|
|
118
|
+
.chain()
|
|
119
|
+
.focus()
|
|
120
|
+
.setTextSelection({
|
|
121
|
+
from: $cell.pos + 1,
|
|
122
|
+
to: $cell.pos + cell.nodeSize - 1,
|
|
123
|
+
})
|
|
124
|
+
.run();
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const rect = getRect();
|
|
128
|
+
const canMoveRowUp = !!rect && rect.top > 0;
|
|
129
|
+
const canMoveRowDown = !!rect && rect.top < rect.map.height - 1;
|
|
130
|
+
const canMoveColumnLeft = !!rect && rect.left > 0;
|
|
131
|
+
const canMoveColumnRight = !!rect && rect.left < rect.map.width - 1;
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<BubbleMenu
|
|
135
|
+
editor={editor}
|
|
136
|
+
pluginKey="tableMenu"
|
|
137
|
+
options={{ placement: "top", offset: 10 }}
|
|
138
|
+
shouldShow={({ editor: activeEditor }) => activeEditor.isActive("table")}
|
|
139
|
+
className="flex items-center gap-0.5 rounded-md border border-border bg-popover px-1 py-1 shadow-lg"
|
|
140
|
+
>
|
|
141
|
+
<TableButton
|
|
142
|
+
label="Select cell text"
|
|
143
|
+
icon={Table}
|
|
144
|
+
onAction={selectCellText}
|
|
145
|
+
/>
|
|
146
|
+
<Separator />
|
|
147
|
+
<TableButton
|
|
148
|
+
label="Add row above"
|
|
149
|
+
icon={Rows3}
|
|
150
|
+
onAction={() => run(() => editor.chain().focus().addRowBefore().run())}
|
|
151
|
+
/>
|
|
152
|
+
<TableButton
|
|
153
|
+
label="Add row below"
|
|
154
|
+
icon={ArrowDown}
|
|
155
|
+
onAction={() => run(() => editor.chain().focus().addRowAfter().run())}
|
|
156
|
+
/>
|
|
157
|
+
<TableButton
|
|
158
|
+
label="Move row up"
|
|
159
|
+
icon={ArrowUp}
|
|
160
|
+
disabled={!canMoveRowUp}
|
|
161
|
+
onAction={() => moveRow(-1)}
|
|
162
|
+
/>
|
|
163
|
+
<TableButton
|
|
164
|
+
label="Move row down"
|
|
165
|
+
icon={ArrowDown}
|
|
166
|
+
disabled={!canMoveRowDown}
|
|
167
|
+
onAction={() => moveRow(1)}
|
|
168
|
+
/>
|
|
169
|
+
<TableButton
|
|
170
|
+
label="Delete row"
|
|
171
|
+
icon={Trash2}
|
|
172
|
+
danger
|
|
173
|
+
onAction={() => run(() => editor.chain().focus().deleteRow().run())}
|
|
174
|
+
/>
|
|
175
|
+
<Separator />
|
|
176
|
+
<TableButton
|
|
177
|
+
label="Add column before"
|
|
178
|
+
icon={Columns3}
|
|
179
|
+
onAction={() =>
|
|
180
|
+
run(() => editor.chain().focus().addColumnBefore().run())
|
|
181
|
+
}
|
|
182
|
+
/>
|
|
183
|
+
<TableButton
|
|
184
|
+
label="Add column after"
|
|
185
|
+
icon={ArrowRight}
|
|
186
|
+
onAction={() =>
|
|
187
|
+
run(() => editor.chain().focus().addColumnAfter().run())
|
|
188
|
+
}
|
|
189
|
+
/>
|
|
190
|
+
<TableButton
|
|
191
|
+
label="Move column left"
|
|
192
|
+
icon={ArrowLeft}
|
|
193
|
+
disabled={!canMoveColumnLeft}
|
|
194
|
+
onAction={() => moveColumn(-1)}
|
|
195
|
+
/>
|
|
196
|
+
<TableButton
|
|
197
|
+
label="Move column right"
|
|
198
|
+
icon={ArrowRight}
|
|
199
|
+
disabled={!canMoveColumnRight}
|
|
200
|
+
onAction={() => moveColumn(1)}
|
|
201
|
+
/>
|
|
202
|
+
<TableButton
|
|
203
|
+
label="Delete column"
|
|
204
|
+
icon={Trash2}
|
|
205
|
+
danger
|
|
206
|
+
onAction={() => run(() => editor.chain().focus().deleteColumn().run())}
|
|
207
|
+
/>
|
|
208
|
+
<Separator />
|
|
209
|
+
<TableButton
|
|
210
|
+
label="Toggle header row"
|
|
211
|
+
icon={PanelTop}
|
|
212
|
+
onAction={() =>
|
|
213
|
+
run(() => editor.chain().focus().toggleHeaderRow().run())
|
|
214
|
+
}
|
|
215
|
+
/>
|
|
216
|
+
<TableButton
|
|
217
|
+
label="Toggle header column"
|
|
218
|
+
icon={PanelLeft}
|
|
219
|
+
onAction={() =>
|
|
220
|
+
run(() => editor.chain().focus().toggleHeaderColumn().run())
|
|
221
|
+
}
|
|
222
|
+
/>
|
|
223
|
+
<Separator />
|
|
224
|
+
<TableButton
|
|
225
|
+
label="Delete table"
|
|
226
|
+
icon={Trash2}
|
|
227
|
+
danger
|
|
228
|
+
onAction={() => editor.chain().focus().deleteTable().run()}
|
|
229
|
+
/>
|
|
230
|
+
</BubbleMenu>
|
|
231
|
+
);
|
|
232
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ArrowLeft, ExternalLink } from "lucide-react";
|
|
4
|
+
import { ViewerToolbar } from "@/components/layout/viewer-toolbar";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
|
|
7
|
+
interface WebsiteViewerProps {
|
|
8
|
+
path: string;
|
|
9
|
+
title: string;
|
|
10
|
+
/** Override iframe src. Defaults to `/api/assets/{path}/index.html`. */
|
|
11
|
+
src?: string;
|
|
12
|
+
fullscreen?: boolean;
|
|
13
|
+
onExit?: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function WebsiteViewer({
|
|
17
|
+
path,
|
|
18
|
+
title,
|
|
19
|
+
src,
|
|
20
|
+
fullscreen,
|
|
21
|
+
onExit,
|
|
22
|
+
}: WebsiteViewerProps) {
|
|
23
|
+
const iframeSrc = src ?? `/api/assets/${path}/index.html`;
|
|
24
|
+
const exitButton =
|
|
25
|
+
fullscreen && onExit ? (
|
|
26
|
+
<Button
|
|
27
|
+
variant="ghost"
|
|
28
|
+
size="sm"
|
|
29
|
+
className="h-7 gap-1.5 text-xs"
|
|
30
|
+
onClick={onExit}
|
|
31
|
+
title="Exit app"
|
|
32
|
+
>
|
|
33
|
+
<ArrowLeft className="h-3.5 w-3.5" />
|
|
34
|
+
Exit app
|
|
35
|
+
</Button>
|
|
36
|
+
) : null;
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div className="flex-1 flex flex-col overflow-hidden">
|
|
40
|
+
<ViewerToolbar
|
|
41
|
+
path={path}
|
|
42
|
+
badge={fullscreen ? "App" : undefined}
|
|
43
|
+
showBreadcrumb={!fullscreen}
|
|
44
|
+
leading={
|
|
45
|
+
fullscreen ? (
|
|
46
|
+
<>
|
|
47
|
+
{exitButton}
|
|
48
|
+
<span className="truncate text-[13px] font-medium text-foreground">
|
|
49
|
+
{title}
|
|
50
|
+
</span>
|
|
51
|
+
</>
|
|
52
|
+
) : null
|
|
53
|
+
}
|
|
54
|
+
>
|
|
55
|
+
<Button
|
|
56
|
+
variant="ghost"
|
|
57
|
+
size="sm"
|
|
58
|
+
className="h-7 gap-1.5 text-xs"
|
|
59
|
+
onClick={() => window.open(iframeSrc, "_blank")}
|
|
60
|
+
>
|
|
61
|
+
<ExternalLink className="h-3.5 w-3.5" />
|
|
62
|
+
Open in new tab
|
|
63
|
+
</Button>
|
|
64
|
+
</ViewerToolbar>
|
|
65
|
+
|
|
66
|
+
<iframe
|
|
67
|
+
src={iframeSrc}
|
|
68
|
+
className="flex-1 w-full border-0 bg-card"
|
|
69
|
+
title={title}
|
|
70
|
+
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-top-navigation-by-user-activation"
|
|
71
|
+
/>
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type React from "react";
|
|
4
|
+
import { useCallback, useRef, useState } from "react";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import {
|
|
7
|
+
DialogContent,
|
|
8
|
+
DialogFooter,
|
|
9
|
+
DialogHeader,
|
|
10
|
+
Dialog as DialogRoot,
|
|
11
|
+
DialogTitle,
|
|
12
|
+
} from "@/components/ui/dialog";
|
|
13
|
+
import { Input } from "@/components/ui/input";
|
|
14
|
+
import { Label } from "@/components/ui/label";
|
|
15
|
+
import { showError } from "@/lib/toast";
|
|
16
|
+
import { useWikiSlugsStore } from "@/stores/wiki-slugs-store";
|
|
17
|
+
|
|
18
|
+
type Dir = "entities" | "concepts" | "comparisons";
|
|
19
|
+
|
|
20
|
+
export interface WikiCreateResult {
|
|
21
|
+
ok: boolean;
|
|
22
|
+
slug: string;
|
|
23
|
+
dir?: Dir;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface DialogState {
|
|
27
|
+
slug: string;
|
|
28
|
+
dir: Dir;
|
|
29
|
+
title: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function humanizeSlug(slug: string): string {
|
|
33
|
+
return slug
|
|
34
|
+
.split("-")
|
|
35
|
+
.filter(Boolean)
|
|
36
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
37
|
+
.join(" ");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const DIRS: Dir[] = ["entities", "concepts", "comparisons"];
|
|
41
|
+
|
|
42
|
+
export function useWikiLinkCreate(): {
|
|
43
|
+
open: (slug: string) => Promise<WikiCreateResult>;
|
|
44
|
+
Dialog: React.ReactNode;
|
|
45
|
+
} {
|
|
46
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
47
|
+
const [dialogState, setDialogState] = useState<DialogState>({
|
|
48
|
+
slug: "",
|
|
49
|
+
dir: "entities",
|
|
50
|
+
title: "",
|
|
51
|
+
});
|
|
52
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
53
|
+
const resolveRef = useRef<((result: WikiCreateResult) => void) | null>(null);
|
|
54
|
+
|
|
55
|
+
const open = useCallback((slug: string): Promise<WikiCreateResult> => {
|
|
56
|
+
setDialogState({
|
|
57
|
+
slug,
|
|
58
|
+
dir: "entities",
|
|
59
|
+
title: humanizeSlug(slug),
|
|
60
|
+
});
|
|
61
|
+
setIsOpen(true);
|
|
62
|
+
return new Promise<WikiCreateResult>((resolve) => {
|
|
63
|
+
resolveRef.current = resolve;
|
|
64
|
+
});
|
|
65
|
+
}, []);
|
|
66
|
+
|
|
67
|
+
const handleCancel = useCallback(() => {
|
|
68
|
+
setIsOpen(false);
|
|
69
|
+
resolveRef.current?.({ ok: false, slug: dialogState.slug });
|
|
70
|
+
resolveRef.current = null;
|
|
71
|
+
}, [dialogState.slug]);
|
|
72
|
+
|
|
73
|
+
const handleCreate = useCallback(async () => {
|
|
74
|
+
const { slug, dir, title } = dialogState;
|
|
75
|
+
setIsSubmitting(true);
|
|
76
|
+
try {
|
|
77
|
+
const res = await fetch("/api/wiki/page", {
|
|
78
|
+
method: "POST",
|
|
79
|
+
headers: { "Content-Type": "application/json" },
|
|
80
|
+
body: JSON.stringify({
|
|
81
|
+
slug,
|
|
82
|
+
dir,
|
|
83
|
+
title: title.trim() || undefined,
|
|
84
|
+
}),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
if (res.ok || res.status === 409) {
|
|
88
|
+
// 409 means the page already exists — still a success from our POV
|
|
89
|
+
const store = useWikiSlugsStore.getState();
|
|
90
|
+
store.invalidate();
|
|
91
|
+
void store.load();
|
|
92
|
+
setIsOpen(false);
|
|
93
|
+
resolveRef.current?.({ ok: true, slug, dir });
|
|
94
|
+
resolveRef.current = null;
|
|
95
|
+
} else {
|
|
96
|
+
const data: unknown = await res.json().catch(() => ({}));
|
|
97
|
+
const msg =
|
|
98
|
+
data !== null &&
|
|
99
|
+
typeof data === "object" &&
|
|
100
|
+
"error" in data &&
|
|
101
|
+
typeof (data as { error: unknown }).error === "string"
|
|
102
|
+
? (data as { error: string }).error
|
|
103
|
+
: "Failed to create page";
|
|
104
|
+
showError(msg);
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
showError("Failed to create page");
|
|
108
|
+
} finally {
|
|
109
|
+
setIsSubmitting(false);
|
|
110
|
+
}
|
|
111
|
+
}, [dialogState]);
|
|
112
|
+
|
|
113
|
+
const dialogNode = (
|
|
114
|
+
<DialogRoot
|
|
115
|
+
open={isOpen}
|
|
116
|
+
onOpenChange={(v) => {
|
|
117
|
+
if (!v) handleCancel();
|
|
118
|
+
}}
|
|
119
|
+
>
|
|
120
|
+
<DialogContent>
|
|
121
|
+
<DialogHeader>
|
|
122
|
+
<DialogTitle>Create wiki page</DialogTitle>
|
|
123
|
+
</DialogHeader>
|
|
124
|
+
|
|
125
|
+
<div className="space-y-4 py-2">
|
|
126
|
+
<div className="space-y-1.5">
|
|
127
|
+
<Label htmlFor="wiki-create-slug">Slug</Label>
|
|
128
|
+
<Input
|
|
129
|
+
id="wiki-create-slug"
|
|
130
|
+
value={dialogState.slug}
|
|
131
|
+
readOnly
|
|
132
|
+
className="bg-muted cursor-default select-all"
|
|
133
|
+
/>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<div className="space-y-1.5">
|
|
137
|
+
<Label>Directory</Label>
|
|
138
|
+
<div className="flex gap-4">
|
|
139
|
+
{DIRS.map((d) => (
|
|
140
|
+
<label
|
|
141
|
+
key={d}
|
|
142
|
+
className="flex items-center gap-1.5 text-sm cursor-pointer"
|
|
143
|
+
>
|
|
144
|
+
<input
|
|
145
|
+
type="radio"
|
|
146
|
+
name="wiki-create-dir"
|
|
147
|
+
value={d}
|
|
148
|
+
checked={dialogState.dir === d}
|
|
149
|
+
onChange={() => setDialogState((s) => ({ ...s, dir: d }))}
|
|
150
|
+
className="accent-primary"
|
|
151
|
+
/>
|
|
152
|
+
{d}
|
|
153
|
+
</label>
|
|
154
|
+
))}
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
<div className="space-y-1.5">
|
|
159
|
+
<Label htmlFor="wiki-create-title">Title (optional)</Label>
|
|
160
|
+
<Input
|
|
161
|
+
id="wiki-create-title"
|
|
162
|
+
value={dialogState.title}
|
|
163
|
+
onChange={(e) =>
|
|
164
|
+
setDialogState((s) => ({ ...s, title: e.target.value }))
|
|
165
|
+
}
|
|
166
|
+
placeholder="Humanized title..."
|
|
167
|
+
/>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
<DialogFooter>
|
|
172
|
+
<Button
|
|
173
|
+
variant="outline"
|
|
174
|
+
onClick={handleCancel}
|
|
175
|
+
disabled={isSubmitting}
|
|
176
|
+
>
|
|
177
|
+
Cancel
|
|
178
|
+
</Button>
|
|
179
|
+
<Button onClick={() => void handleCreate()} disabled={isSubmitting}>
|
|
180
|
+
{isSubmitting ? "Creating..." : "Create"}
|
|
181
|
+
</Button>
|
|
182
|
+
</DialogFooter>
|
|
183
|
+
</DialogContent>
|
|
184
|
+
</DialogRoot>
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
return { open, Dialog: dialogNode };
|
|
188
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { EditorState, Transaction } from "@tiptap/pm/state";
|
|
2
|
+
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
|
3
|
+
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
|
4
|
+
import { useWikiSlugsStore } from "@/stores/wiki-slugs-store";
|
|
5
|
+
|
|
6
|
+
const WIKI_LINK_DECORATOR_KEY = new PluginKey<DecorationSet>(
|
|
7
|
+
"wikiLinkDecorator",
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
const REFRESH_META = "wikiLinkDecorator:refresh";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Walk the doc, find every wikiLink mark, and emit an inline decoration
|
|
14
|
+
* carrying data-broken="true" for any slug not in the slug index store.
|
|
15
|
+
*
|
|
16
|
+
* Decorations apply attributes during ProseMirror's own DOM render pass,
|
|
17
|
+
* so they don't trigger the MutationObserver feedback loop that direct
|
|
18
|
+
* setAttribute would.
|
|
19
|
+
*/
|
|
20
|
+
function buildDecorations(state: EditorState): DecorationSet {
|
|
21
|
+
const { has } = useWikiSlugsStore.getState();
|
|
22
|
+
const markType = state.schema.marks.wikiLink;
|
|
23
|
+
if (!markType) return DecorationSet.empty;
|
|
24
|
+
|
|
25
|
+
const decorations: Decoration[] = [];
|
|
26
|
+
state.doc.descendants((node, pos) => {
|
|
27
|
+
if (!node.isText) return;
|
|
28
|
+
const mark = node.marks.find((m) => m.type === markType);
|
|
29
|
+
if (!mark) return;
|
|
30
|
+
const slug = mark.attrs.slug as string | undefined;
|
|
31
|
+
if (!slug || has(slug)) return;
|
|
32
|
+
decorations.push(
|
|
33
|
+
Decoration.inline(pos, pos + node.nodeSize, {
|
|
34
|
+
"data-broken": "true",
|
|
35
|
+
}),
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
return DecorationSet.create(state.doc, decorations);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Plugin that marks wiki-links as broken when their slug is absent from
|
|
44
|
+
* the slug index. Recomputes on doc changes and when the slug store
|
|
45
|
+
* signals a refresh via a meta-only transaction.
|
|
46
|
+
*/
|
|
47
|
+
export function wikiLinkDecoratorPlugin(): Plugin {
|
|
48
|
+
return new Plugin<DecorationSet>({
|
|
49
|
+
key: WIKI_LINK_DECORATOR_KEY,
|
|
50
|
+
state: {
|
|
51
|
+
init: (_config, state) => buildDecorations(state),
|
|
52
|
+
apply(tr: Transaction, old: DecorationSet, _oldState, newState) {
|
|
53
|
+
if (tr.getMeta(REFRESH_META)) return buildDecorations(newState);
|
|
54
|
+
if (!tr.docChanged) return old.map(tr.mapping, tr.doc);
|
|
55
|
+
return buildDecorations(newState);
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
props: {
|
|
59
|
+
decorations(state) {
|
|
60
|
+
return WIKI_LINK_DECORATOR_KEY.getState(state) ?? DecorationSet.empty;
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
view(editorView) {
|
|
64
|
+
// Subscribe to slug-store changes. When the index loads or
|
|
65
|
+
// invalidates, dispatch a meta-only transaction so the plugin
|
|
66
|
+
// state recomputes against the latest snapshot.
|
|
67
|
+
const unsubscribe = useWikiSlugsStore.subscribe(() => {
|
|
68
|
+
const tr = editorView.state.tr.setMeta(REFRESH_META, true);
|
|
69
|
+
editorView.dispatch(tr);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
destroy() {
|
|
74
|
+
unsubscribe();
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { InputRule, Mark, mergeAttributes } from "@tiptap/core";
|
|
2
|
+
import { wikiLinkDecoratorPlugin } from "./wiki-link-decorator";
|
|
3
|
+
|
|
4
|
+
// Matches: [[slug]] [[slug|alias]] [[slug#anchor]]
|
|
5
|
+
// Groups: 1=slug 2=alias 3=anchor
|
|
6
|
+
const INPUT_RULE_REGEX = /\[\[([a-z0-9-]+)(?:\|([^\]#|]+)|#([a-z0-9-]+))?\]\]$/;
|
|
7
|
+
|
|
8
|
+
declare module "@tiptap/core" {
|
|
9
|
+
interface Commands<ReturnType> {
|
|
10
|
+
wikiLink: {
|
|
11
|
+
/** Apply a wiki-link mark to the current selection. */
|
|
12
|
+
setWikiLink: (attrs: {
|
|
13
|
+
slug: string;
|
|
14
|
+
alias?: string | null;
|
|
15
|
+
anchor?: string | null;
|
|
16
|
+
}) => ReturnType;
|
|
17
|
+
/** Remove the wiki-link mark from the current selection. */
|
|
18
|
+
unsetWikiLink: () => ReturnType;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const WikiLink = Mark.create({
|
|
24
|
+
name: "wikiLink",
|
|
25
|
+
priority: 1000,
|
|
26
|
+
keepOnSplit: false,
|
|
27
|
+
inclusive: false,
|
|
28
|
+
|
|
29
|
+
addAttributes() {
|
|
30
|
+
return {
|
|
31
|
+
slug: {
|
|
32
|
+
default: null,
|
|
33
|
+
parseHTML: (element) => element.getAttribute("data-slug"),
|
|
34
|
+
renderHTML: (attributes) => ({
|
|
35
|
+
"data-slug": attributes.slug,
|
|
36
|
+
}),
|
|
37
|
+
},
|
|
38
|
+
alias: {
|
|
39
|
+
default: null,
|
|
40
|
+
parseHTML: (element) => element.getAttribute("data-alias"),
|
|
41
|
+
renderHTML: (attributes) =>
|
|
42
|
+
attributes.alias != null ? { "data-alias": attributes.alias } : {},
|
|
43
|
+
},
|
|
44
|
+
anchor: {
|
|
45
|
+
default: null,
|
|
46
|
+
parseHTML: (element) => element.getAttribute("data-anchor"),
|
|
47
|
+
renderHTML: (attributes) =>
|
|
48
|
+
attributes.anchor != null ? { "data-anchor": attributes.anchor } : {},
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
parseHTML() {
|
|
54
|
+
return [{ tag: 'a[data-wiki-link="true"]' }];
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
renderHTML({ HTMLAttributes }) {
|
|
58
|
+
const slug = String(HTMLAttributes["data-slug"] ?? "");
|
|
59
|
+
const anchor =
|
|
60
|
+
HTMLAttributes["data-anchor"] != null
|
|
61
|
+
? String(HTMLAttributes["data-anchor"])
|
|
62
|
+
: null;
|
|
63
|
+
const href = anchor ? `#wiki:${slug}#${anchor}` : `#wiki:${slug}`;
|
|
64
|
+
return [
|
|
65
|
+
"a",
|
|
66
|
+
mergeAttributes(HTMLAttributes, {
|
|
67
|
+
"data-wiki-link": "true",
|
|
68
|
+
href,
|
|
69
|
+
class: "wiki-link",
|
|
70
|
+
}),
|
|
71
|
+
0,
|
|
72
|
+
];
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
addInputRules() {
|
|
76
|
+
// markInputRule always uses the last capture group as visible text and
|
|
77
|
+
// cannot produce variable text per form ([[slug]] vs [[slug|alias]] vs
|
|
78
|
+
// [[slug#anchor]]). A raw InputRule is required here.
|
|
79
|
+
return [
|
|
80
|
+
new InputRule({
|
|
81
|
+
find: INPUT_RULE_REGEX,
|
|
82
|
+
handler: ({ state, range, match }) => {
|
|
83
|
+
const slug: string = match[1] ?? "";
|
|
84
|
+
const alias: string | null = match[2] ?? null;
|
|
85
|
+
const anchor: string | null = match[3] ?? null;
|
|
86
|
+
const visibleText = alias ?? (anchor ? `${slug}#${anchor}` : slug);
|
|
87
|
+
const { tr } = state;
|
|
88
|
+
tr.replaceWith(
|
|
89
|
+
range.from,
|
|
90
|
+
range.to,
|
|
91
|
+
state.schema.text(visibleText, [
|
|
92
|
+
this.type.create({ slug, alias, anchor }),
|
|
93
|
+
]),
|
|
94
|
+
);
|
|
95
|
+
},
|
|
96
|
+
}),
|
|
97
|
+
];
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
addProseMirrorPlugins() {
|
|
101
|
+
return [wikiLinkDecoratorPlugin()];
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
addCommands() {
|
|
105
|
+
return {
|
|
106
|
+
setWikiLink:
|
|
107
|
+
(attrs) =>
|
|
108
|
+
({ commands }) =>
|
|
109
|
+
commands.setMark(this.name, {
|
|
110
|
+
slug: attrs.slug,
|
|
111
|
+
alias: attrs.alias ?? null,
|
|
112
|
+
anchor: attrs.anchor ?? null,
|
|
113
|
+
}),
|
|
114
|
+
unsetWikiLink:
|
|
115
|
+
() =>
|
|
116
|
+
({ commands }) =>
|
|
117
|
+
commands.unsetMark(this.name),
|
|
118
|
+
};
|
|
119
|
+
},
|
|
120
|
+
});
|