@lapis-notes/lapis-plugin-docs 0.0.0-stage → 0.1.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.
Files changed (196) hide show
  1. package/README.md +188 -2
  2. package/dist/docs/DocsBlockMarkdownEditor.svelte +37 -0
  3. package/dist/docs/DocsBlockMarkdownEditor.svelte.d.ts +13 -0
  4. package/dist/docs/DocsBlockRefPicker.svelte +75 -0
  5. package/dist/docs/DocsBlockRefPicker.svelte.d.ts +11 -0
  6. package/dist/docs/DocsBookEditor.svelte +141 -0
  7. package/dist/docs/DocsBookEditor.svelte.d.ts +10 -0
  8. package/dist/docs/DocsBookNavigation.svelte +838 -0
  9. package/dist/docs/DocsBookNavigation.svelte.d.ts +11 -0
  10. package/dist/docs/DocsBooksColumn.svelte +82 -0
  11. package/dist/docs/DocsBooksColumn.svelte.d.ts +10 -0
  12. package/dist/docs/DocsCardBackMatter.svelte +32 -0
  13. package/dist/docs/DocsCardBackMatter.svelte.d.ts +10 -0
  14. package/dist/docs/DocsCardBlockExtra.svelte +43 -0
  15. package/dist/docs/DocsCardBlockExtra.svelte.d.ts +9 -0
  16. package/dist/docs/DocsCardPreviewFlip.svelte +143 -0
  17. package/dist/docs/DocsCardPreviewFlip.svelte.d.ts +17 -0
  18. package/dist/docs/DocsCardPreviewStack.svelte +135 -0
  19. package/dist/docs/DocsCardPreviewStack.svelte.d.ts +13 -0
  20. package/dist/docs/DocsCardPrompt.svelte +33 -0
  21. package/dist/docs/DocsCardPrompt.svelte.d.ts +8 -0
  22. package/dist/docs/DocsCardReviewAnswer.svelte +45 -0
  23. package/dist/docs/DocsCardReviewAnswer.svelte.d.ts +8 -0
  24. package/dist/docs/DocsCardTagChips.svelte +19 -0
  25. package/dist/docs/DocsCardTagChips.svelte.d.ts +7 -0
  26. package/dist/docs/DocsCardsPanel.svelte +232 -0
  27. package/dist/docs/DocsCardsPanel.svelte.d.ts +17 -0
  28. package/dist/docs/DocsCardsReview.css +1050 -0
  29. package/dist/docs/DocsChapterFooter.svelte +248 -0
  30. package/dist/docs/DocsChapterFooter.svelte.d.ts +11 -0
  31. package/dist/docs/DocsChapterScrollRing.svelte +101 -0
  32. package/dist/docs/DocsChapterScrollRing.svelte.d.ts +6 -0
  33. package/dist/docs/DocsDisplayPanel.css +627 -0
  34. package/dist/docs/DocsDisplayPanel.svelte +78 -0
  35. package/dist/docs/DocsDisplayPanel.svelte.d.ts +16 -0
  36. package/dist/docs/DocsEditorPanel.css +325 -0
  37. package/dist/docs/DocsEditorPanel.svelte +570 -0
  38. package/dist/docs/DocsEditorPanel.svelte.d.ts +22 -0
  39. package/dist/docs/DocsFlipCard.svelte +68 -0
  40. package/dist/docs/DocsFlipCard.svelte.d.ts +8 -0
  41. package/dist/docs/DocsFolderDirectoryList.svelte +206 -0
  42. package/dist/docs/DocsFolderDirectoryList.svelte.d.ts +14 -0
  43. package/dist/docs/DocsFolderPanel.css +357 -0
  44. package/dist/docs/DocsFolderPanel.svelte +102 -0
  45. package/dist/docs/DocsFolderPanel.svelte.d.ts +17 -0
  46. package/dist/docs/DocsLinksPanel.css +61 -0
  47. package/dist/docs/DocsLinksPanel.svelte +55 -0
  48. package/dist/docs/DocsLinksPanel.svelte.d.ts +11 -0
  49. package/dist/docs/DocsMarkdownField.svelte +93 -0
  50. package/dist/docs/DocsMarkdownField.svelte.d.ts +11 -0
  51. package/dist/docs/DocsMarkdownPreview.svelte +278 -0
  52. package/dist/docs/DocsMarkdownPreview.svelte.d.ts +17 -0
  53. package/dist/docs/DocsReviewPanel.svelte +63 -0
  54. package/dist/docs/DocsReviewPanel.svelte.d.ts +11 -0
  55. package/dist/docs/DocsRichBlock.svelte +291 -0
  56. package/dist/docs/DocsRichBlock.svelte.d.ts +13 -0
  57. package/dist/docs/DocsRichDisplay.svelte +316 -0
  58. package/dist/docs/DocsRichDisplay.svelte.d.ts +14 -0
  59. package/dist/docs/DocsSlidesPreview.css +243 -0
  60. package/dist/docs/DocsSlidesPreview.svelte +206 -0
  61. package/dist/docs/DocsSlidesPreview.svelte.d.ts +10 -0
  62. package/dist/docs/DocsWorkspace.css +570 -0
  63. package/dist/docs/DocsWorkspace.svelte +831 -0
  64. package/dist/docs/DocsWorkspace.svelte.d.ts +48 -0
  65. package/dist/docs/DocsWorkspaceToolbar.svelte +211 -0
  66. package/dist/docs/DocsWorkspaceToolbar.svelte.d.ts +27 -0
  67. package/dist/docs/candidates/ChoiceMenu.svelte +114 -0
  68. package/dist/docs/candidates/ChoiceMenu.svelte.d.ts +10 -0
  69. package/dist/docs/candidates/FlipCardDeck.svelte +800 -0
  70. package/dist/docs/candidates/FlipCardDeck.svelte.d.ts +36 -0
  71. package/dist/docs/candidates/MarkdownFloatingToc.svelte +313 -0
  72. package/dist/docs/candidates/MarkdownFloatingToc.svelte.d.ts +14 -0
  73. package/dist/docs/candidates/SearchableChoicePicker.css +161 -0
  74. package/dist/docs/candidates/SearchableChoicePicker.svelte +208 -0
  75. package/dist/docs/candidates/SearchableChoicePicker.svelte.d.ts +19 -0
  76. package/dist/docs/candidates/StructuredTable.svelte +98 -0
  77. package/dist/docs/candidates/StructuredTable.svelte.d.ts +8 -0
  78. package/dist/docs/candidates/StructuredTableEditor.svelte +374 -0
  79. package/dist/docs/candidates/StructuredTableEditor.svelte.d.ts +12 -0
  80. package/dist/docs/candidates/TagEditor.svelte +31 -0
  81. package/dist/docs/candidates/TagEditor.svelte.d.ts +14 -0
  82. package/dist/docs/candidates/WorkspaceHistoryControls.svelte +112 -0
  83. package/dist/docs/candidates/WorkspaceHistoryControls.svelte.d.ts +8 -0
  84. package/dist/docs/candidates/markdown-floating-toc.css +151 -0
  85. package/dist/docs/candidates/markdown-floating-toc.d.ts +1 -0
  86. package/dist/docs/candidates/markdown-floating-toc.js +1 -0
  87. package/dist/docs/docs-anki-import.d.ts +34 -0
  88. package/dist/docs/docs-anki-import.js +516 -0
  89. package/dist/docs/docs-book-view-model.d.ts +9 -0
  90. package/dist/docs/docs-book-view-model.js +71 -0
  91. package/dist/docs/docs-card-answer-markdown.d.ts +1 -0
  92. package/dist/docs/docs-card-answer-markdown.js +63 -0
  93. package/dist/docs/docs-card-display.d.ts +10 -0
  94. package/dist/docs/docs-card-display.js +18 -0
  95. package/dist/docs/docs-display-focus.d.ts +7 -0
  96. package/dist/docs/docs-display-focus.js +42 -0
  97. package/dist/docs/docs-document-surface.d.ts +7 -0
  98. package/dist/docs/docs-document-surface.js +25 -0
  99. package/dist/docs/docs-folder-directory.d.ts +21 -0
  100. package/dist/docs/docs-folder-directory.js +56 -0
  101. package/dist/docs/docs-link-mode.d.ts +16 -0
  102. package/dist/docs/docs-link-mode.js +94 -0
  103. package/dist/docs/docs-workspace-helpers.d.ts +25 -0
  104. package/dist/docs/docs-workspace-helpers.js +195 -0
  105. package/dist/docs/docs.tokens.css +13 -0
  106. package/dist/docs/import-anki-package.d.ts +10 -0
  107. package/dist/docs/import-anki-package.js +30 -0
  108. package/dist/docs/lapis-markdown-extensions.d.ts +9 -0
  109. package/dist/docs/lapis-markdown-extensions.js +128 -0
  110. package/dist/docs/pin-reading-chrome.d.ts +4 -0
  111. package/dist/docs/pin-reading-chrome.js +32 -0
  112. package/dist/docs/sidebar/DocsFoldersSidebar.svelte +134 -0
  113. package/dist/docs/sidebar/DocsFoldersSidebar.svelte.d.ts +15 -0
  114. package/dist/docs/sidebar/DocsLinksSidebar.svelte +105 -0
  115. package/dist/docs/sidebar/DocsLinksSidebar.svelte.d.ts +11 -0
  116. package/dist/docs/sidebar/DocsSidebar.css +346 -0
  117. package/dist/docs/sidebar/DocsSidebar.svelte +430 -0
  118. package/dist/docs/sidebar/DocsSidebar.svelte.d.ts +18 -0
  119. package/dist/docs/sidebar/DocsSidebarHost.svelte +139 -0
  120. package/dist/docs/sidebar/DocsSidebarHost.svelte.d.ts +12 -0
  121. package/dist/docs/sidebar/DocsTagsSidebar.svelte +176 -0
  122. package/dist/docs/sidebar/DocsTagsSidebar.svelte.d.ts +12 -0
  123. package/dist/docs/sidebar/docs-book-note-tree.d.ts +23 -0
  124. package/dist/docs/sidebar/docs-book-note-tree.js +88 -0
  125. package/dist/docs/sidebar/docs-note-sort.d.ts +26 -0
  126. package/dist/docs/sidebar/docs-note-sort.js +54 -0
  127. package/dist/docs/sidebar/docs-sidebar-filters.d.ts +17 -0
  128. package/dist/docs/sidebar/docs-sidebar-filters.js +74 -0
  129. package/dist/docs/sidebar/docs-sidebar-ui-storage.d.ts +24 -0
  130. package/dist/docs/sidebar/docs-sidebar-ui-storage.js +87 -0
  131. package/dist/docs/sidebar/docs-source-folder-tree.d.ts +16 -0
  132. package/dist/docs/sidebar/docs-source-folder-tree.js +143 -0
  133. package/dist/docs/sidebar/docs-tag-tree.d.ts +17 -0
  134. package/dist/docs/sidebar/docs-tag-tree.js +84 -0
  135. package/dist/docs-cards-view.d.ts +18 -0
  136. package/dist/docs-cards-view.js +52 -0
  137. package/dist/docs-document-view.d.ts +24 -0
  138. package/dist/docs-document-view.js +127 -0
  139. package/dist/docs-document-workspace.svelte +215 -0
  140. package/dist/docs-document-workspace.svelte.d.ts +16 -0
  141. package/dist/docs-index.d.ts +5 -0
  142. package/dist/docs-index.js +49 -0
  143. package/dist/docs-path.d.ts +6 -0
  144. package/dist/docs-path.js +10 -0
  145. package/dist/docs-plugin.d.ts +21 -0
  146. package/dist/docs-plugin.js +140 -0
  147. package/dist/docs-repository.d.ts +15 -0
  148. package/dist/docs-repository.js +75 -0
  149. package/dist/docs-review-view.d.ts +18 -0
  150. package/dist/docs-review-view.js +52 -0
  151. package/dist/docs-settings-tab.d.ts +8 -0
  152. package/dist/docs-settings-tab.js +25 -0
  153. package/dist/docs-settings.d.ts +17 -0
  154. package/dist/docs-settings.js +38 -0
  155. package/dist/docs-view.d.ts +22 -0
  156. package/dist/docs-view.js +51 -0
  157. package/dist/docs-workspace.svelte +394 -0
  158. package/dist/docs-workspace.svelte.d.ts +15 -0
  159. package/dist/index.d.ts +12 -0
  160. package/dist/index.js +12 -0
  161. package/dist/model/book.d.ts +6 -0
  162. package/dist/model/book.js +101 -0
  163. package/dist/model/coding-runtime.d.ts +4 -0
  164. package/dist/model/coding-runtime.js +9 -0
  165. package/dist/model/docs-card-context.d.ts +32 -0
  166. package/dist/model/docs-card-context.js +89 -0
  167. package/dist/model/docs-card-preview-stack-motion.d.ts +51 -0
  168. package/dist/model/docs-card-preview-stack-motion.js +94 -0
  169. package/dist/model/docs-media-context.d.ts +9 -0
  170. package/dist/model/docs-media-context.js +18 -0
  171. package/dist/model/docs-note-list.d.ts +8 -0
  172. package/dist/model/docs-note-list.js +25 -0
  173. package/dist/model/docs-relative-media.d.ts +30 -0
  174. package/dist/model/docs-relative-media.js +116 -0
  175. package/dist/model/docs-review-constants.d.ts +4 -0
  176. package/dist/model/docs-review-constants.js +13 -0
  177. package/dist/model/fsrs.d.ts +14 -0
  178. package/dist/model/fsrs.js +153 -0
  179. package/dist/model/graph.d.ts +12 -0
  180. package/dist/model/graph.js +169 -0
  181. package/dist/model/index.d.ts +14 -0
  182. package/dist/model/index.js +14 -0
  183. package/dist/model/markdown-normalize.d.ts +11 -0
  184. package/dist/model/markdown-normalize.js +755 -0
  185. package/dist/model/markdown.d.ts +167 -0
  186. package/dist/model/markdown.js +365 -0
  187. package/dist/model/sample.d.ts +7 -0
  188. package/dist/model/sample.js +524 -0
  189. package/dist/model/types.d.ts +328 -0
  190. package/dist/model/types.js +1 -0
  191. package/dist/model/workspace.d.ts +6 -0
  192. package/dist/model/workspace.js +81 -0
  193. package/dist/storybook.css +52 -0
  194. package/dist/styles.css +1 -0
  195. package/manifest.json +11 -0
  196. package/package.json +147 -4
@@ -0,0 +1,831 @@
1
+ <script lang="ts">
2
+ import './DocsWorkspace.css';
3
+ import './DocsCardsReview.css';
4
+ import AlertCircleIcon from '@lucide/svelte/icons/alert-circle';
5
+ import PlusIcon from '@lucide/svelte/icons/plus';
6
+ import { onDestroy } from 'svelte';
7
+ import type { Snippet } from 'svelte';
8
+ import { normalizeDateOnly as normalizeTaskDateOnly } from "@lapismd/design-core/forms";
9
+ import DocsCardsPanel from './DocsCardsPanel.svelte';
10
+ import DocsDisplayPanel from './DocsDisplayPanel.svelte';
11
+ import DocsEditorPanel from './DocsEditorPanel.svelte';
12
+ import DocsFolderPanel from './DocsFolderPanel.svelte';
13
+ import DocsReviewPanel from './DocsReviewPanel.svelte';
14
+ import DocsWorkspaceToolbar from './DocsWorkspaceToolbar.svelte';
15
+
16
+ import { YamlEditor, type YamlReviewDiff } from '@lapismd/design-core/forms';
17
+ import * as ScrollArea from "@lapismd/design-core/shadcn/scroll-area";
18
+
19
+ import { fsrsCardFromState, fsrsStateFromCard } from '../model/fsrs';
20
+ import { docsBookWorkspaceView } from './docs-book-view-model';
21
+ import { openDocsContentLink, preservePreviewModeForDocsLink } from './docs-link-mode';
22
+ import { setDocsMediaResolver } from '../model/docs-media-context';
23
+ import { serverDocsMediaUrl } from '../model/docs-relative-media';
24
+
25
+ import {
26
+ docsNoteToMarkdown,
27
+ parseDocsNoteMarkdownFile,
28
+ renderDocsNoteMarkdown,
29
+ slugifyDocsId
30
+ } from '../model/markdown';
31
+ import {
32
+ cloneDocsNote,
33
+ createDocsBlockFromType,
34
+ createEmptyDocsBlock,
35
+ type DisplayMode
36
+ } from './docs-workspace-helpers';
37
+ import { docsNoteListKey, isDocsNoteSummary } from '../model/docs-note-list';
38
+ import type {
39
+ CreateDocsNoteInput,
40
+ DocsCard,
41
+ DocsNote,
42
+ DocsNotePanel,
43
+ DocsRevisionBlock,
44
+ DocsReviewRating,
45
+ DocsReviewScope,
46
+ DocsWorkspace,
47
+ DocsWorkspaceIndex
48
+ } from '../model';
49
+
50
+ type Props = {
51
+ workspace: DocsWorkspace | DocsWorkspaceIndex | null;
52
+ /** Full note for the active id; index rows are summaries only. */
53
+ activeNote?: DocsNote | null;
54
+ activeNoteId?: string | null;
55
+ activePanel?: DocsNotePanel;
56
+ activeCodingCardId?: string | null;
57
+ reviewScope?: DocsReviewScope;
58
+ previewMode?: boolean;
59
+ sidebarOpen?: boolean;
60
+ activeFolderTag?: string | null;
61
+ folderDisplayMode?: "panel" | "inline";
62
+ folderPanelCollapsed?: boolean;
63
+ loading?: boolean;
64
+ saving?: boolean;
65
+ error?: string | null;
66
+ onToggleSidebar?: () => void | Promise<void>;
67
+ onActiveNoteChange?: (noteId: string | null) => void;
68
+ onActivePanelChange?: (panel: DocsNotePanel) => void;
69
+ onActiveCodingCardChange?: (cardId: string | null) => void;
70
+ onReviewScopeChange?: (scope: DocsReviewScope) => void;
71
+ onPreviewModeChange?: (enabled: boolean) => void;
72
+ onActiveFolderTagChange?: (tag: string | null) => void;
73
+ onFolderPanelCollapsedChange?: (collapsed: boolean) => void;
74
+ onCreateNote?: (input?: CreateDocsNoteInput) => void | Promise<void>;
75
+ onSaveNote?: (note: DocsNote) => void | Promise<void>;
76
+ onDeleteNote?: (noteId: string) => void | Promise<void>;
77
+ onReviewCard?: (noteId: string, cardId: string, rating: DocsReviewRating) => void | Promise<void>;
78
+ onImportAnkiPackage?: (file: File) => void | Promise<void>;
79
+ onUploadAttachment?: (noteId: string, file: File) => Promise<string | null>;
80
+ /** Runtime media URL resolver for co-located docs images. */
81
+ resolveMediaUrl?: (relativePath: string) => string | Promise<string>;
82
+ loadingState?: Snippet;
83
+ emptyState?: Snippet<[() => void]>;
84
+ codingPractice?: Snippet<[DocsNote, DocsCard | null, (card: DocsCard) => void]>;
85
+ reviewContent?: Snippet;
86
+ reviewDiffs?: YamlReviewDiff[];
87
+ onUndoReview?: (id: string) => void;
88
+ onKeepReview?: (id: string) => void;
89
+ };
90
+
91
+ let {
92
+ workspace,
93
+ activeNote = null,
94
+ activeNoteId = null,
95
+ activePanel = "note",
96
+ activeCodingCardId = null,
97
+ reviewScope = "note",
98
+ previewMode = false,
99
+ activeFolderTag = null,
100
+ folderDisplayMode = "panel",
101
+ folderPanelCollapsed = false,
102
+ loading = false,
103
+ saving = false,
104
+ error = null,
105
+ onActiveNoteChange = () => {},
106
+ onActivePanelChange = () => {},
107
+ onActiveCodingCardChange = () => {},
108
+ onReviewScopeChange = () => {},
109
+ onPreviewModeChange = () => {},
110
+ onActiveFolderTagChange = () => {},
111
+ onFolderPanelCollapsedChange = () => {},
112
+ onCreateNote = () => {},
113
+ onSaveNote = () => {},
114
+ onDeleteNote = () => {},
115
+ onReviewCard = () => {},
116
+ onImportAnkiPackage,
117
+ onUploadAttachment,
118
+ resolveMediaUrl = serverDocsMediaUrl,
119
+ loadingState,
120
+ emptyState,
121
+ codingPractice,
122
+ reviewContent,
123
+ reviewDiffs = [],
124
+ onUndoReview,
125
+ onKeepReview,
126
+ }: Props = $props();
127
+
128
+ setDocsMediaResolver((relativePath) => resolveMediaUrl(relativePath));
129
+ let draftNote = $state<DocsNote | null>(null);
130
+ let draftKey = $state("");
131
+ let filenameDraft = $state("");
132
+ let blockOpenById = $state<Record<string, boolean>>({});
133
+ let cardOpenById = $state<Record<string, boolean>>({});
134
+ let cardBlockAddOpenById = $state<Record<string, boolean>>({});
135
+ let propertiesOpen = $state(false);
136
+ let displayMode = $state<DisplayMode>("document");
137
+ let reviewAnswerOpenById = $state<Record<string, boolean>>({});
138
+ let yamlMode = $state(false);
139
+ let yamlDraft = $state("");
140
+ let yamlError = $state("");
141
+ let saveTimer: ReturnType<typeof setTimeout> | null = null;
142
+ let trackedNoteId = $state<string | null>(null);
143
+ let deletingNoteId = $state<string | null>(null);
144
+
145
+ const DOCS_AUTOSAVE_MS = 1200;
146
+
147
+ const notes = $derived(workspace?.notes ?? []);
148
+ const selectedNote = $derived.by(() => {
149
+ if (activeNote && (!activeNoteId || activeNote.id === activeNoteId)) {
150
+ return activeNote;
151
+ }
152
+ const candidate =
153
+ notes.find((note) => note.id === activeNoteId) ?? notes[0] ?? null;
154
+ if (!candidate || isDocsNoteSummary(candidate)) return null;
155
+ return candidate;
156
+ });
157
+ // Initial workspace load, or lazy full-note fetch while the index is already present.
158
+ const showLoadingState = $derived(
159
+ loading &&
160
+ (!workspace ||
161
+ (activePanel !== "review" && Boolean(activeNoteId) && !selectedNote)),
162
+ );
163
+ const selectedNoteKey = $derived(selectedNote ? docsNoteListKey(selectedNote) : "");
164
+ const noteReviewQueue = $derived(
165
+ workspace?.reviewQueue.filter((item) => item.noteId === draftNote?.id) ??
166
+ [],
167
+ );
168
+ const activeReviewQueue = $derived(
169
+ reviewScope === "workspace"
170
+ ? (workspace?.reviewQueue ?? [])
171
+ : noteReviewQueue,
172
+ );
173
+ const activeCodingCard = $derived(
174
+ draftNote?.cards.find(
175
+ (card) => card.type === "coding" && card.id === activeCodingCardId,
176
+ ) ?? draftNote?.cards.find((card) => card.type === "coding"),
177
+ );
178
+ function getNoteBlocks(noteId: string): DocsRevisionBlock[] {
179
+ if (draftNote?.id === noteId) return draftNote.blocks;
180
+ if (activeNote?.id === noteId) return activeNote.blocks;
181
+ return [];
182
+ }
183
+ const tagSuggestions = $derived(workspace?.tags.map((tag) => tag.tag) ?? []);
184
+ const aliasSuggestions = $derived(
185
+ Array.from(
186
+ new Set(
187
+ (workspace?.notes ?? [])
188
+ .flatMap((note) => note.aliases)
189
+ .filter(Boolean),
190
+ ),
191
+ ),
192
+ );
193
+ const bookWorkspaceView = $derived(
194
+ docsBookWorkspaceView(
195
+ workspace,
196
+ notes,
197
+ activeNoteId ?? draftNote?.id ?? null,
198
+ draftNote,
199
+ ),
200
+ );
201
+ const draftSerialized = $derived(draftNote ? JSON.stringify(draftNote) : "");
202
+ const selectedSerialized = $derived(
203
+ selectedNote ? JSON.stringify(selectedNote) : "",
204
+ );
205
+ const draftDirty = $derived(
206
+ Boolean(draftNote && draftSerialized !== selectedSerialized),
207
+ );
208
+ const renderedDocumentMarkdown = $derived(
209
+ draftNote ? renderDocsNoteMarkdown(draftNote) : "",
210
+ );
211
+ const renderedSlidesMarkdown = $derived(
212
+ draftNote ? renderDocsNoteMarkdown(draftNote, { mode: "slides" }) : "",
213
+ );
214
+ const renderedDisplayMarkdown = $derived(
215
+ displayMode === "slides"
216
+ ? renderedSlidesMarkdown
217
+ : renderedDocumentMarkdown,
218
+ );
219
+ const hasCollapsedEditSections = $derived(
220
+ Boolean(
221
+ draftNote &&
222
+ (!propertiesOpen ||
223
+ draftNote.blocks.some((block) => blockOpenById[block.id] === false)),
224
+ ),
225
+ );
226
+ const hasEditCollapseTarget = $derived(Boolean(draftNote));
227
+
228
+ $effect(() => {
229
+ const noteId = activeNoteId ?? selectedNote?.id ?? null;
230
+ if (trackedNoteId !== null && trackedNoteId !== noteId) {
231
+ void flushPendingSave();
232
+ }
233
+ trackedNoteId = noteId;
234
+ });
235
+
236
+ $effect(() => {
237
+ if (!selectedNote) {
238
+ draftNote = null;
239
+ draftKey = "";
240
+ filenameDraft = "";
241
+ yamlMode = false;
242
+ yamlDraft = "";
243
+ yamlError = "";
244
+ blockOpenById = {};
245
+ return;
246
+ }
247
+ if (selectedNote.id === deletingNoteId) return;
248
+ if (draftKey === selectedNoteKey) return;
249
+ draftNote = cloneDocsNote(selectedNote);
250
+ draftKey = selectedNoteKey;
251
+ filenameDraft = selectedNote.id;
252
+ blockOpenById = Object.fromEntries(
253
+ selectedNote.blocks.map((block, index) => [block.id, index === 0]),
254
+ );
255
+ cardOpenById = Object.fromEntries(
256
+ selectedNote.cards.map((card, index) => [card.id, index === 0]),
257
+ );
258
+ propertiesOpen = false;
259
+ if (yamlMode) {
260
+ yamlDraft = docsNoteToMarkdown(selectedNote);
261
+ yamlError = "";
262
+ }
263
+ });
264
+
265
+ $effect(() => {
266
+ if (!draftNote || yamlMode || !draftDirty) return;
267
+ scheduleDraftSave();
268
+ });
269
+
270
+ $effect(() => {
271
+ activePanel;
272
+ reviewScope;
273
+ reviewAnswerOpenById = {};
274
+ });
275
+
276
+ $effect(() => {
277
+ if (activePanel !== "practice") return;
278
+ const selectedCardId = activeCodingCard?.id ?? null;
279
+ if (selectedCardId !== activeCodingCardId) onActiveCodingCardChange(selectedCardId);
280
+ });
281
+
282
+ onDestroy(() => {
283
+ void flushPendingSave();
284
+ });
285
+
286
+ function scheduleDraftSave(delay = DOCS_AUTOSAVE_MS) {
287
+ if (yamlError) return;
288
+ if (saveTimer) clearTimeout(saveTimer);
289
+ saveTimer = setTimeout(() => {
290
+ saveTimer = null;
291
+ void saveDraft();
292
+ }, delay);
293
+ }
294
+
295
+ async function flushPendingSave() {
296
+ if (saveTimer) {
297
+ clearTimeout(saveTimer);
298
+ saveTimer = null;
299
+ }
300
+ if (!draftNote || !draftDirty) return;
301
+ await saveDraft();
302
+ }
303
+
304
+ function cancelPendingSave() {
305
+ if (!saveTimer) return;
306
+ clearTimeout(saveTimer);
307
+ saveTimer = null;
308
+ }
309
+
310
+ function setYamlMode(enabled: boolean) {
311
+ if (enabled) {
312
+ if (!draftNote) return;
313
+ yamlDraft = docsNoteToMarkdown(draftNote);
314
+ yamlError = "";
315
+ yamlMode = true;
316
+ return;
317
+ }
318
+ yamlMode = false;
319
+ }
320
+
321
+ function updateYamlDraft(value: string) {
322
+ yamlDraft = value;
323
+ if (!draftNote) return;
324
+ try {
325
+ const parsed = parseDocsNoteMarkdownFile({
326
+ path: draftNote.sourcePath,
327
+ content: value,
328
+ });
329
+ yamlError = "";
330
+ draftNote = {
331
+ ...parsed,
332
+ id: draftNote.id,
333
+ sourcePath: draftNote.sourcePath,
334
+ };
335
+ draftKey = `${draftNote.id}:${draftNote.updatedAt}:${draftNote.completedAt ?? ""}:${draftNote.blocks.length}:${draftNote.cards.length}:${draftNote.body.length}`;
336
+ scheduleDraftSave();
337
+ } catch (error) {
338
+ yamlError = error instanceof Error ? error.message : String(error);
339
+ }
340
+ }
341
+
342
+ function updateDraft(updater: (note: DocsNote) => DocsNote) {
343
+ if (!draftNote) return;
344
+ draftNote = updater(cloneDocsNote(draftNote));
345
+ }
346
+
347
+ function setDraftField<K extends keyof DocsNote>(key: K, value: DocsNote[K]) {
348
+ updateDraft((note) => ({ ...note, [key]: value }));
349
+ }
350
+
351
+ function uniqueDraftBlockId(base: string, fallback = "block") {
352
+ const existingIds = new Set(
353
+ draftNote?.blocks.map((block) => block.id) ?? [],
354
+ );
355
+ const slug = slugifyDocsId(base, fallback);
356
+ const prefix = slug.startsWith("block-") ? slug : `block-${slug}`;
357
+ let id = prefix;
358
+ let suffix = 2;
359
+ while (existingIds.has(id)) id = `${prefix}-${suffix++}`;
360
+ return id;
361
+ }
362
+
363
+ function blockFromType(
364
+ type: DocsRevisionBlock["type"],
365
+ previous?: DocsRevisionBlock,
366
+ title = "",
367
+ ) {
368
+ const block = createDocsBlockFromType(
369
+ type,
370
+ previous?.id ?? uniqueDraftBlockId(title || type),
371
+ previous,
372
+ );
373
+ const trimmedTitle = title.trim();
374
+ if (!trimmedTitle) return block;
375
+ return { ...block, title: trimmedTitle } as DocsRevisionBlock;
376
+ }
377
+
378
+ function updateBlock(
379
+ blockId: string,
380
+ updater: (block: DocsRevisionBlock) => DocsRevisionBlock,
381
+ ) {
382
+ updateDraft((note) => ({
383
+ ...note,
384
+ blocks: note.blocks.map((block) =>
385
+ block.id === blockId ? updater(block) : block,
386
+ ),
387
+ }));
388
+ }
389
+
390
+ function patchBlock(blockId: string, patch: Record<string, unknown>) {
391
+ updateBlock(
392
+ blockId,
393
+ (block) => ({ ...block, ...patch }) as DocsRevisionBlock,
394
+ );
395
+ }
396
+
397
+ function updateBlockTitle(blockId: string, title: string) {
398
+ updateBlock(blockId, (block) => {
399
+ const next = { ...block };
400
+ const trimmed = title.trim();
401
+ if (trimmed) next.title = title;
402
+ else delete next.title;
403
+ return next;
404
+ });
405
+ }
406
+
407
+ function updateBlockId(blockId: string, rawValue: string) {
408
+ const nextId = slugifyDocsId(rawValue, blockId);
409
+ if (!nextId || nextId === blockId) return;
410
+ if (draftNote?.blocks.some((block) => block.id === nextId)) return;
411
+ updateDraft((note) => ({
412
+ ...note,
413
+ blocks: note.blocks.map((block) =>
414
+ block.id === blockId ? { ...block, id: nextId } : block,
415
+ ),
416
+ cards: note.cards.map((card) => ({
417
+ ...card,
418
+ blockRefs: card.blockRefs.map((ref) =>
419
+ ref === blockId ? nextId : ref,
420
+ ),
421
+ })),
422
+ }));
423
+ blockOpenById = {
424
+ ...Object.fromEntries(
425
+ Object.entries(blockOpenById).filter(([id]) => id !== blockId),
426
+ ),
427
+ [nextId]: blockOpenById[blockId] !== false,
428
+ };
429
+ }
430
+
431
+ function addBlock(type: DocsRevisionBlock["type"] = "concept", title = "") {
432
+ if (!draftNote) return;
433
+ const block = blockFromType(type, undefined, title);
434
+ updateDraft((note) => ({
435
+ ...note,
436
+ blocks: [...note.blocks, block],
437
+ }));
438
+ blockOpenById = { ...blockOpenById, [block.id]: true };
439
+ void flushPendingSave();
440
+ }
441
+
442
+ function removeBlock(blockId: string) {
443
+ updateDraft((note) => ({
444
+ ...note,
445
+ blocks: note.blocks.filter((block) => block.id !== blockId),
446
+ cards: note.cards.map((card) => ({
447
+ ...card,
448
+ blockRefs: card.blockRefs.filter((ref) => ref !== blockId),
449
+ })),
450
+ }));
451
+ void flushPendingSave();
452
+ }
453
+
454
+ function moveBlock(blockId: string, direction: -1 | 1) {
455
+ updateDraft((note) => {
456
+ const index = note.blocks.findIndex((block) => block.id === blockId);
457
+ const nextIndex = index + direction;
458
+ if (index < 0 || nextIndex < 0 || nextIndex >= note.blocks.length)
459
+ return note;
460
+ const blocks = [...note.blocks];
461
+ const [block] = blocks.splice(index, 1);
462
+ blocks.splice(nextIndex, 0, block);
463
+ return { ...note, blocks };
464
+ });
465
+ void flushPendingSave();
466
+ }
467
+
468
+ function toggleAllEditSections() {
469
+ if (!draftNote) return;
470
+ const open = hasCollapsedEditSections;
471
+ propertiesOpen = open;
472
+ blockOpenById = Object.fromEntries(
473
+ draftNote.blocks.map((block) => [block.id, open]),
474
+ );
475
+ }
476
+
477
+ async function copyRenderedMarkdown() {
478
+ if (typeof navigator === "undefined" || !navigator.clipboard) return;
479
+ await navigator.clipboard.writeText(renderedDisplayMarkdown);
480
+ }
481
+
482
+ function updateCard(cardId: string, updater: (card: DocsCard) => DocsCard) {
483
+ updateDraft((note) => ({
484
+ ...note,
485
+ cards: note.cards.map((card) =>
486
+ card.id === cardId ? updater(card) : card,
487
+ ),
488
+ }));
489
+ }
490
+
491
+ function replaceCodingCard(nextCard: DocsCard) {
492
+ updateCard(nextCard.id, () => nextCard);
493
+ }
494
+
495
+ function addCard() {
496
+ if (!draftNote) return;
497
+ const base = "New card";
498
+ const existingIds = new Set(draftNote.cards.map((card) => card.id));
499
+ let id = `card-${slugifyDocsId(base, "card")}`;
500
+ let suffix = 2;
501
+ while (existingIds.has(id))
502
+ id = `card-${slugifyDocsId(base, "card")}-${suffix++}`;
503
+ updateDraft((note) => ({
504
+ ...note,
505
+ cards: [
506
+ ...note.cards,
507
+ {
508
+ id,
509
+ type: "basic",
510
+ status: "active",
511
+ prompt: "",
512
+ answer: "",
513
+ tags: [],
514
+ blockRefs: [],
515
+ reviews: [],
516
+ },
517
+ ],
518
+ }));
519
+ cardOpenById = { ...cardOpenById, [id]: true };
520
+ void flushPendingSave();
521
+ }
522
+
523
+ function removeCard(cardId: string) {
524
+ updateDraft((note) => ({
525
+ ...note,
526
+ cards: note.cards.filter((card) => card.id !== cardId),
527
+ }));
528
+ void flushPendingSave();
529
+ }
530
+
531
+ function moveCard(cardId: string, direction: -1 | 1) {
532
+ updateDraft((note) => {
533
+ const index = note.cards.findIndex((card) => card.id === cardId);
534
+ const nextIndex = index + direction;
535
+ if (index < 0 || nextIndex < 0 || nextIndex >= note.cards.length)
536
+ return note;
537
+ const cards = [...note.cards];
538
+ const [card] = cards.splice(index, 1);
539
+ cards.splice(nextIndex, 0, card);
540
+ return { ...note, cards };
541
+ });
542
+ void flushPendingSave();
543
+ }
544
+
545
+ async function saveDraft() {
546
+ if (!draftNote || yamlError) return;
547
+ if (draftNote.id === deletingNoteId) return;
548
+ await onSaveNote({
549
+ ...draftNote,
550
+ updatedAt: new Date().toISOString(),
551
+ });
552
+ }
553
+
554
+ async function toggleChapterComplete(completed: boolean) {
555
+ if (!draftNote || yamlError) return;
556
+ if (draftNote.id === deletingNoteId) return;
557
+ const nextNote: DocsNote = {
558
+ ...draftNote,
559
+ updatedAt: new Date().toISOString(),
560
+ };
561
+ if (completed) {
562
+ nextNote.completedAt = new Date().toISOString();
563
+ } else {
564
+ delete nextNote.completedAt;
565
+ }
566
+ draftNote = nextNote;
567
+ draftKey = `${nextNote.id}:${nextNote.updatedAt}:${nextNote.completedAt ?? ""}:${nextNote.blocks.length}:${nextNote.cards.length}:${nextNote.body.length}`;
568
+ cancelPendingSave();
569
+ await onSaveNote(nextNote);
570
+ }
571
+
572
+ async function deleteDraftNote() {
573
+ if (!draftNote) return;
574
+ const noteId = draftNote.id;
575
+ cancelPendingSave();
576
+ deletingNoteId = noteId;
577
+ draftNote = null;
578
+ draftKey = "";
579
+ filenameDraft = "";
580
+ yamlMode = false;
581
+ yamlDraft = "";
582
+ yamlError = "";
583
+ try {
584
+ await onDeleteNote(noteId);
585
+ } finally {
586
+ deletingNoteId = null;
587
+ }
588
+ }
589
+
590
+ async function commitFilename() {
591
+ if (!draftNote || !selectedNote) return;
592
+ const nextId = slugifyDocsId(filenameDraft, draftNote.id);
593
+ filenameDraft = nextId;
594
+ if (nextId === selectedNote.id) return;
595
+ if (
596
+ (workspace?.notes ?? []).some(
597
+ (note) => note.id === nextId && note.id !== selectedNote.id,
598
+ )
599
+ ) {
600
+ filenameDraft = selectedNote.id;
601
+ return;
602
+ }
603
+ const renamed = {
604
+ ...draftNote,
605
+ id: nextId,
606
+ sourcePath: `docs/${nextId}.doc.md`,
607
+ updatedAt: new Date().toISOString(),
608
+ };
609
+ draftNote = renamed;
610
+ draftKey = `${nextId}:${renamed.updatedAt}:${renamed.completedAt ?? ""}:${renamed.blocks.length}:${renamed.cards.length}:${renamed.body.length}`;
611
+ try {
612
+ await onSaveNote(renamed);
613
+ } catch {
614
+ filenameDraft = selectedNote.id;
615
+ draftNote = cloneDocsNote(selectedNote);
616
+ draftKey = selectedNoteKey;
617
+ }
618
+ }
619
+
620
+ function createNote() {
621
+ void onCreateNote({
622
+ title: "Untitled note",
623
+ body: "",
624
+ });
625
+ }
626
+
627
+ function cardDueAt(card: DocsCard) {
628
+ return card.fsrs?.due ? normalizeTaskDateOnly(card.fsrs.due) : undefined;
629
+ }
630
+
631
+ function dueDateToIso(dateOnly: string) {
632
+ const [year, month, day] = dateOnly.split("-").map((part) => Number(part));
633
+ return new Date(year, month - 1, day, 9, 0, 0, 0).toISOString();
634
+ }
635
+ function onWorkspaceClick(event: MouseEvent) {
636
+ const opened = openDocsContentLink(event, notes, (noteId) => {
637
+ onActivePanelChange("display");
638
+ onPreviewModeChange(true);
639
+ onActiveNoteChange(noteId);
640
+ });
641
+ if (!opened) preservePreviewModeForDocsLink(event, activePanel);
642
+ }
643
+
644
+ function updateCardDue(cardId: string, dateOnly: string | undefined) {
645
+ updateCard(cardId, (item) => {
646
+ const now = new Date();
647
+ const due = dateOnly ? dueDateToIso(dateOnly) : now.toISOString();
648
+ const fsrsCard = fsrsCardFromState(item.fsrs, now);
649
+ return {
650
+ ...item,
651
+ fsrs: fsrsStateFromCard({
652
+ ...fsrsCard,
653
+ due: new Date(due),
654
+ } as import("ts-fsrs").Card),
655
+ };
656
+ });
657
+ void flushPendingSave();
658
+ }
659
+ </script>
660
+
661
+ <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
662
+ <div
663
+ class="docs-workspace"
664
+ onclick={onWorkspaceClick}
665
+ >
666
+ {#if activeFolderTag && folderDisplayMode === "panel"}
667
+ <DocsFolderPanel
668
+ {workspace}
669
+ activeTag={activeFolderTag}
670
+ activeNoteId={activeNoteId ?? selectedNote?.id ?? null}
671
+ {activePanel}
672
+ collapsed={folderPanelCollapsed}
673
+ onSelectNote={onActiveNoteChange}
674
+ onSelectPanel={onActivePanelChange}
675
+ onSelectTag={onActiveFolderTagChange}
676
+ onClose={() => {
677
+ onActiveFolderTagChange(null);
678
+ onFolderPanelCollapsedChange(false);
679
+ }}
680
+ onCollapsedChange={onFolderPanelCollapsedChange}
681
+ />
682
+ {/if}
683
+ <div class="docs-main">
684
+ <DocsWorkspaceToolbar
685
+ {draftNote}
686
+ bind:filenameDraft
687
+ {yamlMode}
688
+ {saving}
689
+ {activePanel}
690
+ {previewMode}
691
+ showCollapseAll={activePanel === "note" && !yamlMode}
692
+ hasCollapsedSections={hasCollapsedEditSections}
693
+ collapseAllDisabled={!hasEditCollapseTarget}
694
+ bind:displayMode
695
+ {renderedDisplayMarkdown}
696
+ onCommitFilename={commitFilename}
697
+ onYamlModeChange={setYamlMode}
698
+ onPanelChange={onActivePanelChange}
699
+ {onPreviewModeChange}
700
+ onToggleAllSections={toggleAllEditSections}
701
+ onAddCard={addCard}
702
+ {onImportAnkiPackage}
703
+ onCopyRenderedMarkdown={copyRenderedMarkdown}
704
+ onDeleteNote={deleteDraftNote}
705
+ />
706
+ {#if showLoadingState}
707
+ {#if loadingState}
708
+ {@render loadingState()}
709
+ {:else}
710
+ <div
711
+ class="docs-empty-body"
712
+ role="status"
713
+ aria-label={workspace ? "Loading docs note" : "Loading docs workspace"}
714
+ >
715
+ <div class="docs-placeholder" aria-hidden="true"></div>
716
+ </div>
717
+ {/if}
718
+ {:else}
719
+ {#if error}
720
+ <div class="docs-alert">
721
+ <AlertCircleIcon />
722
+ <span>{error}</span>
723
+ </div>
724
+ {/if}
725
+ {#if activePanel === "review"}
726
+ <DocsReviewPanel
727
+ dueCards={activeReviewQueue}
728
+ {reviewScope}
729
+ getNoteBlocks={getNoteBlocks}
730
+ bind:reviewAnswerOpenById
731
+ {onReviewCard}
732
+ />
733
+ {:else if !draftNote}
734
+ <div class="docs-empty-body">
735
+ {#if emptyState}
736
+ {@render emptyState(createNote)}
737
+ {:else}
738
+ <div class="docs-placeholder">
739
+ <button type="button" class="primary" onclick={createNote}>
740
+ <PlusIcon />
741
+ Create note
742
+ </button>
743
+ </div>
744
+ {/if}
745
+ </div>
746
+ {:else if yamlMode}
747
+ <div class="docs-yaml-pane">
748
+ <YamlEditor
749
+ value={yamlDraft}
750
+ invalid={Boolean(yamlError)}
751
+ frameless
752
+ minHeight="100%"
753
+ ariaLabel="Docs note YAML"
754
+ {reviewDiffs}
755
+ {onUndoReview}
756
+ {onKeepReview}
757
+ onChange={updateYamlDraft}
758
+ />
759
+ {#if yamlError}
760
+ <div class="docs-alert small">
761
+ <AlertCircleIcon />
762
+ <span>{yamlError}</span>
763
+ </div>
764
+ {/if}
765
+ </div>
766
+ {:else if activePanel === "practice"}
767
+ {#if codingPractice}
768
+ {@render codingPractice(draftNote, activeCodingCard ?? null, replaceCodingCard)}
769
+ {:else}
770
+ <div class="docs-empty-body">No coding exercise is selected.</div>
771
+ {/if}
772
+ {:else if activePanel === "display"}
773
+ <DocsDisplayPanel
774
+ bind:displayMode
775
+ note={draftNote}
776
+ {renderedDisplayMarkdown}
777
+ bookNavigation={bookWorkspaceView.bookNavigation}
778
+ chapterNavigation={bookWorkspaceView.chapterNavigation}
779
+ {saving}
780
+ onToggleChapterComplete={toggleChapterComplete}
781
+ onOpenNote={onActiveNoteChange}
782
+ />
783
+ {:else if activePanel === "cards"}
784
+ <section class="docs-note-surface">
785
+ <DocsCardsPanel
786
+ {draftNote}
787
+ {previewMode}
788
+ bind:cardOpenById
789
+ bind:cardBlockAddOpenById
790
+ {tagSuggestions}
791
+ {cardDueAt}
792
+ {updateCardDue}
793
+ {updateCard}
794
+ {moveCard}
795
+ {removeCard}
796
+ {addCard}
797
+ />
798
+ {#if reviewContent}
799
+ {@render reviewContent()}
800
+ {/if}
801
+ </section>
802
+ {:else}
803
+ <div class="docs-note-surface">
804
+ <ScrollArea.Root class="docs-note-tab-panel">
805
+ <div class="docs-note-tab-panel__content">
806
+ <DocsEditorPanel
807
+ {draftNote}
808
+ bind:propertiesOpen
809
+ bind:blockOpenById
810
+ {tagSuggestions}
811
+ {aliasSuggestions}
812
+ bookChapterOptions={bookWorkspaceView.chapterOptions}
813
+ {setDraftField}
814
+ {patchBlock}
815
+ {updateBlockTitle}
816
+ {updateBlockId}
817
+ {moveBlock}
818
+ {removeBlock}
819
+ {addBlock}
820
+ {onUploadAttachment}
821
+ />
822
+ {#if reviewContent}
823
+ {@render reviewContent()}
824
+ {/if}
825
+ </div>
826
+ </ScrollArea.Root>
827
+ </div>
828
+ {/if}
829
+ {/if}
830
+ </div>
831
+ </div>