@libraz/mejiro-react 0.5.0 → 0.6.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/README.md CHANGED
@@ -31,6 +31,17 @@ export default function App() {
31
31
  html, body, #root { height: 100%; margin: 0; }
32
32
  ```
33
33
 
34
+ To embed in normal page flow instead (a blog post, a docs page) with no height
35
+ math, use `fit="width"` — the reader self-sizes its height from its width:
36
+
37
+ ```tsx
38
+ <MejiroReader epubUrl="/book.epub" fit="width" />
39
+ ```
40
+
41
+ > Embedding inside a framework with an unlayered global CSS reset (Next.js,
42
+ > Tailwind preflight, normalize.css)? See the [cascade-layers note](https://github.com/libraz/mejiro/tree/main/docs/en/08-react-and-vue.md#css-cascade-layers-host-resets-can-clobber-the-reader-chrome)
43
+ > so host resets don't clobber the reader chrome.
44
+
34
45
  ## Templates
35
46
 
36
47
  Copy-paste-ready starters live under [`examples/`](https://github.com/libraz/mejiro/tree/main/examples):
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as react from 'react';
2
2
  import { ReactNode, CSSProperties, PointerEvent, TextareaHTMLAttributes, RefObject } from 'react';
3
3
  import { EpubBook, AssetResolver, EditableEpub, ManuscriptDialect, EpubChapter, EditableEpubBook, AnnotatedParagraph, AddImageInput, EditableEpubImage, EpubExportOptions, EpubProjectMetadata, EpubProject } from '@libraz/mejiro/epub';
4
- import { BookOptions, ChapterLayout, SpreadResult, PageResult, InChapterAnchor, ReadingAnchor, ManuscriptChapter, AnchorRect, AnchorRange, MejiroBook } from '@libraz/mejiro/book';
4
+ import { BookOptions, ChapterLayout, SpreadResult, PageResult, ComputePageSizeOptions, InChapterAnchor, ReadingAnchor, ManuscriptChapter, AnchorRect, AnchorRange, MejiroBook } from '@libraz/mejiro/book';
5
5
  import { RenderPage } from '@libraz/mejiro/render';
6
6
  import { FontFamily, InlineAnnotation } from '@libraz/mejiro/browser';
7
7
 
@@ -567,6 +567,22 @@ type MejiroReaderMode = 'paginated' | 'scroll';
567
567
  * - `auto` — switch based on the surface aspect ratio (single when portrait).
568
568
  */
569
569
  type MejiroSpreadMode = 'double' | 'single' | 'auto';
570
+ /**
571
+ * Which page of a spread prints its page number: `'both'` (right = odd,
572
+ * left = even), only `'right'`, only `'left'`, or `'none'`.
573
+ */
574
+ type PageNumberDisplay = 'both' | 'right' | 'left' | 'none';
575
+ /**
576
+ * How the reader sizes itself inside its container.
577
+ * - `fill` — fill the container's given height; the spread is fitted inside it,
578
+ * letterboxing if the box aspect doesn't match the spread (the default; the
579
+ * host must give the reader a height).
580
+ * - `width` — self-size from width: the reader derives its own height from its
581
+ * measured width and the page aspect ratio, so the spread fills exactly with
582
+ * no letterbox and the host needs no height/aspect magic numbers. The host
583
+ * only constrains the width.
584
+ */
585
+ type MejiroReaderFit = 'fill' | 'width';
570
586
  /** Built-in reader theme presets. */
571
587
  type MejiroThemeName = 'light' | 'dark' | 'sepia' | 'high-contrast' | 'auto';
572
588
  /**
@@ -656,6 +672,23 @@ interface MejiroReaderHandle {
656
672
  */
657
673
  subscribe<E extends keyof MejiroReaderEventMap>(event: E, listener: MejiroReaderEventMap[E]): () => void;
658
674
  }
675
+ /**
676
+ * Context passed to a {@link MejiroReaderCommonProps.renderSettings} render prop.
677
+ *
678
+ * Lets a host replace the built-in settings controls with its own UI while
679
+ * keeping the panel chrome and its open/close accordion. All fields are wired
680
+ * to the live reader.
681
+ */
682
+ interface MejiroReaderSettingsSlot {
683
+ /** Current effective settings (font, size, line spacing, kinsoku, hanging). */
684
+ settings: EditableSettings;
685
+ /** Applies a partial settings change and re-flows the chapter. */
686
+ update: (partial: Partial<BookOptions>) => void;
687
+ /** Whether the settings panel is currently open. */
688
+ open: boolean;
689
+ /** Toggles the settings panel open/closed (same as the header button). */
690
+ toggle: () => void;
691
+ }
659
692
  /** Props shared across every {@link MejiroReader} source mode. */
660
693
  interface MejiroReaderCommonProps {
661
694
  /**
@@ -668,6 +701,15 @@ interface MejiroReaderCommonProps {
668
701
  * ```
669
702
  */
670
703
  options?: BookOptions;
704
+ /**
705
+ * Page-geometry overrides forwarded to `MejiroBook.computePageSize`. Use to
706
+ * tune how the spread is sized inside the surface — most usefully to shrink
707
+ * the reserved margins so the pages fill their frame, e.g.
708
+ * `pageGeometry={{ gutterOffset: 0, headerOffset: 0 }}`. Also accepts
709
+ * `aspect`, `minWidth`, `minHeight`, `maxHeight`, and inner `padding`; omitted
710
+ * fields fall back to the built-in defaults.
711
+ */
712
+ pageGeometry?: ComputePageSizeOptions;
671
713
  /** Font choices for the settings panel. */
672
714
  fonts?: FontChoice[];
673
715
  /**
@@ -699,6 +741,16 @@ interface MejiroReaderCommonProps {
699
741
  * only the right page; `auto` flips to `single` for portrait viewports.
700
742
  */
701
743
  spreadMode?: MejiroSpreadMode;
744
+ /**
745
+ * How the reader sizes itself in its container. `fill` (default) fills the
746
+ * container height and letterboxes the spread; `width` makes the reader
747
+ * self-size — it derives its height from its width and the page aspect, so an
748
+ * embedding host only has to constrain the width (no height/aspect magic
749
+ * numbers, no letterbox). In `width` mode the reserved `gutterOffset` /
750
+ * `headerOffset` default to 0 so the spread fills edge-to-edge; override via
751
+ * {@link MejiroReaderProps.pageGeometry} if you still want them. @defaultValue 'fill'
752
+ */
753
+ fit?: MejiroReaderFit;
702
754
  /**
703
755
  * Enable surface-tap chrome toggling. Tapping the center of the spread
704
756
  * (away from buttons) hides the header and chapter panel; tapping again
@@ -769,6 +821,15 @@ interface MejiroReaderCommonProps {
769
821
  chapterNavMode?: MejiroChapterNavMode;
770
822
  /** Show the settings panel toggle. @defaultValue `!bare` */
771
823
  enableSettings?: boolean;
824
+ /**
825
+ * Replaces the built-in settings controls with custom UI while keeping the
826
+ * panel chrome and its open/close accordion. Receives a
827
+ * {@link MejiroReaderSettingsSlot} wired to the live reader, so a host can
828
+ * build its own settings form without a parallel `options` shadow. The header
829
+ * "Settings" button still toggles the panel. `enableSettings` remains the
830
+ * on/off switch.
831
+ */
832
+ renderSettings?: (slot: MejiroReaderSettingsSlot) => ReactNode;
772
833
  /** Show the image-overlay editing/demo button. @defaultValue false */
773
834
  enableImageOverlay?: boolean;
774
835
  /** Show the stats line. @defaultValue `!bare` */
@@ -777,6 +838,14 @@ interface MejiroReaderCommonProps {
777
838
  enableKeyboard?: boolean;
778
839
  /** Show the "n / total" indicator. @defaultValue `!bare` */
779
840
  enablePageIndicator?: boolean;
841
+ /**
842
+ * Which page of a spread shows its page number in the running head.
843
+ * `'both'` numbers each page (right = odd, left = even), `'right'` /
844
+ * `'left'` number only that side, `'none'` hides them (the
845
+ * {@link MejiroReaderProps.enablePageIndicator} "n / total" badge is
846
+ * independent). @defaultValue 'both'
847
+ */
848
+ pageNumbers?: PageNumberDisplay;
780
849
  /**
781
850
  * Reader-side annotations to render as highlights. Each annotation whose
782
851
  * `chapter` matches the current chapter is converted to spread-local
@@ -1248,10 +1317,39 @@ declare function useAnnotations(options: UseAnnotationsOptions): UseAnnotationsR
1248
1317
 
1249
1318
  /** Options for {@link useChapterLayout}. */
1250
1319
  interface UseChapterLayoutOptions {
1251
- /** Listen for `window.resize` and re-flow on resize. @defaultValue true */
1320
+ /** Observe the surface element for size changes and re-flow. @defaultValue true */
1252
1321
  enableResize?: boolean;
1253
- /** Debounce window (ms) for resize re-flows. @defaultValue 120 */
1322
+ /** Debounce window (ms) applied to size-triggered re-flows. @defaultValue 120 */
1254
1323
  resizeDebounce?: number;
1324
+ /**
1325
+ * Resolver for page-geometry overrides forwarded to
1326
+ * {@link MejiroBook.computePageSize} — e.g. to shrink the reserved
1327
+ * `gutterOffset` / `headerOffset` so the pages fill their frame. Called on
1328
+ * every (re)layout so it may return a value that changes over time.
1329
+ */
1330
+ pageGeometry?: () => ComputePageSizeOptions | undefined;
1331
+ /**
1332
+ * Capture the current reading position from the outgoing layout, just before
1333
+ * a **reflow** (non-blank) re-layout replaces it. The returned anchor is
1334
+ * exposed via {@link UseChapterLayoutReturn.pendingRestore} and should be
1335
+ * applied once the new layout commits. Return `null` to skip preservation.
1336
+ * Never called for blank (content-change) re-layouts.
1337
+ *
1338
+ * A reflow yields a brand-new {@link ChapterLayout} object, which resets any
1339
+ * downstream spread index to 0; capturing here and restoring after keeps the
1340
+ * reader on the same passage across resizes and font / option changes.
1341
+ */
1342
+ capturePosition?: (layout: ChapterLayout) => InChapterAnchor | null;
1343
+ }
1344
+ /** Options for {@link UseChapterLayoutReturn.recompute}. */
1345
+ interface RecomputeOptions {
1346
+ /**
1347
+ * Blank the current layout while the new one is computed. Use for content
1348
+ * changes (chapter / book swaps); skip for size or option changes so the
1349
+ * previous spread stays visible until the re-flow completes (no flicker).
1350
+ * @defaultValue true
1351
+ */
1352
+ blank?: boolean;
1255
1353
  }
1256
1354
  /** Page dimensions returned by {@link MejiroBook.computePageSize}. */
1257
1355
  interface PageDimensions {
@@ -1272,14 +1370,37 @@ interface UseChapterLayoutReturn {
1272
1370
  /** Most recent layout time (ms). */
1273
1371
  elapsedMs: number;
1274
1372
  /** Force a fresh layout computation. */
1275
- recompute: () => Promise<void>;
1373
+ recompute: (opts?: RecomputeOptions) => Promise<void>;
1374
+ /**
1375
+ * Anchor captured before the most recent reflow re-layout, awaiting
1376
+ * restoration into the new {@link layout}. Consume it in a layout effect keyed
1377
+ * on `layout` (after any index reset) and clear it back to `null`.
1378
+ */
1379
+ pendingRestore: RefObject<InChapterAnchor | null>;
1276
1380
  }
1277
1381
  /**
1278
- * React hook that lays out the currently-selected chapter and re-flows on resize.
1382
+ * React hook that lays out the currently-selected chapter and re-flows when the
1383
+ * surface resizes.
1279
1384
  *
1280
- * Re-layouts whenever `epub` or `chapterIndex` change. On `window.resize`,
1281
- * page dimensions are recomputed and {@link ChapterLayout.resize} is called
1282
- * instead of a full re-layout.
1385
+ * Re-layouts whenever `book`, `epub`, or `chapterIndex` change, against the
1386
+ * dimensions of `surface`. The `surface` element is observed with a
1387
+ * {@link ResizeObserver}: the first observation runs immediately (so a reader
1388
+ * mounted before its container had a final box is still sized correctly on
1389
+ * first paint), and later size changes trigger a debounced **full re-layout**.
1390
+ *
1391
+ * A full re-layout — rather than a `ChapterLayout.resize()` fast-path — is used
1392
+ * for size changes on purpose: the fast-path only stretches `pageWidth` and does
1393
+ * not re-paginate, leaving sparse, half-empty pages after a non-trivial size
1394
+ * delta. Because a full re-layout yields a new {@link ChapterLayout} object
1395
+ * (resetting any downstream spread index to 0), the reading position is
1396
+ * preserved across reflows via {@link UseChapterLayoutOptions.capturePosition}
1397
+ * and {@link UseChapterLayoutReturn.pendingRestore}.
1398
+ *
1399
+ * @param book - The book instance to lay out with.
1400
+ * @param epub - The current parsed EPUB.
1401
+ * @param chapterIndex - Zero-based chapter index to lay out.
1402
+ * @param surface - DOM ref for the reading surface used for page sizing.
1403
+ * @param options - Behavior overrides.
1283
1404
  */
1284
1405
  declare function useChapterLayout(book: MejiroBook, epub: EpubBook | null, chapterIndex: number, surface: RefObject<HTMLElement | null>, options?: UseChapterLayoutOptions): UseChapterLayoutReturn;
1285
1406
 
@@ -1602,6 +1723,12 @@ interface UseSpreadReturn {
1602
1723
  prev: () => void;
1603
1724
  /** Jump to an arbitrary spread index (clamped). */
1604
1725
  goTo: (index: number) => void;
1726
+ /**
1727
+ * Set the spread index immediately, with no page-turn animation, clamped to
1728
+ * `[0, totalSpreads − 1]`. Use to restore a reading position after a reflow
1729
+ * re-layout (where an animated {@link goTo} would briefly flash spread 0).
1730
+ */
1731
+ setSpread: (index: number) => void;
1605
1732
  /** Manually refresh `spread` from `layout` at the current index. */
1606
1733
  refresh: () => void;
1607
1734
  }
@@ -1611,4 +1738,4 @@ interface UseSpreadReturn {
1611
1738
  */
1612
1739
  declare function useSpread(layout: ChapterLayout | null, options?: UseSpreadOptions): UseSpreadReturn;
1613
1740
 
1614
- export { type Annotation, type AnnotationsStorage, type EditableEpubSelection, type EditableSettings, type EpubProjectChapterDraft, type FontChoice, type ImageRect, type ManuscriptEditorChapter, type ManuscriptPageDimensions, type ManuscriptPreviewProps, MejiroChapterNav, type MejiroChapterNavMode, type MejiroChapterNavProps, type MejiroChapterNavVariant, MejiroDropZone, type MejiroDropZoneProps, MejiroEditor, type MejiroEditorProps, type MejiroExportPolicy, MejiroI18nProvider, MejiroImageOverlay, type MejiroImageOverlayProps, type MejiroLocale, MejiroManuscriptEditor, type MejiroManuscriptEditorProps, type MejiroMessages, MejiroNotationHighlighter, type MejiroNotationHighlighterProps, MejiroPage, MejiroPageIndicator, type MejiroPageIndicatorProps, type MejiroPageProps, MejiroPageView, type MejiroPageViewProps, MejiroReader, type MejiroReaderControlledProps, type MejiroReaderEventMap, type MejiroReaderFileProps, type MejiroReaderHandle, type MejiroReaderMode, type MejiroReaderProps, type MejiroReaderUrlProps, MejiroScrollView, type MejiroScrollViewProps, MejiroSelectionLayer, type MejiroSelectionLayerProps, MejiroSettingsPanel, type MejiroSettingsPanelProps, MejiroShelf, type MejiroShelfProps, MejiroSpread, type MejiroSpreadMode, type MejiroSpreadProps, MejiroStats, type MejiroStatsProps, type MejiroTheme, type MejiroThemeName, MejiroToc, type MejiroTocProps, type MultiImageItem, type PageDimensions, type PageHeaderData, type ReadingPosition, type ReadingPositionStorage, type ReadingPositionValue, type UseAnnotationsOptions, type UseAnnotationsReturn, type UseChapterLayoutOptions, type UseChapterLayoutReturn, type UseEditableEpubOptions, type UseEditableEpubReturn, type UseEpubOptions, type UseEpubProjectOptions, type UseEpubProjectReturn, type UseEpubReturn, type UseI18nOptions, type UseImageOverlayOptions, type UseImageOverlayReturn, type UseLibraryOptions, type UseLibraryReturn, type UseManuscriptDraftOptions, type UseManuscriptDraftReturn, type UseManuscriptLayoutOptions, type UseManuscriptLayoutReturn, type UseMejiroBookReturn, type UseMultiImageOverlayOptions, type UseMultiImageOverlayReturn, type UseReadingPositionOptions, type UseReadingPositionReturn, type UseSpreadOptions, type UseSpreadReturn, type VolumeInfo, enMessages, format, jaMessages, resolveMessages, useAnnotations, useChapterLayout, useEditableEpub, useEpub, useEpubProject, useI18n, useImageOverlay, useLibrary, useManuscriptDraft, useManuscriptLayout, useMejiroBook, useMultiImageOverlay, useReadingPosition, useSpread };
1741
+ export { type Annotation, type AnnotationsStorage, type EditableEpubSelection, type EditableSettings, type EpubProjectChapterDraft, type FontChoice, type ImageRect, type ManuscriptEditorChapter, type ManuscriptPageDimensions, type ManuscriptPreviewProps, MejiroChapterNav, type MejiroChapterNavMode, type MejiroChapterNavProps, type MejiroChapterNavVariant, MejiroDropZone, type MejiroDropZoneProps, MejiroEditor, type MejiroEditorProps, type MejiroExportPolicy, MejiroI18nProvider, MejiroImageOverlay, type MejiroImageOverlayProps, type MejiroLocale, MejiroManuscriptEditor, type MejiroManuscriptEditorProps, type MejiroMessages, MejiroNotationHighlighter, type MejiroNotationHighlighterProps, MejiroPage, MejiroPageIndicator, type MejiroPageIndicatorProps, type MejiroPageProps, MejiroPageView, type MejiroPageViewProps, MejiroReader, type MejiroReaderControlledProps, type MejiroReaderEventMap, type MejiroReaderFileProps, type MejiroReaderFit, type MejiroReaderHandle, type MejiroReaderMode, type MejiroReaderProps, type MejiroReaderSettingsSlot, type MejiroReaderUrlProps, MejiroScrollView, type MejiroScrollViewProps, MejiroSelectionLayer, type MejiroSelectionLayerProps, MejiroSettingsPanel, type MejiroSettingsPanelProps, MejiroShelf, type MejiroShelfProps, MejiroSpread, type MejiroSpreadMode, type MejiroSpreadProps, MejiroStats, type MejiroStatsProps, type MejiroTheme, type MejiroThemeName, MejiroToc, type MejiroTocProps, type MultiImageItem, type PageDimensions, type PageHeaderData, type PageNumberDisplay, type ReadingPosition, type ReadingPositionStorage, type ReadingPositionValue, type UseAnnotationsOptions, type UseAnnotationsReturn, type UseChapterLayoutOptions, type UseChapterLayoutReturn, type UseEditableEpubOptions, type UseEditableEpubReturn, type UseEpubOptions, type UseEpubProjectOptions, type UseEpubProjectReturn, type UseEpubReturn, type UseI18nOptions, type UseImageOverlayOptions, type UseImageOverlayReturn, type UseLibraryOptions, type UseLibraryReturn, type UseManuscriptDraftOptions, type UseManuscriptDraftReturn, type UseManuscriptLayoutOptions, type UseManuscriptLayoutReturn, type UseMejiroBookReturn, type UseMultiImageOverlayOptions, type UseMultiImageOverlayReturn, type UseReadingPositionOptions, type UseReadingPositionReturn, type UseSpreadOptions, type UseSpreadReturn, type VolumeInfo, enMessages, format, jaMessages, resolveMessages, useAnnotations, useChapterLayout, useEditableEpub, useEpub, useEpubProject, useI18n, useImageOverlay, useLibrary, useManuscriptDraft, useManuscriptLayout, useMejiroBook, useMultiImageOverlay, useReadingPosition, useSpread };
package/dist/index.js CHANGED
@@ -277,7 +277,7 @@ import { EditableEpub } from "@libraz/mejiro/epub";
277
277
  import { useCallback as useCallback7, useEffect as useEffect7, useRef as useRef10, useState as useState8 } from "react";
278
278
 
279
279
  // src/MejiroReader.tsx
280
- import { DEFAULT_BOOK_OPTIONS } from "@libraz/mejiro/book";
280
+ import { DEFAULT_BOOK_OPTIONS, DEFAULT_PAGE_GEOMETRY } from "@libraz/mejiro/book";
281
281
  import { normalizeFontFamily as normalizeFontFamily5 } from "@libraz/mejiro/browser";
282
282
  import { manuscriptToEpubBook } from "@libraz/mejiro/epub";
283
283
  import {
@@ -285,6 +285,7 @@ import {
285
285
  useCallback as useCallback6,
286
286
  useEffect as useEffect6,
287
287
  useImperativeHandle,
288
+ useLayoutEffect as useLayoutEffect4,
288
289
  useMemo as useMemo4,
289
290
  useRef as useRef9,
290
291
  useState as useState7
@@ -538,6 +539,10 @@ var DEFAULT_FONTS = [
538
539
  { value: "serif", label: "System Serif" },
539
540
  { value: "sans-serif", label: "System Sans" }
540
541
  ];
542
+ function fontLabelFromCss(css) {
543
+ const first = css.split(",")[0]?.trim() ?? css;
544
+ return first.replace(/^["']|["']$/g, "") || css;
545
+ }
541
546
  function MejiroSettingsPanel({
542
547
  open,
543
548
  settings,
@@ -548,17 +553,12 @@ function MejiroSettingsPanel({
548
553
  }) {
549
554
  const messages = useI18n();
550
555
  const patch = (next) => onChange({ ...settings, ...next });
551
- return /* @__PURE__ */ jsx7("div", { className: `mejiro-reader-settings-panel${open ? " is-open" : ""}`, children: /* @__PURE__ */ jsxs7("div", { className: "mejiro-reader-settings-inner", children: [
556
+ const currentFont = normalizeFontFamily3(settings.fontFamily);
557
+ const fontOptions = fonts.some((f) => f.value === currentFont) ? fonts : [{ value: currentFont, label: fontLabelFromCss(currentFont) }, ...fonts];
558
+ return /* @__PURE__ */ jsx7("div", { className: `mejiro-reader-settings-panel${open ? " is-open" : ""}`, children: /* @__PURE__ */ jsx7("div", { className: "mejiro-reader-settings-inner", children: /* @__PURE__ */ jsxs7("div", { className: "mejiro-reader-settings-content", children: [
552
559
  /* @__PURE__ */ jsxs7("div", { className: "mejiro-reader-settings-group", children: [
553
560
  /* @__PURE__ */ jsx7("span", { className: "mejiro-reader-settings-group-title", children: messages.settingsFont }),
554
- /* @__PURE__ */ jsx7("div", { className: "mejiro-reader-control", children: /* @__PURE__ */ jsx7(
555
- "select",
556
- {
557
- value: normalizeFontFamily3(settings.fontFamily),
558
- onChange: (e) => patch({ fontFamily: e.target.value }),
559
- children: fonts.map((f) => /* @__PURE__ */ jsx7("option", { value: f.value, children: f.label }, f.value))
560
- }
561
- ) }),
561
+ /* @__PURE__ */ jsx7("div", { className: "mejiro-reader-control", children: /* @__PURE__ */ jsx7("select", { value: currentFont, onChange: (e) => patch({ fontFamily: e.target.value }), children: fontOptions.map((f) => /* @__PURE__ */ jsx7("option", { value: f.value, children: f.label }, f.value)) }) }),
562
562
  /* @__PURE__ */ jsxs7("div", { className: "mejiro-reader-control", children: [
563
563
  /* @__PURE__ */ jsx7("label", { className: "mejiro-reader-control-label", htmlFor: "mejiro-reader-font-size", children: messages.settingsSize }),
564
564
  /* @__PURE__ */ jsx7(
@@ -643,7 +643,7 @@ function MejiroSettingsPanel({
643
643
  )
644
644
  ] })
645
645
  ] })
646
- ] }) });
646
+ ] }) }) });
647
647
  }
648
648
 
649
649
  // src/MejiroSpread.tsx
@@ -685,11 +685,18 @@ function MejiroImageOverlay({
685
685
  {
686
686
  type: "button",
687
687
  "aria-label": messages.imageRemoveButton,
688
+ title: messages.imageRemoveButton,
688
689
  className: "mejiro-reader-image-overlay-close",
689
690
  onPointerDown: (e) => {
690
691
  e.stopPropagation();
691
692
  e.preventDefault();
692
693
  onClose?.();
694
+ },
695
+ onKeyDown: (e) => {
696
+ if (e.key !== "Enter" && e.key !== " ") return;
697
+ e.stopPropagation();
698
+ e.preventDefault();
699
+ onClose?.();
693
700
  }
694
701
  }
695
702
  )
@@ -978,7 +985,13 @@ function MejiroStats({
978
985
  }
979
986
 
980
987
  // src/useChapterLayout.ts
981
- import { useCallback, useEffect as useEffect2, useLayoutEffect as useLayoutEffect2, useRef as useRef4, useState as useState2 } from "react";
988
+ import {
989
+ useCallback,
990
+ useEffect as useEffect2,
991
+ useLayoutEffect as useLayoutEffect2,
992
+ useRef as useRef4,
993
+ useState as useState2
994
+ } from "react";
982
995
  function useChapterLayout(book, epub, chapterIndex, surface, options = {}) {
983
996
  const enableResize = options.enableResize ?? true;
984
997
  const resizeDebounce = options.resizeDebounce ?? 120;
@@ -989,31 +1002,43 @@ function useChapterLayout(book, epub, chapterIndex, surface, options = {}) {
989
1002
  const [elapsedMs, setElapsedMs] = useState2(0);
990
1003
  const layoutRef = useRef4(null);
991
1004
  const requestIdRef = useRef4(0);
1005
+ const pendingRestore = useRef4(null);
992
1006
  layoutRef.current = layout;
993
- const recompute = useCallback(async () => {
994
- const requestId = ++requestIdRef.current;
995
- const chapter = epub?.chapters[chapterIndex];
996
- if (!(chapter && surface.current)) {
997
- setLayout(null);
998
- setPageWidth(0);
999
- setPageHeight(0);
1000
- setContentHeight(0);
1001
- setElapsedMs(0);
1002
- return;
1003
- }
1004
- setLayout(null);
1005
- const dims = book.computePageSize(surface.current);
1006
- setPageWidth(dims.pageWidth);
1007
- setPageHeight(dims.pageHeight);
1008
- setContentHeight(dims.contentHeight);
1009
- const t0 = performance.now();
1010
- const l = await book.layoutChapter(chapter);
1011
- if (requestId !== requestIdRef.current) return;
1012
- setLayout(l);
1013
- setElapsedMs(performance.now() - t0);
1014
- }, [book, epub, chapterIndex, surface]);
1007
+ const optionsRef = useRef4(options);
1008
+ optionsRef.current = options;
1009
+ const recompute = useCallback(
1010
+ async (opts = {}) => {
1011
+ const blank = opts.blank ?? true;
1012
+ const o = optionsRef.current;
1013
+ const requestId = ++requestIdRef.current;
1014
+ const chapter = epub?.chapters[chapterIndex];
1015
+ if (!(chapter && surface.current)) {
1016
+ setLayout(null);
1017
+ setPageWidth(0);
1018
+ setPageHeight(0);
1019
+ setContentHeight(0);
1020
+ setElapsedMs(0);
1021
+ return;
1022
+ }
1023
+ const captured = !blank && layoutRef.current ? o.capturePosition?.(layoutRef.current) ?? null : null;
1024
+ pendingRestore.current = captured;
1025
+ if (blank) setLayout(null);
1026
+ const dims = book.computePageSize(surface.current, o.pageGeometry?.());
1027
+ setPageWidth(dims.pageWidth);
1028
+ setPageHeight(dims.pageHeight);
1029
+ setContentHeight(dims.contentHeight);
1030
+ const t0 = performance.now();
1031
+ const l = await book.layoutChapter(chapter);
1032
+ if (requestId !== requestIdRef.current) return;
1033
+ layoutRef.current = l;
1034
+ setLayout(l);
1035
+ setElapsedMs(performance.now() - t0);
1036
+ },
1037
+ [book, epub, chapterIndex, surface]
1038
+ );
1015
1039
  useLayoutEffect2(() => {
1016
1040
  requestIdRef.current++;
1041
+ pendingRestore.current = null;
1017
1042
  setLayout(null);
1018
1043
  setPageWidth(0);
1019
1044
  setPageHeight(0);
@@ -1028,29 +1053,55 @@ function useChapterLayout(book, epub, chapterIndex, surface, options = {}) {
1028
1053
  }, [recompute]);
1029
1054
  useEffect2(() => {
1030
1055
  if (!enableResize) return;
1056
+ const el = surface.current;
1057
+ if (!el) return;
1031
1058
  let timer = null;
1032
- const onResize = () => {
1033
- if (!(surface.current && layoutRef.current)) return;
1034
- if (timer) clearTimeout(timer);
1059
+ let observed = false;
1060
+ const clear = () => {
1061
+ if (timer) {
1062
+ clearTimeout(timer);
1063
+ timer = null;
1064
+ }
1065
+ };
1066
+ const scheduleReflow = (immediate) => {
1067
+ clear();
1068
+ if (immediate) {
1069
+ void recompute({ blank: false });
1070
+ return;
1071
+ }
1035
1072
  timer = setTimeout(() => {
1036
- if (!(surface.current && layoutRef.current)) return;
1037
- const dims = book.computePageSize(surface.current);
1038
- setPageWidth(dims.pageWidth);
1039
- setPageHeight(dims.pageHeight);
1040
- setContentHeight(dims.contentHeight);
1041
- layoutRef.current.resize({
1042
- pageWidth: dims.pageWidth,
1043
- lineWidth: dims.contentHeight - book.getOptions().fontSize * 0.5
1044
- });
1073
+ timer = null;
1074
+ void recompute({ blank: false });
1045
1075
  }, resizeDebounce);
1046
1076
  };
1047
- window.addEventListener("resize", onResize);
1077
+ if (typeof ResizeObserver !== "undefined") {
1078
+ const observer = new ResizeObserver(() => {
1079
+ const immediate = !observed;
1080
+ observed = true;
1081
+ scheduleReflow(immediate);
1082
+ });
1083
+ observer.observe(el);
1084
+ return () => {
1085
+ observer.disconnect();
1086
+ clear();
1087
+ };
1088
+ }
1089
+ const onWindowResize = () => scheduleReflow(false);
1090
+ window.addEventListener("resize", onWindowResize);
1048
1091
  return () => {
1049
- window.removeEventListener("resize", onResize);
1050
- if (timer) clearTimeout(timer);
1092
+ window.removeEventListener("resize", onWindowResize);
1093
+ clear();
1051
1094
  };
1052
- }, [book, surface, enableResize, resizeDebounce]);
1053
- return { layout, pageWidth, pageHeight, contentHeight, elapsedMs, recompute };
1095
+ }, [surface, enableResize, resizeDebounce, recompute]);
1096
+ return {
1097
+ layout,
1098
+ pageWidth,
1099
+ pageHeight,
1100
+ contentHeight,
1101
+ elapsedMs,
1102
+ recompute,
1103
+ pendingRestore
1104
+ };
1054
1105
  }
1055
1106
 
1056
1107
  // src/useEpub.ts
@@ -1457,6 +1508,16 @@ function useSpread(layout, options = {}) {
1457
1508
  },
1458
1509
  [turnDuration]
1459
1510
  );
1511
+ const setSpreadIndex = useCallback5((index) => {
1512
+ if (!layoutRef.current) return;
1513
+ if (turnTimerRef.current) {
1514
+ clearTimeout(turnTimerRef.current);
1515
+ turnTimerRef.current = null;
1516
+ }
1517
+ setTurning(false);
1518
+ const max = Math.max(1, Math.ceil(layoutRef.current.totalPages / 2)) - 1;
1519
+ setSpreadIdx(Math.max(0, Math.min(max, index)));
1520
+ }, []);
1460
1521
  const next = useCallback5(() => goTo(spreadIdxRef.current + 1), [goTo]);
1461
1522
  const prev = useCallback5(() => goTo(spreadIdxRef.current - 1), [goTo]);
1462
1523
  useEffect5(() => {
@@ -1475,7 +1536,18 @@ function useSpread(layout, options = {}) {
1475
1536
  },
1476
1537
  []
1477
1538
  );
1478
- return { spreadIdx, spread, totalPages, totalSpreads, turning, next, prev, goTo, refresh };
1539
+ return {
1540
+ spreadIdx,
1541
+ spread,
1542
+ totalPages,
1543
+ totalSpreads,
1544
+ turning,
1545
+ next,
1546
+ prev,
1547
+ goTo,
1548
+ setSpread: setSpreadIndex,
1549
+ refresh
1550
+ };
1479
1551
  }
1480
1552
 
1481
1553
  // src/MejiroReader.tsx
@@ -1485,6 +1557,7 @@ function MejiroReaderInner(props, ref) {
1485
1557
  const dialectProp = "dialect" in props ? props.dialect : void 0;
1486
1558
  const {
1487
1559
  options = DEFAULT_BOOK_OPTIONS,
1560
+ pageGeometry: pageGeometryProp,
1488
1561
  fonts,
1489
1562
  epub: epubProp,
1490
1563
  epubUrl,
@@ -1493,6 +1566,7 @@ function MejiroReaderInner(props, ref) {
1493
1566
  theme = "light",
1494
1567
  mode = "paginated",
1495
1568
  spreadMode = "double",
1569
+ fit = "fill",
1496
1570
  enableSurfaceTap = true,
1497
1571
  fallback,
1498
1572
  fetchOptions,
@@ -1508,10 +1582,12 @@ function MejiroReaderInner(props, ref) {
1508
1582
  enableChapterNav = !bare,
1509
1583
  chapterNavMode = "select",
1510
1584
  enableSettings = !bare,
1585
+ renderSettings,
1511
1586
  enableImageOverlay = false,
1512
1587
  enableStats = !bare,
1513
1588
  enableKeyboard = true,
1514
1589
  enablePageIndicator = !bare,
1590
+ pageNumbers = "both",
1515
1591
  annotations,
1516
1592
  onLoad,
1517
1593
  onError,
@@ -1551,6 +1627,24 @@ function MejiroReaderInner(props, ref) {
1551
1627
  return () => observer.disconnect();
1552
1628
  }, [spreadMode]);
1553
1629
  const effectiveSingle = spreadMode === "single" || spreadMode === "auto" && autoSingle;
1630
+ const resolvedGeometry = useMemo4(() => {
1631
+ const columns = effectiveSingle ? 1 : 2;
1632
+ if (fit !== "width") return { columns, ...pageGeometryProp };
1633
+ return {
1634
+ columns,
1635
+ gutterOffset: 0,
1636
+ headerOffset: 0,
1637
+ maxHeight: Number.POSITIVE_INFINITY,
1638
+ minWidth: 0,
1639
+ minHeight: 0,
1640
+ ...pageGeometryProp
1641
+ };
1642
+ }, [fit, pageGeometryProp, effectiveSingle]);
1643
+ const surfaceAspect = useMemo4(() => {
1644
+ const columns = effectiveSingle ? 1 : 2;
1645
+ const aspect = resolvedGeometry?.aspect ?? DEFAULT_PAGE_GEOMETRY.aspect;
1646
+ return `${columns} / ${aspect}`;
1647
+ }, [effectiveSingle, resolvedGeometry]);
1554
1648
  const { book, options: bookOptions, setOptions } = useMejiroBook(options);
1555
1649
  const synthesizedEpub = useMemo4(() => {
1556
1650
  if (manuscriptProp === void 0) return null;
@@ -1575,7 +1669,18 @@ function MejiroReaderInner(props, ref) {
1575
1669
  useEffect6(() => {
1576
1670
  if (epubCtx.error) onErrorRef.current?.(epubCtx.error);
1577
1671
  }, [epubCtx.error]);
1578
- const layoutCtx = useChapterLayout(book, e, chapter, surfaceRef);
1672
+ const pageGeometryRef = useRef9(resolvedGeometry);
1673
+ pageGeometryRef.current = resolvedGeometry;
1674
+ const curSpreadIdxRef = useRef9(0);
1675
+ const spreadIdxPropRef = useRef9(spreadIdxProp);
1676
+ spreadIdxPropRef.current = spreadIdxProp;
1677
+ const layoutCtx = useChapterLayout(book, e, chapter, surfaceRef, {
1678
+ pageGeometry: () => pageGeometryRef.current,
1679
+ // Preserve the reading position across a reflow re-layout (size / option
1680
+ // changes), but only in uncontrolled mode — when `spreadIdx` is controlled
1681
+ // the host owns the position and the controlled-restore effect handles it.
1682
+ capturePosition: (layout2) => spreadIdxPropRef.current != null ? null : layout2.anchorAt(curSpreadIdxRef.current, "right")
1683
+ });
1579
1684
  const spreadChangedRef = useRef9(void 0);
1580
1685
  spreadChangedRef.current = (i) => {
1581
1686
  onSpreadChange?.(i);
@@ -1585,6 +1690,46 @@ function MejiroReaderInner(props, ref) {
1585
1690
  enableKeyboard,
1586
1691
  onChange: (i) => spreadChangedRef.current?.(i)
1587
1692
  });
1693
+ curSpreadIdxRef.current = spreadCtx.spreadIdx;
1694
+ const setSpreadRef = useRef9(spreadCtx.setSpread);
1695
+ setSpreadRef.current = spreadCtx.setSpread;
1696
+ useLayoutEffect4(() => {
1697
+ const anchor = layoutCtx.pendingRestore.current;
1698
+ if (!(anchor && layoutCtx.layout)) return;
1699
+ layoutCtx.pendingRestore.current = null;
1700
+ const loc = layoutCtx.layout.locateAnchor(anchor);
1701
+ setSpreadRef.current(loc?.spreadIdx ?? 0);
1702
+ }, [layoutCtx.layout]);
1703
+ const optionsKey = [
1704
+ bookOptions.fontFamily,
1705
+ bookOptions.fontSize,
1706
+ bookOptions.lineSpacing,
1707
+ bookOptions.mode,
1708
+ bookOptions.enableHanging
1709
+ ].join("|");
1710
+ const optionsKeyRef = useRef9(optionsKey);
1711
+ const bookOptionsRef = useRef9(bookOptions);
1712
+ bookOptionsRef.current = bookOptions;
1713
+ const recomputeRef = useRef9(layoutCtx.recompute);
1714
+ recomputeRef.current = layoutCtx.recompute;
1715
+ useEffect6(() => {
1716
+ if (optionsKeyRef.current === optionsKey) return;
1717
+ optionsKeyRef.current = optionsKey;
1718
+ const timer = setTimeout(() => {
1719
+ void (async () => {
1720
+ await setOptions({ ...bookOptionsRef.current });
1721
+ await recomputeRef.current({ blank: false });
1722
+ })();
1723
+ }, 60);
1724
+ return () => clearTimeout(timer);
1725
+ }, [optionsKey, setOptions]);
1726
+ const geometryKey = JSON.stringify(resolvedGeometry ?? null);
1727
+ const geometryKeyRef = useRef9(geometryKey);
1728
+ useEffect6(() => {
1729
+ if (geometryKeyRef.current === geometryKey) return;
1730
+ geometryKeyRef.current = geometryKey;
1731
+ void recomputeRef.current({ blank: false });
1732
+ }, [geometryKey]);
1588
1733
  const imageCtx = useMultiImageOverlay(layoutCtx.layout, spreadCtx.spreadIdx, {
1589
1734
  onUpdate: () => spreadCtx.refresh()
1590
1735
  });
@@ -1616,9 +1761,13 @@ function MejiroReaderInner(props, ref) {
1616
1761
  spreadGoToRef.current = spreadCtx.goTo;
1617
1762
  useEffect6(() => {
1618
1763
  if (spreadIdxProp == null) return;
1619
- if (spreadIdxProp === spreadCtx.spreadIdx) return;
1620
1764
  spreadGoToRef.current(spreadIdxProp);
1621
- }, [spreadIdxProp, spreadCtx.spreadIdx]);
1765
+ }, [spreadIdxProp]);
1766
+ useLayoutEffect4(() => {
1767
+ const next = spreadIdxPropRef.current;
1768
+ if (next == null || !layoutCtx.layout) return;
1769
+ setSpreadRef.current(next);
1770
+ }, [layoutCtx.layout]);
1622
1771
  const onChapter = useCallback6(
1623
1772
  (i) => {
1624
1773
  if (i === chapter) return;
@@ -1788,7 +1937,17 @@ function MejiroReaderInner(props, ref) {
1788
1937
  const layoutReady = e && spreadCtx.spread && layoutCtx.layout && layoutCtx.pageWidth > 0;
1789
1938
  const themeName = typeof theme === "string" ? theme : theme.name;
1790
1939
  const themeOverride = typeof theme === "string" ? void 0 : theme.override;
1791
- const themeStyle = themeOverride;
1940
+ const themeStyle = useMemo4(() => {
1941
+ const pad = pageGeometryProp?.padding;
1942
+ const styleVars = {};
1943
+ if (pad?.x != null) styleVars["--mejiro-page-pad-x"] = `${pad.x}px`;
1944
+ if (pad?.y != null) styleVars["--mejiro-page-pad-y"] = `${pad.y}px`;
1945
+ if (pad?.bottom != null) styleVars["--mejiro-page-pad-bottom"] = `${pad.bottom}px`;
1946
+ if (fit === "width") styleVars["--mejiro-surface-aspect"] = surfaceAspect;
1947
+ const hasVars = Object.keys(styleVars).length > 0;
1948
+ if (!(themeOverride || hasVars)) return void 0;
1949
+ return { ...themeOverride, ...styleVars };
1950
+ }, [themeOverride, pageGeometryProp, fit, surfaceAspect]);
1792
1951
  const resolvedMessages = useI18n({ locale, messages });
1793
1952
  const effectiveSubtitle = subtitle ?? resolvedMessages.logoSubtitle;
1794
1953
  const defaultLogo = /* @__PURE__ */ jsxs10("div", { className: "mejiro-reader-logo", children: [
@@ -1846,15 +2005,22 @@ function MejiroReaderInner(props, ref) {
1846
2005
  const rightPage = currentSpread * 2 + 1;
1847
2006
  const leftPage = currentSpread * 2 + 2;
1848
2007
  const showLeft = spreadCtx.spread != null && leftPage <= spreadCtx.spread.totalPages;
2008
+ const showRightNum = pageNumbers === "both" || pageNumbers === "right";
2009
+ const showLeftNum = pageNumbers === "both" || pageNumbers === "left";
1849
2010
  return /* @__PURE__ */ jsx12(MejiroI18nProvider, { messages: resolvedMessages, children: /* @__PURE__ */ jsxs10(
1850
2011
  "div",
1851
2012
  {
1852
- className: `mejiro-reader${chromeHidden ? " mejiro-reader--chrome-hidden" : ""}`,
2013
+ className: `mejiro-reader${chromeHidden ? " mejiro-reader--chrome-hidden" : ""}${fit === "width" ? " mejiro-reader--fit-width" : ""}`,
1853
2014
  "data-mejiro-theme": themeName,
1854
2015
  style: themeStyle,
1855
2016
  children: [
1856
2017
  header,
1857
- enableSettings && /* @__PURE__ */ jsx12(
2018
+ enableSettings && (renderSettings ? /* @__PURE__ */ jsx12("div", { className: `mejiro-reader-settings-panel${settingsOpen ? " is-open" : ""}`, children: /* @__PURE__ */ jsx12("div", { className: "mejiro-reader-settings-inner", children: /* @__PURE__ */ jsx12("div", { className: "mejiro-reader-settings-content", children: renderSettings({
2019
+ settings: editable,
2020
+ update: setOptions,
2021
+ open: settingsOpen,
2022
+ toggle: () => setSettingsOpen((v) => !v)
2023
+ }) }) }) }) : /* @__PURE__ */ jsx12(
1858
2024
  MejiroSettingsPanel,
1859
2025
  {
1860
2026
  open: settingsOpen,
@@ -1862,7 +2028,7 @@ function MejiroReaderInner(props, ref) {
1862
2028
  fonts,
1863
2029
  onChange: setOptions
1864
2030
  }
1865
- ),
2031
+ )),
1866
2032
  /* @__PURE__ */ jsxs10("div", { className: `mejiro-reader-body${showChapterPanel ? " has-chapter-panel" : ""}`, children: [
1867
2033
  showChapterPanel && /* @__PURE__ */ jsx12(MejiroChapterNav, { epub: e, chapter, onChange: onChapter, variant: "panel" }),
1868
2034
  /* @__PURE__ */ jsxs10("div", { ref: surfaceRef, className: "mejiro-reader-surface", children: [
@@ -1900,10 +2066,13 @@ function MejiroReaderInner(props, ref) {
1900
2066
  fontSize: bookOptions.fontSize,
1901
2067
  lineSpacing: bookOptions.lineSpacing,
1902
2068
  turning: spreadCtx.turning,
1903
- rightHeader: { title: runningTitleRight, pageNumber: rightPage },
2069
+ rightHeader: {
2070
+ title: runningTitleRight,
2071
+ pageNumber: showRightNum ? rightPage : null
2072
+ },
1904
2073
  leftHeader: {
1905
2074
  title: runningTitleLeft,
1906
- pageNumber: showLeft ? leftPage : null
2075
+ pageNumber: showLeft && showLeftNum ? leftPage : null
1907
2076
  },
1908
2077
  images: imageCtx.currentImages,
1909
2078
  indicator: enablePageIndicator ? /* @__PURE__ */ jsx12(
@@ -3618,7 +3787,7 @@ function useLibrary(options) {
3618
3787
  }
3619
3788
 
3620
3789
  // src/useManuscriptLayout.ts
3621
- import { useCallback as useCallback15, useEffect as useEffect15, useLayoutEffect as useLayoutEffect4, useRef as useRef18, useState as useState17 } from "react";
3790
+ import { useCallback as useCallback15, useEffect as useEffect15, useLayoutEffect as useLayoutEffect5, useRef as useRef18, useState as useState17 } from "react";
3622
3791
  function useManuscriptLayout(book, chapter, surface, options = {}) {
3623
3792
  const dialect = options.dialect ?? "mejiro";
3624
3793
  const enableResize = options.enableResize ?? true;
@@ -3653,7 +3822,7 @@ function useManuscriptLayout(book, chapter, surface, options = {}) {
3653
3822
  setLayout(next);
3654
3823
  setElapsedMs(performance.now() - t0);
3655
3824
  }, [book, chapter, dialect, surface]);
3656
- useLayoutEffect4(() => {
3825
+ useLayoutEffect5(() => {
3657
3826
  requestIdRef.current++;
3658
3827
  setLayout(null);
3659
3828
  setPageWidth(0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@libraz/mejiro-react",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "React components and hooks for mejiro — vertical text rendering, EPUB reader, and editor",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -46,7 +46,7 @@
46
46
  "build": "tsup"
47
47
  },
48
48
  "dependencies": {
49
- "@libraz/mejiro": "^0.5.0"
49
+ "@libraz/mejiro": "^0.6.0"
50
50
  },
51
51
  "peerDependencies": {
52
52
  "@types/react": ">=18",