@libraz/mejiro-react 0.2.0 → 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.
package/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # @libraz/mejiro-react
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@libraz/mejiro-react.svg)](https://www.npmjs.com/package/@libraz/mejiro-react)
4
+ [![license](https://img.shields.io/npm/l/@libraz/mejiro-react.svg)](https://github.com/libraz/mejiro/blob/main/LICENSE)
5
+
6
+ React components and hooks for [mejiro](https://www.npmjs.com/package/@libraz/mejiro) vertical text rendering.
7
+
8
+ > **Experimental** — API may change in future releases.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ npm install @libraz/mejiro @libraz/mejiro-react
14
+ ```
15
+
16
+ Peer dependency: `react >= 18`
17
+
18
+ ## Quick Start
19
+
20
+ ```tsx
21
+ import { DEFAULT_HEADING_STYLES, MejiroBook } from '@libraz/mejiro/book';
22
+ import { parseEpub } from '@libraz/mejiro/epub';
23
+ import { MejiroPageView } from '@libraz/mejiro-react';
24
+ import '@libraz/mejiro/render/mejiro.css';
25
+
26
+ const book = new MejiroBook({
27
+ fontFamily: '"Noto Serif JP"',
28
+ fontSize: 16,
29
+ lineSpacing: 1.8,
30
+ headingStyles: DEFAULT_HEADING_STYLES,
31
+ });
32
+ book.computePageSize(containerEl);
33
+
34
+ const epub = await parseEpub(buffer);
35
+ const layout = await book.layoutChapter(epub.chapters[0]);
36
+ const spread = layout.getSpread(0);
37
+
38
+ <MejiroPageView result={spread.right} fontFamily='"Noto Serif JP"' lineSpacing={1.8} />
39
+ ```
40
+
41
+ ## Components
42
+
43
+ ### `MejiroPageView` (Recommended)
44
+
45
+ Renders a `PageResult` from `ChapterLayout`. Automatically switches between CSS `vertical-rl` and slot-based rendering when images are present.
46
+
47
+ | Prop | Type | Description |
48
+ |------|------|-------------|
49
+ | `result` | `PageResult` | Required. Page result from `ChapterLayout`. |
50
+ | `fontFamily` | `string` | CSS font family (used in slot-based mode). |
51
+ | `lineSpacing` | `number` | Line spacing multiplier (used in slot-based mode). |
52
+ | `slotMode` | `boolean` | Force slot-based rendering (set when layout has images). |
53
+ | `className` | `string` | Additional CSS class name. |
54
+ | `style` | `CSSProperties` | Additional inline styles. |
55
+
56
+ ### `MejiroPage` (Low-Level)
57
+
58
+ Renders a `RenderPage` using CSS `writing-mode: vertical-rl`. For use with the lower-level `buildRenderPage()` API.
59
+
60
+ ## Hooks
61
+
62
+ ### `useImageOverlay`
63
+
64
+ Manages a draggable/resizable image overlay with automatic text reflow.
65
+
66
+ ```tsx
67
+ import { useImageOverlay } from '@libraz/mejiro-react';
68
+
69
+ const { imageRect, hasImage, toggleImage, onOverlayPointerDown, onResizePointerDown } =
70
+ useImageOverlay(layout, spreadIdx, (spread) => setSpread(spread));
71
+ ```
72
+
73
+ | Parameter | Type | Description |
74
+ |-----------|------|-------------|
75
+ | `layout` | `ChapterLayout \| null` | Current chapter layout. |
76
+ | `spreadIdx` | `number` | Current spread index. |
77
+ | `onUpdate` | `(spread: SpreadResult) => void` | Called after every reflow. |
78
+ | `options?` | `UseImageOverlayOptions` | Default dimensions, position, and margin. |
79
+
80
+ Returns `{ imageRect, hasImage, toggleImage, onOverlayPointerDown, onResizePointerDown }`.
81
+
82
+ ## CSS
83
+
84
+ ```ts
85
+ import '@libraz/mejiro/render/mejiro.css';
86
+ ```
87
+
88
+ ## License
89
+
90
+ [Apache License 2.0](https://github.com/libraz/mejiro/blob/main/LICENSE)
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { RenderPage } from '@libraz/mejiro/render';
2
- import { CSSProperties, ReactNode } from 'react';
2
+ import { CSSProperties, ReactNode, PointerEvent } from 'react';
3
+ import { PageResult, ChapterLayout, SpreadResult } from '@libraz/mejiro/book';
3
4
 
4
5
  /** Props for the MejiroPage component. */
5
6
  interface MejiroPageProps {
@@ -18,4 +19,88 @@ interface MejiroPageProps {
18
19
  */
19
20
  declare function MejiroPage({ page, className, style }: MejiroPageProps): ReactNode;
20
21
 
21
- export { MejiroPage, type MejiroPageProps };
22
+ /** Props for the MejiroPageView component. */
23
+ interface MejiroPageViewProps {
24
+ /** Page result from {@link ChapterLayout.getSpread} or {@link ChapterLayout.getPage}. */
25
+ result: PageResult;
26
+ /** CSS font family for slot-based rendering (used when images are present). */
27
+ fontFamily?: string;
28
+ /** Line spacing multiplier for slot-based rendering (used when images are present). */
29
+ lineSpacing?: number;
30
+ /**
31
+ * Force slot-based rendering even when `result.hasImages` is false.
32
+ * Set to `true` when the layout has images on any spread, so that
33
+ * all pages use consistent slot-based rendering.
34
+ */
35
+ slotMode?: boolean;
36
+ /** Additional CSS class name for the root element. */
37
+ className?: string;
38
+ /** Additional inline styles for the root element. */
39
+ style?: CSSProperties;
40
+ }
41
+ /**
42
+ * Renders a page from a {@link PageResult}.
43
+ *
44
+ * Automatically selects the rendering strategy:
45
+ * - **Normal mode** (no images): Uses CSS `writing-mode: vertical-rl` via `<MejiroPage>`.
46
+ * - **Slot mode** (images present): Uses absolute-positioned columns with per-line sizing.
47
+ *
48
+ * @example
49
+ * ```tsx
50
+ * const spread = layout.getSpread(0);
51
+ * <MejiroPageView result={spread.right} fontFamily="serif" lineSpacing={1.8} />
52
+ * ```
53
+ */
54
+ declare function MejiroPageView({ result, fontFamily, lineSpacing, slotMode, className, style, }: MejiroPageViewProps): ReactNode;
55
+
56
+ /** Rectangle describing an image overlay position and size. */
57
+ interface ImageRect {
58
+ x: number;
59
+ y: number;
60
+ w: number;
61
+ h: number;
62
+ }
63
+ /** Options for {@link useImageOverlay}. */
64
+ interface UseImageOverlayOptions {
65
+ /** Default image width in pixels. @defaultValue 120 */
66
+ defaultWidth?: number;
67
+ /** Default image height in pixels. @defaultValue 160 */
68
+ defaultHeight?: number;
69
+ /** Default x position. @defaultValue 80 */
70
+ defaultX?: number;
71
+ /** Default y position. @defaultValue 100 */
72
+ defaultY?: number;
73
+ /** Margin around the image in pixels. Defaults to the book's font size. */
74
+ margin?: number;
75
+ }
76
+ /** Return value of {@link useImageOverlay}. */
77
+ interface UseImageOverlayReturn {
78
+ /** Current image rectangle, or `null` if no overlay is active. */
79
+ imageRect: ImageRect | null;
80
+ /** Whether an image overlay is currently active. */
81
+ hasImage: boolean;
82
+ /** Toggle the image overlay on/off. */
83
+ toggleImage: () => void;
84
+ /** Pointer-down handler for the overlay body (initiates drag). */
85
+ onOverlayPointerDown: (e: PointerEvent) => void;
86
+ /** Pointer-down handler for the resize handle. */
87
+ onResizePointerDown: (e: PointerEvent) => void;
88
+ }
89
+ /**
90
+ * React hook that manages a draggable/resizable image overlay
91
+ * with automatic text reflow via {@link ChapterLayout.syncImages}.
92
+ *
93
+ * @param layout - Current chapter layout (or `null` if not yet loaded).
94
+ * @param spreadIdx - Current spread index.
95
+ * @param onUpdate - Called with the updated {@link SpreadResult} after every reflow.
96
+ * @param options - Default dimensions and position for the overlay.
97
+ *
98
+ * @example
99
+ * ```tsx
100
+ * const { imageRect, hasImage, toggleImage, onOverlayPointerDown, onResizePointerDown } =
101
+ * useImageOverlay(layout, spreadIdx, setSpread);
102
+ * ```
103
+ */
104
+ declare function useImageOverlay(layout: ChapterLayout | null, spreadIdx: number, onUpdate: (spread: SpreadResult) => void, options?: UseImageOverlayOptions): UseImageOverlayReturn;
105
+
106
+ export { type ImageRect, MejiroPage, type MejiroPageProps, MejiroPageView, type MejiroPageViewProps, type UseImageOverlayOptions, type UseImageOverlayReturn, useImageOverlay };
package/dist/index.js CHANGED
@@ -22,13 +22,171 @@ function renderLine(line, lineIndex) {
22
22
  function MejiroPage({ page, className, style }) {
23
23
  const rootClass = className ? `mejiro-page ${className}` : "mejiro-page";
24
24
  return /* @__PURE__ */ jsx("div", { className: rootClass, style, children: page.paragraphs.map((paragraph, pi) => {
25
- const paraClass = paragraph.isHeading ? "mejiro-paragraph mejiro-paragraph--heading" : "mejiro-paragraph";
25
+ let paraClass = "mejiro-paragraph";
26
+ if (paragraph.headingLevel != null) {
27
+ paraClass += ` mejiro-paragraph--h${paragraph.headingLevel}`;
28
+ } else if (paragraph.isHeading) {
29
+ paraClass += " mejiro-paragraph--heading";
30
+ }
26
31
  return (
27
32
  // biome-ignore lint/suspicious/noArrayIndexKey: paragraphs have no stable ID
28
33
  /* @__PURE__ */ jsx("div", { className: paraClass, children: paragraph.lines.flatMap((line, li) => renderLine(line, li)) }, pi)
29
34
  );
30
35
  }) });
31
36
  }
37
+
38
+ // src/MejiroPageView.tsx
39
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
40
+ function renderSlotSegment(segment, key) {
41
+ if (segment.type === "text") return segment.text;
42
+ return /* @__PURE__ */ jsxs2("ruby", { children: [
43
+ segment.base,
44
+ /* @__PURE__ */ jsx2("rt", { children: segment.rubyText })
45
+ ] }, key);
46
+ }
47
+ function MejiroPageView({
48
+ result,
49
+ fontFamily,
50
+ lineSpacing,
51
+ slotMode,
52
+ className,
53
+ style
54
+ }) {
55
+ if (result.hasImages || slotMode) {
56
+ const rootClass = className ? `mejiro-page-slots ${className}` : "mejiro-page-slots";
57
+ return /* @__PURE__ */ jsx2("div", { className: rootClass, style: { position: "relative", ...style }, children: result.lines.map((line, i) => {
58
+ const slot = result.slots[i];
59
+ if (!slot || slot.height <= 0) return null;
60
+ const colStyle = {
61
+ position: "absolute",
62
+ writingMode: "vertical-rl",
63
+ whiteSpace: "nowrap",
64
+ overflow: "hidden",
65
+ right: slot.xPos,
66
+ top: slot.yStart,
67
+ height: slot.height,
68
+ fontSize: line.fontSize,
69
+ fontFamily,
70
+ lineHeight: lineSpacing,
71
+ fontWeight: line.headingLevel != null ? 700 : void 0
72
+ };
73
+ return (
74
+ // biome-ignore lint/suspicious/noArrayIndexKey: lines have no stable ID
75
+ /* @__PURE__ */ jsx2("div", { style: colStyle, children: line.segments.map((seg, si) => renderSlotSegment(seg, si)) }, i)
76
+ );
77
+ }) });
78
+ }
79
+ return /* @__PURE__ */ jsx2(MejiroPage, { page: result.page, className, style });
80
+ }
81
+
82
+ // src/useImageOverlay.ts
83
+ import { useCallback, useRef, useState } from "react";
84
+ function useImageOverlay(layout, spreadIdx, onUpdate, options) {
85
+ const defW = options?.defaultWidth ?? 120;
86
+ const defH = options?.defaultHeight ?? 160;
87
+ const defX = options?.defaultX ?? 80;
88
+ const defY = options?.defaultY ?? 100;
89
+ const margin = options?.margin;
90
+ const [imageRect, setImageRect] = useState(null);
91
+ const rectRef = useRef(imageRect);
92
+ rectRef.current = imageRect;
93
+ const layoutRef = useRef(layout);
94
+ layoutRef.current = layout;
95
+ const spreadRef = useRef(spreadIdx);
96
+ spreadRef.current = spreadIdx;
97
+ const onUpdateRef = useRef(onUpdate);
98
+ onUpdateRef.current = onUpdate;
99
+ const syncToLayout = useCallback(
100
+ (rect) => {
101
+ const lo = layoutRef.current;
102
+ if (!lo) return;
103
+ const images = rect ? [{ x: rect.x, y: rect.y, w: rect.w, h: rect.h, margin }] : void 0;
104
+ const spread = lo.syncImages(spreadRef.current, images);
105
+ onUpdateRef.current(spread);
106
+ },
107
+ [margin]
108
+ );
109
+ const toggleImage = useCallback(() => {
110
+ if (rectRef.current) {
111
+ setImageRect(null);
112
+ syncToLayout(null);
113
+ } else {
114
+ const r = { x: defX, y: defY, w: defW, h: defH };
115
+ setImageRect(r);
116
+ syncToLayout(r);
117
+ }
118
+ }, [defX, defY, defW, defH, syncToLayout]);
119
+ const onOverlayPointerDown = useCallback(
120
+ (e) => {
121
+ e.preventDefault();
122
+ const startX = e.clientX;
123
+ const startY = e.clientY;
124
+ const start = { ...rectRef.current };
125
+ const target = e.currentTarget;
126
+ target.setPointerCapture(e.nativeEvent.pointerId);
127
+ target.classList.add("dragging");
128
+ let rafId = 0;
129
+ const onMove = (me) => {
130
+ const dx = me.clientX - startX;
131
+ const dy = me.clientY - startY;
132
+ const r = { ...start, x: start.x + dx, y: start.y + dy };
133
+ cancelAnimationFrame(rafId);
134
+ rafId = requestAnimationFrame(() => {
135
+ setImageRect(r);
136
+ syncToLayout(r);
137
+ });
138
+ };
139
+ const onUp = () => {
140
+ target.classList.remove("dragging");
141
+ document.removeEventListener("pointermove", onMove);
142
+ document.removeEventListener("pointerup", onUp);
143
+ };
144
+ document.addEventListener("pointermove", onMove);
145
+ document.addEventListener("pointerup", onUp);
146
+ },
147
+ [syncToLayout]
148
+ );
149
+ const onResizePointerDown = useCallback(
150
+ (e) => {
151
+ e.preventDefault();
152
+ e.stopPropagation();
153
+ const startX = e.clientX;
154
+ const startY = e.clientY;
155
+ const start = { ...rectRef.current };
156
+ const target = e.currentTarget;
157
+ target.setPointerCapture(e.nativeEvent.pointerId);
158
+ target.parentElement?.classList.add("dragging");
159
+ let rafId = 0;
160
+ const onMove = (me) => {
161
+ const dx = me.clientX - startX;
162
+ const dy = me.clientY - startY;
163
+ const r = { ...start, w: Math.max(40, start.w + dx), h: Math.max(40, start.h + dy) };
164
+ cancelAnimationFrame(rafId);
165
+ rafId = requestAnimationFrame(() => {
166
+ setImageRect(r);
167
+ syncToLayout(r);
168
+ });
169
+ };
170
+ const onUp = () => {
171
+ target.parentElement?.classList.remove("dragging");
172
+ document.removeEventListener("pointermove", onMove);
173
+ document.removeEventListener("pointerup", onUp);
174
+ };
175
+ document.addEventListener("pointermove", onMove);
176
+ document.addEventListener("pointerup", onUp);
177
+ },
178
+ [syncToLayout]
179
+ );
180
+ return {
181
+ imageRect,
182
+ hasImage: imageRect !== null,
183
+ toggleImage,
184
+ onOverlayPointerDown,
185
+ onResizePointerDown
186
+ };
187
+ }
32
188
  export {
33
- MejiroPage
189
+ MejiroPage,
190
+ MejiroPageView,
191
+ useImageOverlay
34
192
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@libraz/mejiro-react",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "React components for mejiro vertical text rendering",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -18,7 +18,8 @@
18
18
  },
19
19
  "files": [
20
20
  "dist",
21
- "LICENSE"
21
+ "LICENSE",
22
+ "README.md"
22
23
  ],
23
24
  "sideEffects": false,
24
25
  "engines": {
@@ -28,7 +29,7 @@
28
29
  "build": "tsup"
29
30
  },
30
31
  "dependencies": {
31
- "@libraz/mejiro": "^0.2.0"
32
+ "@libraz/mejiro": "^0.3.0"
32
33
  },
33
34
  "peerDependencies": {
34
35
  "react": ">=18"