@peaske7/readit 0.2.0 → 0.3.0-rc.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/.claude/CLAUDE.md +118 -76
- package/.claude/commands/review.md +1 -1
- package/.claude/roadmap.md +32 -9
- package/.claude/user-stories.md +100 -15
- package/AGENTS.md +30 -26
- package/Makefile +32 -0
- package/README.md +90 -2
- package/biome.json +18 -8
- package/bun.lock +426 -568
- package/bunfig.toml +2 -0
- package/docs/perf-baseline.md +56 -1
- package/docs/superpowers/specs/2026-03-27-go-server-rewrite-design.md +284 -0
- package/e2e/comments.spec.ts +14 -58
- package/e2e/document-load.spec.ts +1 -23
- package/e2e/export.spec.ts +4 -4
- package/e2e/perf/add-comment.spec.ts +9 -11
- package/e2e/perf/fixtures/generate.ts +1 -5
- package/e2e/perf/screenshot-final.png +0 -0
- package/e2e/perf/utils/metrics.ts +73 -9
- package/e2e/persistence-file.spec.ts +41 -26
- package/e2e/utils/selection.ts +17 -73
- package/go/cmd/readit/main.go +416 -0
- package/go/go.mod +20 -0
- package/go/go.sum +41 -0
- package/go/internal/server/anchor.go +302 -0
- package/go/internal/server/anchor_test.go +111 -0
- package/go/internal/server/comments.go +390 -0
- package/go/internal/server/documents.go +113 -0
- package/go/internal/server/embed.go +17 -0
- package/go/internal/server/headings.go +33 -0
- package/go/internal/server/headings_test.go +75 -0
- package/go/internal/server/htmltext.go +123 -0
- package/go/internal/server/markdown.go +157 -0
- package/go/internal/server/markdown_bench_test.go +42 -0
- package/go/internal/server/markdown_test.go +79 -0
- package/go/internal/server/server.go +453 -0
- package/go/internal/server/server_bench_test.go +122 -0
- package/go/internal/server/settings.go +110 -0
- package/go/internal/server/sse.go +140 -0
- package/go/internal/server/storage.go +275 -0
- package/go/internal/server/storage_test.go +152 -0
- package/go/internal/server/template.go +66 -0
- package/go/internal/server/types.go +101 -0
- package/go/internal/server/watcher.go +74 -0
- package/index.html +4 -14
- package/nvim-readit/lua/readit/health.lua +64 -0
- package/nvim-readit/lua/readit/init.lua +463 -0
- package/nvim-readit/plugin/readit.lua +19 -0
- package/package.json +20 -28
- package/shell/_readit +158 -0
- package/shell/readit.zsh +87 -0
- package/src/App.svelte +890 -0
- package/src/cli.ts +183 -21
- package/src/components/ActionsMenu.svelte +95 -0
- package/src/components/CommentBadge.svelte +67 -0
- package/src/components/CommentErrorBanner.svelte +33 -0
- package/src/components/CommentInput.svelte +75 -0
- package/src/components/CommentListItem.svelte +95 -0
- package/src/components/CommentManager.svelte +129 -0
- package/src/components/CommentNav.svelte +109 -0
- package/src/components/DocumentViewer.svelte +233 -0
- package/src/components/FloatingComment.svelte +107 -0
- package/src/components/Header.svelte +76 -0
- package/src/components/InlineEditor.svelte +72 -0
- package/src/components/MarginNote.svelte +167 -0
- package/src/components/MarginNotesContainer.svelte +33 -0
- package/src/components/MermaidEnhancer.svelte +218 -0
- package/src/components/MermaidModal.svelte +67 -0
- package/src/components/RawModal.svelte +126 -0
- package/src/components/ReanchorConfirm.svelte +30 -0
- package/src/components/SettingsModal.svelte +220 -0
- package/src/components/ShortcutCapture.svelte +82 -0
- package/src/components/ShortcutList.svelte +145 -0
- package/src/components/TabBar.svelte +52 -0
- package/src/components/TableOfContents.svelte +125 -0
- package/src/components/ui/ActionLink.svelte +40 -0
- package/src/components/ui/{Button.tsx → Button.svelte} +19 -20
- package/src/components/ui/Dialog.svelte +97 -0
- package/src/components/ui/DropdownMenu.svelte +85 -0
- package/src/components/ui/DropdownMenuItem.svelte +38 -0
- package/src/components/ui/DropdownMenuSeparator.svelte +11 -0
- package/src/components/ui/{Text.tsx → Text.svelte} +18 -23
- package/src/env.d.ts +6 -0
- package/src/index.css +141 -166
- package/src/lib/__fixtures__/bench-data.ts +0 -13
- package/src/lib/anchor.bench.ts +1 -12
- package/src/lib/anchor.test.ts +0 -8
- package/src/lib/anchor.ts +0 -4
- package/src/lib/comment-storage.bench.ts +49 -0
- package/src/lib/comment-storage.test.ts +103 -33
- package/src/lib/comment-storage.ts +25 -18
- package/src/lib/export.bench.ts +21 -0
- package/src/lib/export.ts +0 -1
- package/src/lib/fetch-or-throw.test.ts +59 -0
- package/src/lib/fetch-or-throw.ts +12 -0
- package/src/{hooks/useHeadings.test.ts → lib/headings.test.ts} +10 -24
- package/src/{hooks/useHeadings.ts → lib/headings.ts} +11 -13
- package/src/lib/highlight/core.test.ts +0 -5
- package/src/lib/highlight/dom.ts +52 -216
- package/src/lib/highlight/highlight-registry.ts +221 -0
- package/src/lib/highlight/highlight.bench.ts +92 -0
- package/src/lib/highlight/highlighter.ts +112 -132
- package/src/lib/highlight/resolver.ts +5 -79
- package/src/lib/highlight/types.ts +0 -5
- package/src/lib/html-text.test.ts +162 -0
- package/src/lib/html-text.ts +161 -0
- package/src/lib/i18n/en.ts +34 -0
- package/src/lib/i18n/ja.ts +34 -0
- package/src/lib/i18n/types.ts +33 -0
- package/src/lib/key-lock.test.ts +104 -0
- package/src/lib/key-lock.ts +23 -0
- package/src/lib/margin-layout.bench.ts +61 -0
- package/src/lib/margin-layout.ts +0 -7
- package/src/lib/markdown-renderer.test.ts +154 -0
- package/src/lib/markdown-renderer.ts +178 -0
- package/src/lib/mermaid-config.ts +38 -0
- package/src/lib/mermaid-renderer.ts +162 -0
- package/src/lib/mermaid-worker.ts +60 -0
- package/src/lib/positions.ts +31 -24
- package/src/lib/shortcut-registry.ts +244 -0
- package/src/lib/utils.ts +0 -29
- package/src/main.ts +16 -0
- package/src/schema.ts +16 -5
- package/src/server.ts +355 -95
- package/src/stores/app.svelte.ts +231 -0
- package/src/stores/locale.svelte.ts +46 -0
- package/src/stores/settings.svelte.ts +90 -0
- package/src/stores/shortcuts.svelte.ts +104 -0
- package/src/stores/ui.svelte.ts +12 -0
- package/src/template.ts +104 -0
- package/src/test-setup.ts +47 -0
- package/svelte.config.js +5 -0
- package/tsconfig.json +2 -2
- package/vite.config.ts +23 -3
- package/vscode-readit/.mcp.json +7 -0
- package/vscode-readit/.vscodeignore +7 -0
- package/vscode-readit/bun.lock +78 -0
- package/vscode-readit/icon.svg +10 -0
- package/vscode-readit/package.json +110 -0
- package/vscode-readit/src/extension.ts +117 -0
- package/vscode-readit/src/server-manager.ts +272 -0
- package/vscode-readit/src/webview-provider.ts +204 -0
- package/vscode-readit/tsconfig.json +20 -0
- package/e2e/fixtures/sample.html +0 -13
- package/src/App.tsx +0 -368
- package/src/components/ActionsMenu.tsx +0 -91
- package/src/components/DocumentViewer/CodeBlock.tsx +0 -160
- package/src/components/DocumentViewer/DocumentViewer.tsx +0 -230
- package/src/components/DocumentViewer/MermaidDiagram.tsx +0 -136
- package/src/components/Header.tsx +0 -54
- package/src/components/InlineEditor.tsx +0 -74
- package/src/components/MarginNote.tsx +0 -185
- package/src/components/MarginNotes.tsx +0 -23
- package/src/components/RawModal.tsx +0 -144
- package/src/components/ReanchorConfirm.tsx +0 -36
- package/src/components/SettingsModal.tsx +0 -232
- package/src/components/TabBar.tsx +0 -60
- package/src/components/TableOfContents.tsx +0 -108
- package/src/components/comments/CommentBadge.tsx +0 -49
- package/src/components/comments/CommentInput.tsx +0 -86
- package/src/components/comments/CommentListItem.tsx +0 -90
- package/src/components/comments/CommentManager.tsx +0 -129
- package/src/components/comments/CommentNav.tsx +0 -109
- package/src/components/ui/ActionLink.tsx +0 -28
- package/src/components/ui/Dialog.tsx +0 -116
- package/src/components/ui/DropdownMenu.tsx +0 -158
- package/src/contexts/CommentContext.tsx +0 -198
- package/src/contexts/LocaleContext.tsx +0 -76
- package/src/contexts/PositionsContext.tsx +0 -16
- package/src/contexts/SettingsContext.tsx +0 -133
- package/src/hooks/useClickOutside.ts +0 -31
- package/src/hooks/useCommentNavigation.ts +0 -107
- package/src/hooks/useComments.ts +0 -311
- package/src/hooks/useDocument.ts +0 -157
- package/src/hooks/useScrollSpy.ts +0 -77
- package/src/hooks/useTextSelection.ts +0 -86
- package/src/lib/highlight/worker.ts +0 -45
- package/src/main.tsx +0 -13
- package/src/store.ts +0 -222
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
import { FontFamilies } from "../schema";
|
|
4
|
+
import { t } from "../stores/locale.svelte";
|
|
5
|
+
import { settings } from "../stores/settings.svelte";
|
|
6
|
+
import Button from "./ui/Button.svelte";
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
initialText: string;
|
|
10
|
+
onsave: (text: string) => void;
|
|
11
|
+
oncancel: () => void;
|
|
12
|
+
rows?: number;
|
|
13
|
+
class?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let {
|
|
17
|
+
initialText,
|
|
18
|
+
onsave,
|
|
19
|
+
oncancel,
|
|
20
|
+
rows = 2,
|
|
21
|
+
class: className,
|
|
22
|
+
}: Props = $props();
|
|
23
|
+
|
|
24
|
+
let fontClass = $derived(
|
|
25
|
+
settings.fontFamily === FontFamilies.SANS_SERIF ? "font-sans" : "font-serif",
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
// svelte-ignore state_referenced_locally — intentionally capture initial prop value
|
|
29
|
+
let editText = $state(initialText);
|
|
30
|
+
let textareaEl: HTMLTextAreaElement | undefined = $state();
|
|
31
|
+
|
|
32
|
+
$effect(() => {
|
|
33
|
+
textareaEl?.focus();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
function handleSave() {
|
|
37
|
+
if (editText.trim()) {
|
|
38
|
+
onsave(editText);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
43
|
+
if (e.key === "Enter" && e.metaKey) {
|
|
44
|
+
handleSave();
|
|
45
|
+
}
|
|
46
|
+
if (e.key === "Escape") {
|
|
47
|
+
oncancel();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
</script>
|
|
51
|
+
|
|
52
|
+
<div class="space-y-2">
|
|
53
|
+
<textarea
|
|
54
|
+
bind:this={textareaEl}
|
|
55
|
+
bind:value={editText}
|
|
56
|
+
class={cn(
|
|
57
|
+
fontClass,
|
|
58
|
+
"w-full px-2 py-1.5 text-sm border border-zinc-200 dark:border-zinc-700 dark:bg-zinc-800 resize-none focus:outline-none focus:border-zinc-400 dark:focus:border-zinc-500",
|
|
59
|
+
className,
|
|
60
|
+
)}
|
|
61
|
+
{rows}
|
|
62
|
+
onkeydown={handleKeydown}
|
|
63
|
+
></textarea>
|
|
64
|
+
<div class="flex gap-3 text-sm">
|
|
65
|
+
<Button variant="link" size="sm" onclick={handleSave}>
|
|
66
|
+
{t("editor.save")}
|
|
67
|
+
</Button>
|
|
68
|
+
<Button variant="ghost" size="sm" onclick={oncancel}>
|
|
69
|
+
{t("editor.cancel")}
|
|
70
|
+
</Button>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Positions } from "../lib/positions";
|
|
3
|
+
import { cn } from "../lib/utils";
|
|
4
|
+
import { type Comment, FontFamilies } from "../schema";
|
|
5
|
+
import { t } from "../stores/locale.svelte";
|
|
6
|
+
import { settings } from "../stores/settings.svelte";
|
|
7
|
+
import { setHoveredCommentId, ui } from "../stores/ui.svelte";
|
|
8
|
+
import InlineEditor from "./InlineEditor.svelte";
|
|
9
|
+
import ActionLink from "./ui/ActionLink.svelte";
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
comment: Comment;
|
|
13
|
+
commentIndex?: number;
|
|
14
|
+
positions: Positions;
|
|
15
|
+
onedit: (id: string, text: string) => void;
|
|
16
|
+
ondelete: (id: string) => void;
|
|
17
|
+
oncopy: (comment: Comment) => void;
|
|
18
|
+
onnavigate: (commentId: string) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let {
|
|
22
|
+
comment,
|
|
23
|
+
commentIndex = 0,
|
|
24
|
+
positions,
|
|
25
|
+
onedit,
|
|
26
|
+
ondelete,
|
|
27
|
+
oncopy,
|
|
28
|
+
onnavigate,
|
|
29
|
+
}: Props = $props();
|
|
30
|
+
|
|
31
|
+
let isEditing = $state(false);
|
|
32
|
+
let articleEl: HTMLElement | undefined = $state();
|
|
33
|
+
|
|
34
|
+
let isHovered = $derived(ui.hoveredCommentId === comment.id);
|
|
35
|
+
let fontClass = $derived(
|
|
36
|
+
settings.fontFamily === FontFamilies.SANS_SERIF ? "font-sans" : "font-serif",
|
|
37
|
+
);
|
|
38
|
+
let hasNote = $derived(comment.comment.trim().length > 0);
|
|
39
|
+
|
|
40
|
+
$effect(() => {
|
|
41
|
+
if (articleEl) {
|
|
42
|
+
positions.register(comment.id, articleEl);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return () => {
|
|
46
|
+
positions.unregister(comment.id);
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
function selectedTextClass(hovered: boolean): string {
|
|
51
|
+
return cn(
|
|
52
|
+
"text-sm italic mb-1 line-clamp-1 flex items-center gap-1 transition-colors duration-150",
|
|
53
|
+
hovered
|
|
54
|
+
? "text-zinc-600 dark:text-zinc-400"
|
|
55
|
+
: "text-zinc-400 dark:text-zinc-500",
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function commentTextClass(hovered: boolean): string {
|
|
60
|
+
return cn(
|
|
61
|
+
"text-sm whitespace-pre-wrap transition-colors duration-150",
|
|
62
|
+
hovered
|
|
63
|
+
? "text-zinc-800 dark:text-zinc-200"
|
|
64
|
+
: "text-zinc-500 dark:text-zinc-400",
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function badgeClass(hovered: boolean): string {
|
|
69
|
+
return cn(
|
|
70
|
+
"absolute -left-4 top-2 text-xs tabular-nums transition-colors duration-150",
|
|
71
|
+
hovered
|
|
72
|
+
? "text-zinc-600 dark:text-zinc-400"
|
|
73
|
+
: "text-zinc-400 dark:text-zinc-500",
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
</script>
|
|
77
|
+
|
|
78
|
+
{#if !hasNote && !isEditing}
|
|
79
|
+
<!-- Highlight-only (no note): minimal em-dash marker -->
|
|
80
|
+
<article
|
|
81
|
+
bind:this={articleEl}
|
|
82
|
+
class="absolute left-0 right-0 group"
|
|
83
|
+
style="visibility: hidden; content-visibility: auto; contain-intrinsic-size: auto 80px;"
|
|
84
|
+
data-comment-id={comment.id}
|
|
85
|
+
onmouseenter={() => setHoveredCommentId(comment.id)}
|
|
86
|
+
onmouseleave={() => setHoveredCommentId(undefined)}
|
|
87
|
+
>
|
|
88
|
+
<span class={badgeClass(isHovered)}>—</span>
|
|
89
|
+
|
|
90
|
+
<div class="pt-2 pb-2 pl-3">
|
|
91
|
+
<div
|
|
92
|
+
class={cn(
|
|
93
|
+
"flex items-center text-xs text-zinc-400 opacity-0 group-hover:opacity-100 transition-opacity",
|
|
94
|
+
"gap-1.5 duration-150",
|
|
95
|
+
isHovered && "opacity-100",
|
|
96
|
+
)}
|
|
97
|
+
>
|
|
98
|
+
<ActionLink onclick={() => (isEditing = true)}>
|
|
99
|
+
{t("marginNote.addNote")}
|
|
100
|
+
</ActionLink>
|
|
101
|
+
<span aria-hidden="true">·</span>
|
|
102
|
+
<ActionLink variant="destructive" onclick={() => ondelete(comment.id)}>
|
|
103
|
+
{t("marginNote.delete")}
|
|
104
|
+
</ActionLink>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
</article>
|
|
108
|
+
{:else}
|
|
109
|
+
<!-- Comment with note -->
|
|
110
|
+
<article
|
|
111
|
+
bind:this={articleEl}
|
|
112
|
+
class="absolute left-0 right-0 group"
|
|
113
|
+
style="visibility: hidden"
|
|
114
|
+
data-comment-id={comment.id}
|
|
115
|
+
onmouseenter={() => setHoveredCommentId(comment.id)}
|
|
116
|
+
onmouseleave={() => setHoveredCommentId(undefined)}
|
|
117
|
+
>
|
|
118
|
+
<span class={badgeClass(isHovered)}>{commentIndex + 1}</span>
|
|
119
|
+
|
|
120
|
+
<div
|
|
121
|
+
class={cn(
|
|
122
|
+
"relative border-t border-zinc-100 dark:border-zinc-800 pt-3 pb-2 pl-3 transition-colors duration-150",
|
|
123
|
+
comment.anchorConfidence === "unresolved" && "opacity-60",
|
|
124
|
+
)}
|
|
125
|
+
>
|
|
126
|
+
{#if !isEditing}
|
|
127
|
+
<div class={cn(fontClass, selectedTextClass(isHovered))}>
|
|
128
|
+
<button
|
|
129
|
+
type="button"
|
|
130
|
+
onclick={() => onnavigate(comment.id)}
|
|
131
|
+
class="cursor-pointer hover:underline text-left"
|
|
132
|
+
>
|
|
133
|
+
"{comment.selectedText}"
|
|
134
|
+
</button>
|
|
135
|
+
</div>
|
|
136
|
+
{/if}
|
|
137
|
+
|
|
138
|
+
{#if isEditing}
|
|
139
|
+
<InlineEditor
|
|
140
|
+
initialText={comment.comment}
|
|
141
|
+
onsave={(text) => {
|
|
142
|
+
onedit(comment.id, text);
|
|
143
|
+
isEditing = false;
|
|
144
|
+
}}
|
|
145
|
+
oncancel={() => (isEditing = false)}
|
|
146
|
+
/>
|
|
147
|
+
{:else}
|
|
148
|
+
<p class={cn(fontClass, commentTextClass(isHovered))}>
|
|
149
|
+
{comment.comment}
|
|
150
|
+
</p>
|
|
151
|
+
<div class="flex items-center text-xs text-zinc-400 opacity-0 group-hover:opacity-100 transition-opacity gap-1.5 mt-2">
|
|
152
|
+
<ActionLink onclick={() => (isEditing = true)}>
|
|
153
|
+
{t("marginNote.edit")}
|
|
154
|
+
</ActionLink>
|
|
155
|
+
<span aria-hidden="true">·</span>
|
|
156
|
+
<ActionLink variant="destructive" onclick={() => ondelete(comment.id)}>
|
|
157
|
+
{t("marginNote.delete")}
|
|
158
|
+
</ActionLink>
|
|
159
|
+
<span aria-hidden="true">·</span>
|
|
160
|
+
<ActionLink onclick={() => oncopy(comment)}>
|
|
161
|
+
{t("marginNote.copy")}
|
|
162
|
+
</ActionLink>
|
|
163
|
+
</div>
|
|
164
|
+
{/if}
|
|
165
|
+
</div>
|
|
166
|
+
</article>
|
|
167
|
+
{/if}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Positions } from "../lib/positions";
|
|
3
|
+
import type { Comment } from "../schema";
|
|
4
|
+
import MarginNote from "./MarginNote.svelte";
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
sortedComments: Comment[];
|
|
8
|
+
positions: Positions;
|
|
9
|
+
onedit: (id: string, text: string) => void;
|
|
10
|
+
ondelete: (id: string) => void;
|
|
11
|
+
oncopy: (comment: Comment) => void;
|
|
12
|
+
onnavigate: (commentId: string) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let { sortedComments, positions, onedit, ondelete, oncopy, onnavigate }: Props =
|
|
16
|
+
$props();
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
{#if sortedComments.length > 0}
|
|
20
|
+
<div class="relative w-64">
|
|
21
|
+
{#each sortedComments as comment, index (comment.id)}
|
|
22
|
+
<MarginNote
|
|
23
|
+
{comment}
|
|
24
|
+
commentIndex={index}
|
|
25
|
+
{positions}
|
|
26
|
+
{onedit}
|
|
27
|
+
{ondelete}
|
|
28
|
+
{oncopy}
|
|
29
|
+
{onnavigate}
|
|
30
|
+
/>
|
|
31
|
+
{/each}
|
|
32
|
+
</div>
|
|
33
|
+
{/if}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Code2, Image as ImageIcon, Maximize2 } from "lucide-svelte";
|
|
3
|
+
import { mount, unmount } from "svelte";
|
|
4
|
+
import { localeState, t } from "../stores/locale.svelte";
|
|
5
|
+
import MermaidModal from "./MermaidModal.svelte";
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
/** The article element that contains all rendered mermaid containers. */
|
|
9
|
+
root: HTMLElement | undefined;
|
|
10
|
+
/** Bumped whenever document content is re-rendered (rescan triggers). */
|
|
11
|
+
contentVersion: number;
|
|
12
|
+
/** Called after an inline toggle so margin notes / positions can recache. */
|
|
13
|
+
notifyContentChanged?: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let { root, contentVersion, notifyContentChanged }: Props = $props();
|
|
17
|
+
|
|
18
|
+
let modalOpen = $state(false);
|
|
19
|
+
let modalSvg = $state("");
|
|
20
|
+
let modalSource = $state("");
|
|
21
|
+
|
|
22
|
+
const ENHANCED_FLAG = "readitMermaidEnhanced";
|
|
23
|
+
|
|
24
|
+
function decodeSource(container: HTMLElement): string {
|
|
25
|
+
const encoded = container.dataset.mermaidSource ?? "";
|
|
26
|
+
try {
|
|
27
|
+
return decodeURIComponent(encoded);
|
|
28
|
+
} catch {
|
|
29
|
+
return encoded;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function openModal(container: HTMLElement) {
|
|
34
|
+
const svgEl = container.querySelector("svg");
|
|
35
|
+
modalSvg = svgEl ? svgEl.outerHTML : "";
|
|
36
|
+
modalSource = decodeSource(container);
|
|
37
|
+
modalOpen = true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function ensureSourceView(container: HTMLElement, source: string): HTMLElement {
|
|
41
|
+
let pre = container.querySelector<HTMLElement>(".mermaid-source-view");
|
|
42
|
+
if (pre) return pre;
|
|
43
|
+
pre = document.createElement("pre");
|
|
44
|
+
pre.className = "mermaid-source-view";
|
|
45
|
+
const codeEl = document.createElement("code");
|
|
46
|
+
codeEl.className = "language-mermaid";
|
|
47
|
+
codeEl.textContent = source;
|
|
48
|
+
pre.appendChild(codeEl);
|
|
49
|
+
pre.style.display = "none";
|
|
50
|
+
// Insert before the toolbar so the toolbar stays the last child.
|
|
51
|
+
const toolbar = container.querySelector(".mermaid-toolbar");
|
|
52
|
+
container.insertBefore(pre, toolbar);
|
|
53
|
+
return pre;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function toggleInline(container: HTMLElement, source: string): void {
|
|
57
|
+
const svg = container.querySelector<SVGElement>("svg");
|
|
58
|
+
const pre = ensureSourceView(container, source);
|
|
59
|
+
const showingCode = container.dataset.mermaidView === "code";
|
|
60
|
+
const next = showingCode ? "graph" : "code";
|
|
61
|
+
|
|
62
|
+
if (svg) svg.style.display = next === "code" ? "none" : "";
|
|
63
|
+
pre.style.display = next === "code" ? "block" : "none";
|
|
64
|
+
container.dataset.mermaidView = next;
|
|
65
|
+
|
|
66
|
+
const toolbar = container.querySelector<HTMLElement>(".mermaid-toolbar");
|
|
67
|
+
const toggleBtn = toolbar?.querySelector<HTMLButtonElement>(
|
|
68
|
+
'[data-action="toggle"]',
|
|
69
|
+
);
|
|
70
|
+
toggleBtn?.setAttribute(
|
|
71
|
+
"aria-label",
|
|
72
|
+
next === "code" ? t("mermaid.showDiagram") : t("mermaid.showSource"),
|
|
73
|
+
);
|
|
74
|
+
toggleBtn?.setAttribute("aria-pressed", next === "code" ? "true" : "false");
|
|
75
|
+
|
|
76
|
+
const handle = (
|
|
77
|
+
toolbar as (HTMLElement & { _readitHandle?: ToolbarHandle }) | null
|
|
78
|
+
)?._readitHandle;
|
|
79
|
+
handle?.remountToggleIcon(next);
|
|
80
|
+
|
|
81
|
+
notifyContentChanged?.();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface ToolbarHandle {
|
|
85
|
+
cleanup: () => void;
|
|
86
|
+
remountToggleIcon: (view: "graph" | "code") => void;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function mountToggleIcon(target: HTMLElement, view: "graph" | "code") {
|
|
90
|
+
// Show "code" icon when graph is visible (click to see code), and vice versa.
|
|
91
|
+
const Icon = view === "graph" ? Code2 : ImageIcon;
|
|
92
|
+
return mount(Icon, { target, props: { size: 14 } });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function buildToolbar(container: HTMLElement): HTMLElement {
|
|
96
|
+
const toolbar = document.createElement("div");
|
|
97
|
+
toolbar.className = "mermaid-toolbar";
|
|
98
|
+
toolbar.setAttribute("contenteditable", "false");
|
|
99
|
+
|
|
100
|
+
const toggleBtn = document.createElement("button");
|
|
101
|
+
toggleBtn.type = "button";
|
|
102
|
+
toggleBtn.dataset.action = "toggle";
|
|
103
|
+
toggleBtn.setAttribute("aria-label", t("mermaid.showSource"));
|
|
104
|
+
toggleBtn.setAttribute("aria-pressed", "false");
|
|
105
|
+
|
|
106
|
+
const expandBtn = document.createElement("button");
|
|
107
|
+
expandBtn.type = "button";
|
|
108
|
+
expandBtn.dataset.action = "expand";
|
|
109
|
+
expandBtn.setAttribute("aria-label", t("mermaid.expand"));
|
|
110
|
+
|
|
111
|
+
toolbar.appendChild(toggleBtn);
|
|
112
|
+
toolbar.appendChild(expandBtn);
|
|
113
|
+
|
|
114
|
+
let toggleIcon = mountToggleIcon(toggleBtn, "graph");
|
|
115
|
+
const expandIcon = mount(Maximize2, {
|
|
116
|
+
target: expandBtn,
|
|
117
|
+
props: { size: 14 },
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
toggleBtn.addEventListener("click", (e) => {
|
|
121
|
+
e.preventDefault();
|
|
122
|
+
e.stopPropagation();
|
|
123
|
+
toggleInline(container, decodeSource(container));
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
expandBtn.addEventListener("click", (e) => {
|
|
127
|
+
e.preventDefault();
|
|
128
|
+
e.stopPropagation();
|
|
129
|
+
openModal(container);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const handle: ToolbarHandle = {
|
|
133
|
+
cleanup: () => {
|
|
134
|
+
void unmount(toggleIcon);
|
|
135
|
+
void unmount(expandIcon);
|
|
136
|
+
},
|
|
137
|
+
remountToggleIcon: (view) => {
|
|
138
|
+
void unmount(toggleIcon);
|
|
139
|
+
toggleIcon = mountToggleIcon(toggleBtn, view);
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
(toolbar as HTMLElement & { _readitHandle?: ToolbarHandle })._readitHandle =
|
|
143
|
+
handle;
|
|
144
|
+
|
|
145
|
+
return toolbar;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function enhance(target: HTMLElement) {
|
|
149
|
+
const containers = target.querySelectorAll<HTMLElement>(
|
|
150
|
+
".mermaid-container[data-mermaid-source]",
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
for (const container of containers) {
|
|
154
|
+
if (container.dataset[ENHANCED_FLAG] === "true") continue;
|
|
155
|
+
container.dataset[ENHANCED_FLAG] = "true";
|
|
156
|
+
|
|
157
|
+
// Ensure the container can host the absolutely-positioned toolbar.
|
|
158
|
+
container.style.position = "relative";
|
|
159
|
+
|
|
160
|
+
const toolbar = buildToolbar(container);
|
|
161
|
+
container.appendChild(toolbar);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function cleanup(target: HTMLElement) {
|
|
166
|
+
const toolbars = target.querySelectorAll<HTMLElement>(".mermaid-toolbar");
|
|
167
|
+
for (const tb of toolbars) {
|
|
168
|
+
(
|
|
169
|
+
tb as HTMLElement & { _readitHandle?: ToolbarHandle }
|
|
170
|
+
)._readitHandle?.cleanup();
|
|
171
|
+
tb.remove();
|
|
172
|
+
}
|
|
173
|
+
const enhanced = target.querySelectorAll<HTMLElement>(
|
|
174
|
+
".mermaid-container[data-readit-mermaid-enhanced]",
|
|
175
|
+
);
|
|
176
|
+
for (const c of enhanced) {
|
|
177
|
+
delete c.dataset[ENHANCED_FLAG];
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
$effect(() => {
|
|
182
|
+
if (!root) return;
|
|
183
|
+
// Re-scan whenever contentVersion changes; declared so Svelte tracks it.
|
|
184
|
+
void contentVersion;
|
|
185
|
+
enhance(root);
|
|
186
|
+
return () => {
|
|
187
|
+
if (root) cleanup(root);
|
|
188
|
+
};
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// Refresh aria-labels on existing imperative toolbars when locale changes.
|
|
192
|
+
$effect(() => {
|
|
193
|
+
if (!root) return;
|
|
194
|
+
void localeState.locale;
|
|
195
|
+
const toolbars = root.querySelectorAll<HTMLElement>(".mermaid-toolbar");
|
|
196
|
+
for (const tb of toolbars) {
|
|
197
|
+
const container = tb.closest<HTMLElement>(".mermaid-container");
|
|
198
|
+
const showingCode = container?.dataset.mermaidView === "code";
|
|
199
|
+
tb.querySelector<HTMLButtonElement>('[data-action="toggle"]')?.setAttribute(
|
|
200
|
+
"aria-label",
|
|
201
|
+
showingCode ? t("mermaid.showDiagram") : t("mermaid.showSource"),
|
|
202
|
+
);
|
|
203
|
+
tb.querySelector<HTMLButtonElement>('[data-action="expand"]')?.setAttribute(
|
|
204
|
+
"aria-label",
|
|
205
|
+
t("mermaid.expand"),
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
</script>
|
|
210
|
+
|
|
211
|
+
<MermaidModal
|
|
212
|
+
bind:open={modalOpen}
|
|
213
|
+
svg={modalSvg}
|
|
214
|
+
source={modalSource}
|
|
215
|
+
onclose={() => {
|
|
216
|
+
modalOpen = false;
|
|
217
|
+
}}
|
|
218
|
+
/>
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Code2, Image as ImageIcon } from "lucide-svelte";
|
|
3
|
+
import { t } from "../stores/locale.svelte";
|
|
4
|
+
import Dialog from "./ui/Dialog.svelte";
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
open: boolean;
|
|
8
|
+
svg: string;
|
|
9
|
+
source: string;
|
|
10
|
+
onclose: () => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let { open = $bindable(false), svg, source, onclose }: Props = $props();
|
|
14
|
+
|
|
15
|
+
type View = "graph" | "code";
|
|
16
|
+
let view = $state<View>("graph");
|
|
17
|
+
|
|
18
|
+
// Reset to graph view each time the modal opens so the user always
|
|
19
|
+
// lands on the diagram first.
|
|
20
|
+
$effect(() => {
|
|
21
|
+
if (open) view = "graph";
|
|
22
|
+
});
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<Dialog
|
|
26
|
+
bind:open
|
|
27
|
+
{onclose}
|
|
28
|
+
contentClass="w-[90vw] h-[90vh] max-w-[90vw]"
|
|
29
|
+
>
|
|
30
|
+
{#snippet header()}
|
|
31
|
+
{t("mermaid.modalTitle")}
|
|
32
|
+
{/snippet}
|
|
33
|
+
|
|
34
|
+
{#snippet headerActions()}
|
|
35
|
+
<div class="flex items-center gap-1 mr-8">
|
|
36
|
+
<button
|
|
37
|
+
type="button"
|
|
38
|
+
onclick={() => (view = "graph")}
|
|
39
|
+
aria-pressed={view === "graph"}
|
|
40
|
+
class="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs rounded-md text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800 aria-pressed:bg-zinc-100 dark:aria-pressed:bg-zinc-800 aria-pressed:text-zinc-900 dark:aria-pressed:text-zinc-100"
|
|
41
|
+
>
|
|
42
|
+
<ImageIcon class="w-3.5 h-3.5" />
|
|
43
|
+
{t("mermaid.viewGraph")}
|
|
44
|
+
</button>
|
|
45
|
+
<button
|
|
46
|
+
type="button"
|
|
47
|
+
onclick={() => (view = "code")}
|
|
48
|
+
aria-pressed={view === "code"}
|
|
49
|
+
class="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs rounded-md text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800 aria-pressed:bg-zinc-100 dark:aria-pressed:bg-zinc-800 aria-pressed:text-zinc-900 dark:aria-pressed:text-zinc-100"
|
|
50
|
+
>
|
|
51
|
+
<Code2 class="w-3.5 h-3.5" />
|
|
52
|
+
{t("mermaid.viewCode")}
|
|
53
|
+
</button>
|
|
54
|
+
</div>
|
|
55
|
+
{/snippet}
|
|
56
|
+
|
|
57
|
+
<div class="w-full h-full overflow-auto flex items-center justify-center">
|
|
58
|
+
{#if view === "graph"}
|
|
59
|
+
<!-- eslint-disable-next-line -- trusted mermaid render output -->
|
|
60
|
+
<div class="mermaid-container mermaid-modal-graph">{@html svg}</div>
|
|
61
|
+
{:else}
|
|
62
|
+
<pre
|
|
63
|
+
class="w-full h-full m-0 p-4 text-xs leading-relaxed font-mono text-zinc-800 dark:text-zinc-200 bg-zinc-50 dark:bg-zinc-950 rounded-lg overflow-auto whitespace-pre"
|
|
64
|
+
>{source}</pre>
|
|
65
|
+
{/if}
|
|
66
|
+
</div>
|
|
67
|
+
</Dialog>
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Copy } from "lucide-svelte";
|
|
3
|
+
import { app } from "../stores/app.svelte";
|
|
4
|
+
import { t } from "../stores/locale.svelte";
|
|
5
|
+
import Button from "./ui/Button.svelte";
|
|
6
|
+
import Dialog from "./ui/Dialog.svelte";
|
|
7
|
+
import Text from "./ui/Text.svelte";
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
open: boolean;
|
|
11
|
+
onclose: () => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let { open = $bindable(false), onclose }: Props = $props();
|
|
15
|
+
|
|
16
|
+
type ModalState =
|
|
17
|
+
| { status: "idle" }
|
|
18
|
+
| { status: "loading" }
|
|
19
|
+
| { status: "error"; error: string }
|
|
20
|
+
| { status: "empty"; path: string }
|
|
21
|
+
| { status: "success"; content: string; path: string };
|
|
22
|
+
|
|
23
|
+
let modalState = $state<ModalState>({ status: "idle" });
|
|
24
|
+
|
|
25
|
+
$effect(() => {
|
|
26
|
+
if (!open) {
|
|
27
|
+
modalState = { status: "idle" };
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
modalState = { status: "loading" };
|
|
32
|
+
|
|
33
|
+
const query = app.activeDocumentPath
|
|
34
|
+
? `?path=${encodeURIComponent(app.activeDocumentPath)}`
|
|
35
|
+
: "";
|
|
36
|
+
|
|
37
|
+
fetch(`/api/comments/raw${query}`)
|
|
38
|
+
.then((response) => {
|
|
39
|
+
if (!response.ok) {
|
|
40
|
+
throw new Error("Failed to fetch raw comments");
|
|
41
|
+
}
|
|
42
|
+
return response.json();
|
|
43
|
+
})
|
|
44
|
+
.then((result) => {
|
|
45
|
+
if (result.content === null) {
|
|
46
|
+
modalState = { status: "empty", path: result.path };
|
|
47
|
+
} else {
|
|
48
|
+
modalState = {
|
|
49
|
+
status: "success",
|
|
50
|
+
content: result.content,
|
|
51
|
+
path: result.path,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
.catch((err) => {
|
|
56
|
+
modalState = {
|
|
57
|
+
status: "error",
|
|
58
|
+
error: err instanceof Error ? err.message : "Unknown error",
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
async function handleCopy() {
|
|
64
|
+
if (modalState.status !== "success") return;
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
await navigator.clipboard.writeText(modalState.content);
|
|
68
|
+
} catch {}
|
|
69
|
+
}
|
|
70
|
+
</script>
|
|
71
|
+
|
|
72
|
+
<Dialog bind:open {onclose} contentClass="max-w-2xl max-h-[80vh]">
|
|
73
|
+
{#snippet header()}
|
|
74
|
+
{t("rawModal.title")}
|
|
75
|
+
{/snippet}
|
|
76
|
+
|
|
77
|
+
{#snippet headerActions()}
|
|
78
|
+
{#if modalState.status === "success"}
|
|
79
|
+
<Button
|
|
80
|
+
variant="ghost"
|
|
81
|
+
size="icon"
|
|
82
|
+
class="size-7"
|
|
83
|
+
onclick={handleCopy}
|
|
84
|
+
title={t("rawModal.copyTitle")}
|
|
85
|
+
>
|
|
86
|
+
<Copy class="w-4 h-4" />
|
|
87
|
+
</Button>
|
|
88
|
+
{/if}
|
|
89
|
+
{/snippet}
|
|
90
|
+
|
|
91
|
+
{#if modalState.status === "success" || modalState.status === "empty"}
|
|
92
|
+
<div
|
|
93
|
+
class="px-4 py-2 border-b border-zinc-50 dark:border-zinc-800 text-xs text-zinc-400 dark:text-zinc-500 font-mono truncate -mt-4 -mx-4 mb-4"
|
|
94
|
+
>
|
|
95
|
+
{modalState.path}
|
|
96
|
+
</div>
|
|
97
|
+
{/if}
|
|
98
|
+
|
|
99
|
+
{#if modalState.status === "loading"}
|
|
100
|
+
<Text variant="caption" class="text-center py-8">
|
|
101
|
+
{t("rawModal.loading")}
|
|
102
|
+
</Text>
|
|
103
|
+
{/if}
|
|
104
|
+
|
|
105
|
+
{#if modalState.status === "error"}
|
|
106
|
+
<Text variant="body" class="text-red-500 text-center py-8">
|
|
107
|
+
{modalState.error}
|
|
108
|
+
</Text>
|
|
109
|
+
{/if}
|
|
110
|
+
|
|
111
|
+
{#if modalState.status === "empty"}
|
|
112
|
+
<Text variant="caption" class="text-center py-8">
|
|
113
|
+
{t("rawModal.noComments")}
|
|
114
|
+
</Text>
|
|
115
|
+
{/if}
|
|
116
|
+
|
|
117
|
+
{#if modalState.status === "success"}
|
|
118
|
+
<Text
|
|
119
|
+
variant="body"
|
|
120
|
+
as="pre"
|
|
121
|
+
class="text-xs font-mono whitespace-pre-wrap break-words leading-relaxed"
|
|
122
|
+
>
|
|
123
|
+
{modalState.content}
|
|
124
|
+
</Text>
|
|
125
|
+
{/if}
|
|
126
|
+
</Dialog>
|