@open-press/core 0.3.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 (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +36 -0
  3. package/engine/chrome-pdf.d.mts +34 -0
  4. package/engine/chrome-pdf.mjs +344 -0
  5. package/engine/cli.mjs +93 -0
  6. package/engine/commands/_shared.mjs +170 -0
  7. package/engine/commands/deploy.mjs +31 -0
  8. package/engine/commands/dev.mjs +26 -0
  9. package/engine/commands/export.mjs +8 -0
  10. package/engine/commands/init.mjs +24 -0
  11. package/engine/commands/inspect.mjs +35 -0
  12. package/engine/commands/migrate-to-react.mjs +27 -0
  13. package/engine/commands/pdf.mjs +26 -0
  14. package/engine/commands/preview.mjs +26 -0
  15. package/engine/commands/render.mjs +17 -0
  16. package/engine/commands/replace.mjs +41 -0
  17. package/engine/commands/search.mjs +33 -0
  18. package/engine/commands/typecheck.mjs +5 -0
  19. package/engine/commands/validate.mjs +17 -0
  20. package/engine/config.d.mts +40 -0
  21. package/engine/config.mjs +160 -0
  22. package/engine/deploy-sync.mjs +15 -0
  23. package/engine/document-export.mjs +15 -0
  24. package/engine/file-utils.mjs +106 -0
  25. package/engine/fonts.mjs +62 -0
  26. package/engine/init.mjs +90 -0
  27. package/engine/inspection.mjs +348 -0
  28. package/engine/issue-report.mjs +44 -0
  29. package/engine/katex-assets.mjs +45 -0
  30. package/engine/page-block.mjs +30 -0
  31. package/engine/page-renderer.mjs +217 -0
  32. package/engine/pdf-media.mjs +45 -0
  33. package/engine/public-assets.mjs +19 -0
  34. package/engine/react/chapter-css.mjs +53 -0
  35. package/engine/react/comment-endpoint.d.mts +11 -0
  36. package/engine/react/comment-endpoint.mjs +128 -0
  37. package/engine/react/comment-marker.mjs +306 -0
  38. package/engine/react/document-entry.mjs +253 -0
  39. package/engine/react/document-export.mjs +392 -0
  40. package/engine/react/mdx-compile.mjs +295 -0
  41. package/engine/react/measurement-css.mjs +44 -0
  42. package/engine/react/migrate-to-react.mjs +355 -0
  43. package/engine/react/pagination-constants.mjs +3 -0
  44. package/engine/react/pagination.mjs +121 -0
  45. package/engine/react/project-asset-endpoint.d.mts +10 -0
  46. package/engine/react/project-asset-endpoint.mjs +379 -0
  47. package/engine/react/workspace-discovery.mjs +156 -0
  48. package/engine/source-text-tools.mjs +280 -0
  49. package/engine/source-workspace.mjs +76 -0
  50. package/engine/static-server.mjs +493 -0
  51. package/engine/validation.mjs +172 -0
  52. package/index.html +13 -0
  53. package/package.json +86 -0
  54. package/src/openpress/App.tsx +127 -0
  55. package/src/openpress/composerMentions.ts +188 -0
  56. package/src/openpress/core/basePages.tsx +87 -0
  57. package/src/openpress/core/index.tsx +20 -0
  58. package/src/openpress/core/types.ts +71 -0
  59. package/src/openpress/frameScheduler.ts +32 -0
  60. package/src/openpress/indexes.ts +329 -0
  61. package/src/openpress/inspector.ts +282 -0
  62. package/src/openpress/pageRoute.ts +21 -0
  63. package/src/openpress/pagination.ts +845 -0
  64. package/src/openpress/projectIdentity.ts +15 -0
  65. package/src/openpress/projectSources.ts +24 -0
  66. package/src/openpress/projectWorkspace.tsx +919 -0
  67. package/src/openpress/publicPage.tsx +469 -0
  68. package/src/openpress/reactDocumentMetadata.ts +41 -0
  69. package/src/openpress/readerPageRegistry.ts +41 -0
  70. package/src/openpress/readerRuntime.ts +230 -0
  71. package/src/openpress/readerScroll.ts +92 -0
  72. package/src/openpress/readerState.ts +15 -0
  73. package/src/openpress/renderer.tsx +91 -0
  74. package/src/openpress/runtimeMode.ts +22 -0
  75. package/src/openpress/types.ts +112 -0
  76. package/src/openpress/workbench.tsx +1299 -0
  77. package/src/openpress/workbenchPanels.tsx +122 -0
  78. package/src/openpress/workbenchTypes.ts +4 -0
  79. package/src/styles/openpress/app-shell.css +251 -0
  80. package/src/styles/openpress/media-workspace.css +230 -0
  81. package/src/styles/openpress/print-route.css +186 -0
  82. package/src/styles/openpress/project-workspace.css +1318 -0
  83. package/src/styles/openpress/public-viewer.css +983 -0
  84. package/src/styles/openpress/reader-runtime.css +792 -0
  85. package/src/styles/openpress/responsive.css +384 -0
  86. package/src/styles/openpress/workbench-panels.css +558 -0
  87. package/src/styles/openpress/workbench.css +720 -0
  88. package/src/styles/openpress.css +14 -0
  89. package/tsconfig.json +37 -0
  90. package/vite.config.ts +512 -0
@@ -0,0 +1,329 @@
1
+ import type { BlockSource } from "./types";
2
+
3
+ export type MediaAssetKind = "image" | "svg";
4
+
5
+ export interface IndexedHtmlPage {
6
+ id: string;
7
+ html: string;
8
+ title: string;
9
+ pageNumber: number;
10
+ anchors?: string[];
11
+ }
12
+
13
+ export interface MediaAssetItem {
14
+ id: string;
15
+ kind: MediaAssetKind;
16
+ fileName: string;
17
+ src: string;
18
+ pageIndex: number;
19
+ sourceTitle: string;
20
+ usageCount: number;
21
+ references: Array<{
22
+ pageIndex: number;
23
+ sourceTitle: string;
24
+ }>;
25
+ }
26
+
27
+ export interface ContentSourceItem {
28
+ id: string;
29
+ file: string;
30
+ path: string;
31
+ kind?: string;
32
+ chapter?: number;
33
+ slug?: string;
34
+ title: string;
35
+ pageIndexes: number[];
36
+ sectionCount: number;
37
+ }
38
+
39
+ export interface BookmarkItem {
40
+ id: string;
41
+ title: string;
42
+ label?: string;
43
+ pageIndex: number;
44
+ endPageIndex: number;
45
+ subs: BookmarkSubItem[];
46
+ }
47
+
48
+ export interface BookmarkSubItem {
49
+ id: string;
50
+ title: string;
51
+ label?: string;
52
+ pageIndex: number;
53
+ endPageIndex: number;
54
+ subs: BookmarkTopicItem[];
55
+ }
56
+
57
+ export interface BookmarkTopicItem {
58
+ id: string;
59
+ title: string;
60
+ label?: string;
61
+ pageIndex: number;
62
+ endPageIndex: number;
63
+ }
64
+
65
+ export function collectBookmarkIndex(pages: IndexedHtmlPage[]): BookmarkItem[] {
66
+ const totalPages = pages.length;
67
+ const chapters: BookmarkItem[] = [];
68
+ let currentChapter: BookmarkItem | undefined;
69
+ let currentSub: BookmarkSubItem | undefined;
70
+ let pendingChapterOpener: { pageIndex: number } | undefined;
71
+ let tocAdded = false;
72
+
73
+ pages.forEach((page) => {
74
+ const html = parseHtmlPage(page.html);
75
+ if (!html) return;
76
+ const readerPage = html.querySelector<HTMLElement>(".reader-page");
77
+ if (!readerPage) return;
78
+ const pageIndex = page.pageNumber - 1;
79
+
80
+ if (pageKindOf(readerPage) === "toc") {
81
+ if (!tocAdded && readerPage.dataset.tocContinuation !== "true") {
82
+ tocAdded = true;
83
+ chapters.push({
84
+ id: `toc-bookmark-${page.pageNumber}`,
85
+ title: readerPage.dataset.pageTitle || readerPage.querySelector<HTMLElement>(".toc-heading, h2")?.textContent?.trim() || "目錄",
86
+ label: "00",
87
+ pageIndex,
88
+ endPageIndex: totalPages - 1,
89
+ subs: [],
90
+ });
91
+ }
92
+ return;
93
+ }
94
+
95
+ if (pageKindOf(readerPage) === "chapter-opener") {
96
+ pendingChapterOpener = { pageIndex };
97
+ return;
98
+ }
99
+
100
+ if (!isContentPage(readerPage)) return;
101
+
102
+ let pageStartedChapter = false;
103
+ html.querySelectorAll("h2, h3, h4").forEach((heading, headingIndex) => {
104
+ const id = bookmarkItemId(page, heading, headingIndex);
105
+ if (heading.tagName === "H2") {
106
+ const opener = pendingChapterOpener;
107
+ pendingChapterOpener = undefined;
108
+ pageStartedChapter = true;
109
+ currentChapter = {
110
+ id,
111
+ title: normalizeChapterTitle(heading.textContent ?? ""),
112
+ label: heading instanceof HTMLElement ? heading.dataset.chapter : undefined,
113
+ pageIndex: opener?.pageIndex ?? pageIndex,
114
+ endPageIndex: totalPages - 1,
115
+ subs: [],
116
+ };
117
+ chapters.push(currentChapter);
118
+ currentSub = undefined;
119
+ return;
120
+ }
121
+
122
+ if (heading.tagName === "H3" && currentChapter) {
123
+ currentSub = {
124
+ id,
125
+ title: normalizeSectionTitle(heading.textContent ?? ""),
126
+ label: heading instanceof HTMLElement ? heading.dataset.section : undefined,
127
+ pageIndex,
128
+ endPageIndex: totalPages - 1,
129
+ subs: [],
130
+ };
131
+ currentChapter.subs.push(currentSub);
132
+ return;
133
+ }
134
+
135
+ if (heading.tagName === "H4" && currentSub) {
136
+ currentSub.subs.push({
137
+ id,
138
+ title: normalizeTopicTitle(heading.textContent ?? ""),
139
+ label: heading instanceof HTMLElement ? heading.dataset.topic : undefined,
140
+ pageIndex,
141
+ endPageIndex: totalPages - 1,
142
+ });
143
+ }
144
+ });
145
+ if (!pageStartedChapter) pendingChapterOpener = undefined;
146
+ });
147
+
148
+ // Back-fill endPageIndex: each item ends where the next sibling starts (exclusive)
149
+ for (let i = 0; i < chapters.length; i++) {
150
+ const nextChapterStart = chapters[i + 1]?.pageIndex ?? totalPages;
151
+ chapters[i].endPageIndex = nextChapterStart - 1;
152
+
153
+ const subs = chapters[i].subs;
154
+ for (let j = 0; j < subs.length; j++) {
155
+ const nextSubStart = subs[j + 1]?.pageIndex ?? nextChapterStart;
156
+ subs[j].endPageIndex = Math.max(subs[j].pageIndex, nextSubStart - 1);
157
+
158
+ const topics = subs[j].subs;
159
+ for (let k = 0; k < topics.length; k++) {
160
+ const nextTopicStart = topics[k + 1]?.pageIndex ?? nextSubStart;
161
+ topics[k].endPageIndex = Math.max(topics[k].pageIndex, nextTopicStart - 1);
162
+ }
163
+ }
164
+ }
165
+
166
+ return chapters;
167
+ }
168
+
169
+ function pageKindOf(page: HTMLElement) {
170
+ return page.dataset.pageKind || "";
171
+ }
172
+
173
+ function isContentPage(page: HTMLElement) {
174
+ return pageKindOf(page) === "content";
175
+ }
176
+
177
+ function bookmarkItemId(page: IndexedHtmlPage, heading: Element, headingIndex: number) {
178
+ const anchor = heading.id || page.anchors?.[0] || page.id;
179
+ return `${anchor}-bookmark-${page.pageNumber}-${headingIndex + 1}`;
180
+ }
181
+
182
+ export function collectContentSourceIndex(
183
+ pages: Array<IndexedHtmlPage & { source?: BlockSource }>,
184
+ ): ContentSourceItem[] {
185
+ const items = new Map<string, ContentSourceItem>();
186
+
187
+ pages.forEach((page) => {
188
+ const source = page.source;
189
+ if (!source?.path) return;
190
+ const existing = items.get(source.path);
191
+ if (existing) {
192
+ existing.pageIndexes.push(page.pageNumber - 1);
193
+ existing.sectionCount += 1;
194
+ return;
195
+ }
196
+
197
+ items.set(source.path, {
198
+ id: `content-${items.size + 1}`,
199
+ file: source.file,
200
+ path: source.path,
201
+ kind: source.kind,
202
+ chapter: source.chapter,
203
+ slug: source.slug,
204
+ title: page.title,
205
+ pageIndexes: [page.pageNumber - 1],
206
+ sectionCount: 1,
207
+ });
208
+ });
209
+
210
+ return Array.from(items.values()).sort((a, b) => a.path.localeCompare(b.path, "zh-Hant"));
211
+ }
212
+
213
+ export function collectMediaAssetIndex(pages: IndexedHtmlPage[]): MediaAssetItem[] {
214
+ const assets = createMediaInventory();
215
+ const imagePattern = /<(img|image)\b([^>]*)>/gi;
216
+
217
+ pages.forEach((page) => {
218
+ let match: RegExpExecArray | null;
219
+ imagePattern.lastIndex = 0;
220
+ while ((match = imagePattern.exec(page.html)) !== null) {
221
+ const src = normalizeMediaAssetSrc(readHtmlAttribute(match[2], "src") ?? readHtmlAttribute(match[2], "href"));
222
+ if (!src) continue;
223
+ const existing = assets.get(src);
224
+ if (existing) {
225
+ existing.usageCount += 1;
226
+ existing.pageIndex = existing.usageCount === 1 ? page.pageNumber - 1 : existing.pageIndex;
227
+ existing.sourceTitle = existing.usageCount === 1 ? page.title : existing.sourceTitle;
228
+ existing.references.push({
229
+ pageIndex: page.pageNumber - 1,
230
+ sourceTitle: page.title,
231
+ });
232
+ continue;
233
+ }
234
+ const fileName = safeDecodeURIComponent(src.split("/").pop() ?? src);
235
+ assets.set(src, {
236
+ id: `asset-${assets.size + 1}`,
237
+ kind: fileName.toLowerCase().endsWith(".svg") ? "svg" : "image",
238
+ fileName,
239
+ src,
240
+ pageIndex: page.pageNumber - 1,
241
+ sourceTitle: page.title,
242
+ usageCount: 1,
243
+ references: [{
244
+ pageIndex: page.pageNumber - 1,
245
+ sourceTitle: page.title,
246
+ }],
247
+ });
248
+ }
249
+ });
250
+
251
+ return Array.from(assets.values()).sort((a, b) => a.fileName.localeCompare(b.fileName, "zh-Hant"));
252
+ }
253
+
254
+ const workspaceMediaFiles = import.meta.glob<string>("@workspace/media/*", {
255
+ eager: true,
256
+ query: "?url",
257
+ import: "default",
258
+ });
259
+
260
+ function createMediaInventory() {
261
+ const assets = new Map<string, MediaAssetItem>();
262
+
263
+ Object.keys(workspaceMediaFiles).forEach((path) => {
264
+ const fileName = safeDecodeURIComponent(path.split("/").pop() ?? path);
265
+ if (!isMediaFileName(fileName)) return;
266
+ const src = `/openpress/media/${fileName}`;
267
+ assets.set(src, {
268
+ id: `asset-${assets.size + 1}`,
269
+ kind: fileName.toLowerCase().endsWith(".svg") ? "svg" : "image",
270
+ fileName,
271
+ src,
272
+ pageIndex: -1,
273
+ sourceTitle: "未引用",
274
+ usageCount: 0,
275
+ references: [],
276
+ });
277
+ });
278
+
279
+ return assets;
280
+ }
281
+
282
+ function isMediaFileName(fileName: string) {
283
+ return /\.(png|jpe?g|gif|svg|webp)$/i.test(fileName);
284
+ }
285
+
286
+ function parseHtmlPage(html: string) {
287
+ if (typeof DOMParser === "undefined") return undefined;
288
+ return new DOMParser().parseFromString(html, "text/html");
289
+ }
290
+
291
+ function normalizeChapterTitle(value: string) {
292
+ return value
293
+ .trim()
294
+ .replace(/^[一二三四五六七八九十]+、\s*/, "");
295
+ }
296
+
297
+ function normalizeSectionTitle(value: string) {
298
+ return value
299
+ .trim()
300
+ .replace(/^[((][一二三四五六七八九十]+[))]、?\s*/, "")
301
+ .replace(/^\d+\.\d+\s+/, "");
302
+ }
303
+
304
+ function normalizeTopicTitle(value: string) {
305
+ return value
306
+ .trim()
307
+ .replace(/^\d+\.\d+\.\d+\s+/, "");
308
+ }
309
+
310
+ function readHtmlAttribute(attributes: string, name: string) {
311
+ const pattern = new RegExp(`\\b${name}=["']([^"']+)["']`, "i");
312
+ return attributes.match(pattern)?.[1];
313
+ }
314
+
315
+ function normalizeMediaAssetSrc(value?: string) {
316
+ if (!value) return undefined;
317
+ if (value.startsWith("/openpress/media/")) return value;
318
+ if (value.startsWith("media/")) return `/openpress/${value}`;
319
+ if (value.startsWith("./media/")) return `/openpress/${value.slice(2)}`;
320
+ return undefined;
321
+ }
322
+
323
+ function safeDecodeURIComponent(value: string) {
324
+ try {
325
+ return decodeURIComponent(value);
326
+ } catch {
327
+ return value;
328
+ }
329
+ }
@@ -0,0 +1,282 @@
1
+ import { useCallback, useEffect, useMemo, useState, type MouseEvent as ReactMouseEvent } from "react";
2
+ import { getSourceBlockMap } from "./reactDocumentMetadata";
3
+ import type { ReaderDocument, SourceBlock } from "./types";
4
+
5
+ const DEFAULT_INSPECTOR_STORAGE_KEY = "openpress:inspector-mode";
6
+
7
+ export type InspectorIntent = "edit" | "delete" | "add";
8
+ export type InspectorPlacement = "block" | "before";
9
+
10
+ export interface InspectorTarget {
11
+ blockId: string;
12
+ placement: InspectorPlacement;
13
+ }
14
+
15
+ export interface InspectorState {
16
+ enabled: boolean;
17
+ inspectorMode: boolean;
18
+ selectedBlockId: string | null;
19
+ selectedBlock: SourceBlock | null;
20
+ selectedTarget: InspectorTarget | null;
21
+ commentIntent: InspectorIntent;
22
+ setInspectorMode: (enabled: boolean) => void;
23
+ toggleInspectorMode: () => void;
24
+ setCommentIntent: (intent: InspectorIntent) => void;
25
+ selectTarget: (target: InspectorTarget | null) => SourceBlock | null;
26
+ inspectTarget: (target: EventTarget | null) => SourceBlock | null;
27
+ handleClick: (event: ReactMouseEvent) => boolean;
28
+ }
29
+
30
+ export interface InspectorCommentResult {
31
+ ok: boolean;
32
+ comment?: {
33
+ id?: string;
34
+ timestamp?: string;
35
+ path?: string;
36
+ line?: number;
37
+ note?: string;
38
+ hint?: string;
39
+ };
40
+ message?: string;
41
+ }
42
+
43
+ export interface PendingComment {
44
+ id: string;
45
+ timestamp?: string;
46
+ path: string;
47
+ absolutePath?: string;
48
+ line: number;
49
+ marker?: string;
50
+ note: string;
51
+ hint?: string;
52
+ }
53
+
54
+ export interface CommentListResult {
55
+ ok: boolean;
56
+ comments?: PendingComment[];
57
+ message?: string;
58
+ }
59
+
60
+ export interface CommentClearResult {
61
+ ok: boolean;
62
+ removedCount: number;
63
+ comments?: PendingComment[];
64
+ message?: string;
65
+ }
66
+
67
+ export async function submitInspectorComment({
68
+ block,
69
+ note,
70
+ intent,
71
+ placement,
72
+ endpoint = "/__openpress/comment",
73
+ fetchImpl = globalThis.fetch?.bind(globalThis),
74
+ }: {
75
+ block: SourceBlock | null;
76
+ note: string;
77
+ intent?: InspectorIntent;
78
+ placement?: InspectorPlacement;
79
+ endpoint?: string;
80
+ fetchImpl?: typeof fetch;
81
+ }): Promise<InspectorCommentResult> {
82
+ if (!block) throw new Error("OpenPress inspector comment requires a selected block.");
83
+ const normalizedNote = note.trim();
84
+ if (!normalizedNote) throw new Error("OpenPress inspector comment note must not be empty.");
85
+ if (!block.path || !block.source?.line) throw new Error("OpenPress inspector selected block has no editable source location.");
86
+ if (typeof fetchImpl !== "function") throw new Error("OpenPress inspector comment endpoint is unavailable.");
87
+
88
+ const response = await fetchImpl(endpoint, {
89
+ method: "POST",
90
+ headers: { "Content-Type": "application/json" },
91
+ body: JSON.stringify({
92
+ target: {
93
+ blockId: block.id,
94
+ path: block.path,
95
+ source: block.source,
96
+ },
97
+ note: normalizedNote,
98
+ hint: formatInspectorHint({ intent, placement }),
99
+ }),
100
+ });
101
+ const result = await response.json().catch(() => null) as InspectorCommentResult | null;
102
+ if (!response.ok) {
103
+ throw new Error(result?.message ?? `OpenPress inspector comment failed with status ${response.status}`);
104
+ }
105
+ return result ?? { ok: true };
106
+ }
107
+
108
+ export async function fetchInspectorComments({
109
+ endpoint = "/__openpress/comment",
110
+ fetchImpl = globalThis.fetch?.bind(globalThis),
111
+ }: {
112
+ endpoint?: string;
113
+ fetchImpl?: typeof fetch;
114
+ } = {}): Promise<PendingComment[]> {
115
+ if (typeof fetchImpl !== "function") throw new Error("OpenPress inspector comment endpoint is unavailable.");
116
+
117
+ const response = await fetchImpl(endpoint, { method: "GET" });
118
+ const result = await response.json().catch(() => null) as CommentListResult | null;
119
+ if (!response.ok) {
120
+ throw new Error(result?.message ?? `OpenPress inspector comment list failed with status ${response.status}`);
121
+ }
122
+ return Array.isArray(result?.comments) ? result.comments : [];
123
+ }
124
+
125
+ export async function clearInspectorComment({
126
+ id,
127
+ all = false,
128
+ endpoint = "/__openpress/comment",
129
+ fetchImpl = globalThis.fetch?.bind(globalThis),
130
+ }: {
131
+ id?: string;
132
+ all?: boolean;
133
+ endpoint?: string;
134
+ fetchImpl?: typeof fetch;
135
+ } = {}): Promise<CommentClearResult> {
136
+ if (typeof fetchImpl !== "function") throw new Error("OpenPress inspector comment endpoint is unavailable.");
137
+ if (!all && !id) throw new Error("OpenPress inspector comment clear requires an id or all=true.");
138
+
139
+ const response = await fetchImpl(endpoint, {
140
+ method: "DELETE",
141
+ headers: { "Content-Type": "application/json" },
142
+ body: JSON.stringify(all ? { all: true } : { id }),
143
+ });
144
+ const result = await response.json().catch(() => null) as CommentClearResult | null;
145
+ if (!response.ok) {
146
+ throw new Error(result?.message ?? `OpenPress inspector comment clear failed with status ${response.status}`);
147
+ }
148
+ return result ?? { ok: true, removedCount: 0 };
149
+ }
150
+
151
+ export async function updateInspectorComment({
152
+ id,
153
+ note,
154
+ intent,
155
+ placement,
156
+ endpoint = "/__openpress/comment",
157
+ fetchImpl = globalThis.fetch?.bind(globalThis),
158
+ }: {
159
+ id: string;
160
+ note: string;
161
+ intent?: InspectorIntent;
162
+ placement?: InspectorPlacement;
163
+ endpoint?: string;
164
+ fetchImpl?: typeof fetch;
165
+ }): Promise<InspectorCommentResult> {
166
+ const normalizedNote = note.trim();
167
+ if (!id.trim()) throw new Error("OpenPress inspector comment update requires an id.");
168
+ if (!normalizedNote) throw new Error("OpenPress inspector comment note must not be empty.");
169
+ if (typeof fetchImpl !== "function") throw new Error("OpenPress inspector comment endpoint is unavailable.");
170
+
171
+ const response = await fetchImpl(endpoint, {
172
+ method: "PATCH",
173
+ headers: { "Content-Type": "application/json" },
174
+ body: JSON.stringify({
175
+ id,
176
+ note: normalizedNote,
177
+ hint: formatInspectorHint({ intent, placement }),
178
+ }),
179
+ });
180
+ const result = await response.json().catch(() => null) as InspectorCommentResult | null;
181
+ if (!response.ok) {
182
+ throw new Error(result?.message ?? `OpenPress inspector comment update failed with status ${response.status}`);
183
+ }
184
+ return result ?? { ok: true };
185
+ }
186
+
187
+ export function useInspector(
188
+ document: ReaderDocument,
189
+ {
190
+ enabled = false,
191
+ storageKey = DEFAULT_INSPECTOR_STORAGE_KEY,
192
+ }: {
193
+ enabled?: boolean;
194
+ storageKey?: string;
195
+ } = {},
196
+ ): InspectorState {
197
+ const blockMap = useMemo(() => getSourceBlockMap(document), [document]);
198
+ const [inspectorMode, setInspectorModeState] = useState(() => {
199
+ if (!enabled || typeof window === "undefined") return false;
200
+ return window.localStorage.getItem(storageKey) === "on";
201
+ });
202
+ const [selectedTarget, setSelectedTarget] = useState<InspectorTarget | null>(null);
203
+ const [commentIntent, setCommentIntent] = useState<InspectorIntent>("edit");
204
+
205
+ useEffect(() => {
206
+ if (!enabled && inspectorMode) setInspectorModeState(false);
207
+ }, [enabled, inspectorMode]);
208
+
209
+ const setInspectorMode = useCallback((nextEnabled: boolean) => {
210
+ setInspectorModeState(nextEnabled);
211
+ if (typeof window !== "undefined") {
212
+ window.localStorage.setItem(storageKey, nextEnabled ? "on" : "off");
213
+ }
214
+ if (!nextEnabled) setSelectedTarget(null);
215
+ }, [storageKey]);
216
+
217
+ const selectTarget = useCallback((target: InspectorTarget | null) => {
218
+ setSelectedTarget(target);
219
+ if (!target) return null;
220
+ setCommentIntent(target.placement === "before" ? "add" : "edit");
221
+ const sourceBlock = blockMap[target.blockId] ?? null;
222
+ return sourceBlock;
223
+ }, [blockMap]);
224
+
225
+ const inspectTarget = useCallback((target: EventTarget | null) => {
226
+ const inspectorTarget = findInspectorTarget(target);
227
+ return selectTarget(inspectorTarget);
228
+ }, [selectTarget]);
229
+
230
+ const handleClick = useCallback((event: ReactMouseEvent) => {
231
+ if (!enabled || !inspectorMode) return false;
232
+ const inspectorTarget = findInspectorTarget(event.target);
233
+ if (!inspectorTarget) return false;
234
+ selectTarget(inspectorTarget);
235
+ event.preventDefault();
236
+ event.stopPropagation();
237
+ return true;
238
+ }, [enabled, inspectorMode, selectTarget]);
239
+
240
+ const selectedBlockId = selectedTarget?.blockId ?? null;
241
+ const selectedBlock = selectedBlockId ? (blockMap[selectedBlockId] ?? null) : null;
242
+
243
+ return {
244
+ enabled,
245
+ inspectorMode: enabled && inspectorMode,
246
+ selectedBlockId,
247
+ selectedBlock,
248
+ selectedTarget,
249
+ commentIntent,
250
+ setInspectorMode,
251
+ toggleInspectorMode: () => setInspectorMode(!inspectorMode),
252
+ setCommentIntent,
253
+ selectTarget,
254
+ inspectTarget,
255
+ handleClick,
256
+ };
257
+ }
258
+
259
+ export function findInspectorTarget(target: EventTarget | null): InspectorTarget | null {
260
+ if (typeof Element === "undefined") return null;
261
+ if (!(target instanceof Element)) return null;
262
+ const insertTarget = target.closest<HTMLElement>("[data-openpress-insert-before-block-id]");
263
+ const insertBeforeBlockId = insertTarget?.dataset.openpressInsertBeforeBlockId;
264
+ if (insertBeforeBlockId) return { blockId: insertBeforeBlockId, placement: "before" };
265
+
266
+ const element = target.closest<HTMLElement>("[data-openpress-block-id]");
267
+ const blockId = element?.dataset.openpressBlockId;
268
+ return blockId ? { blockId, placement: "block" } : null;
269
+ }
270
+
271
+ export function formatInspectorHint({
272
+ intent,
273
+ placement,
274
+ }: {
275
+ intent?: InspectorIntent;
276
+ placement?: InspectorPlacement;
277
+ } = {}) {
278
+ const parts = ["openpress-react-inspector"];
279
+ if (intent) parts.push(`intent=${intent}`);
280
+ if (placement) parts.push(`placement=${placement}`);
281
+ return parts.join(" ");
282
+ }
@@ -0,0 +1,21 @@
1
+ const PAGE_HASH_PATTERN = /^#page-(\d+)$/;
2
+
3
+ export function pageHashFromIndex(pageIndex: number) {
4
+ return `#page-${String(Math.max(1, pageIndex + 1)).padStart(2, "0")}`;
5
+ }
6
+
7
+ export function pageIndexFromHash(hash: string, pageCount: number) {
8
+ const match = hash.match(PAGE_HASH_PATTERN);
9
+ if (!match) return null;
10
+
11
+ const pageNumber = Number.parseInt(match[1], 10);
12
+ if (!Number.isFinite(pageNumber) || pageNumber < 1 || pageNumber > pageCount) return null;
13
+ return pageNumber - 1;
14
+ }
15
+
16
+ export function replacePageRoute(pageIndex: number) {
17
+ if (typeof window === "undefined") return;
18
+ const hash = pageHashFromIndex(pageIndex);
19
+ if (window.location.hash === hash) return;
20
+ window.history.replaceState(null, "", hash);
21
+ }