@berlysia/vertical-writing-slide-system 0.1.2 → 0.2.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
@@ -139,7 +139,26 @@ Visual regression tests ensure consistent rendering across browsers, with specia
139
139
 
140
140
  ### Print Mode
141
141
 
142
- Print behavior may be unstable across different browsers and scenarios. Users should test print output in their target browser before final use.
142
+ Print mode works reliably only in **Chromium-based browsers** (Chrome, Edge, etc.). Firefox and Safari have fundamental limitations with CSS print specifications.
143
+
144
+ #### Firefox
145
+
146
+ - `@page size` is not fully supported
147
+ - [Bug 851937](https://bugzilla.mozilla.org/show_bug.cgi?id=851937) - Implementing `@page { size }` (marked as RESOLVED FIXED, but may not be available in all versions)
148
+ - [Bug 851441](https://bugzilla.mozilla.org/show_bug.cgi?id=851441) - Implementing `@page` size attribute
149
+
150
+ #### Safari / WebKit
151
+
152
+ - `@page size` is ignored
153
+ - `@page margin` does not work correctly
154
+ - `page-break-after` / `break-after` do not work reliably
155
+ - `page-break-inside: avoid` / `break-inside: avoid` are not respected
156
+ - [WebKit Bug 5097](https://bugs.webkit.org/show_bug.cgi?id=5097) - `page-break-after` does not work
157
+ - [MDN Browser Compat Issue #23178](https://github.com/mdn/browser-compat-data/issues/23178) - Safari does not handle print page margins correctly
158
+
159
+ #### Recommendation
160
+
161
+ For PDF export or printing, use a Chromium-based browser (Chrome, Edge, Arc, etc.).
143
162
 
144
163
  ### Browser-Specific Issues
145
164
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@berlysia/vertical-writing-slide-system",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "vertical-slides": "./cli.js"
package/src/App.tsx CHANGED
@@ -1,23 +1,52 @@
1
1
  import { useState, useRef, useEffect, useCallback } from "react";
2
2
  import slidesContent, { slideScripts } from "virtual:slides.js";
3
3
  import { globalScriptManager } from "./script-manager";
4
+ import { WritingModeHint } from "./WritingModeHint";
5
+
6
+ type DisplayMode = "single" | "dual";
7
+
8
+ // 動作確認用のテストスライドHTML
9
+ const testSlideContent = `<div class="wrapper" style="display: grid; place-items: center;">
10
+
11
+ <svg style="position: absolute; top: 0; left: 0; width: 8cqh; height: 8cqh;" viewBox="0 0 24 24" fill="currentColor">
12
+ <path d="M7 7L17 7L17 9L10.41 9L17 15.59L15.59 17L9 10.41L9 17L7 17Z"/>
13
+ </svg>
14
+ <svg style="position: absolute; top: 0; right: 0; width: 8cqh; height: 8cqh;" viewBox="0 0 24 24" fill="currentColor">
15
+ <path d="M17 7L7 7L7 9L13.59 9L7 15.59L8.41 17L15 10.41L15 17L17 17Z"/>
16
+ </svg>
17
+ <svg style="position: absolute; bottom: 0; left: 0; width: 8cqh; height: 8cqh;" viewBox="0 0 24 24" fill="currentColor">
18
+ <path d="M7 17L17 17L17 15L10.41 15L17 8.41L15.59 7L9 13.59L9 7L7 7Z"/>
19
+ </svg>
20
+ <svg style="position: absolute; bottom: 0; right: 0; width: 8cqh; height: 8cqh;" viewBox="0 0 24 24" fill="currentColor">
21
+ <path d="M17 17L7 17L7 15L13.59 15L7 8.41L8.41 7L15 13.59L15 7L17 7Z"/>
22
+ </svg>
23
+ <div class="wm-horizontal">動作確認</div>
24
+
25
+ </div>`;
4
26
 
5
27
  function App() {
6
28
  const [writingMode, setWritingMode] = useState(() => {
7
29
  const saved = sessionStorage.getItem("slide-writing-mode");
8
30
  return saved ?? "vertical-rl";
9
31
  });
32
+ const [displayMode, setDisplayMode] = useState<DisplayMode>(() => {
33
+ const saved = sessionStorage.getItem("slide-display-mode");
34
+ return (saved as DisplayMode) ?? "single";
35
+ });
36
+ const [showTestSlide, setShowTestSlide] = useState(false);
37
+ const [controlsHidden, setControlsHidden] = useState(false);
10
38
  const isVertical = writingMode !== "horizontal-tb";
39
+ const isDualMode = displayMode === "dual";
11
40
  const slidesRef = useRef<HTMLDivElement>(null);
41
+ const slidesRefHorizontal = useRef<HTMLDivElement>(null);
12
42
 
13
- const [fontSize, setFontSize] = useState(() => {
14
- const saved = sessionStorage.getItem("slide-font-size");
15
- return saved ? Number(saved) : 42;
16
- });
17
- const [withAbsoluteFontSize, setWithAbsoluteFontSize] = useState(() => {
18
- const saved = sessionStorage.getItem("slide-with-absolute-font-size");
19
- return saved === "true";
20
- });
43
+ // 3秒後にコントロールを非表示
44
+ useEffect(() => {
45
+ const timer = setTimeout(() => {
46
+ setControlsHidden(true);
47
+ }, 3000);
48
+ return () => clearTimeout(timer);
49
+ }, []);
21
50
 
22
51
  // 状態変更時にsessionStorageに保存
23
52
  useEffect(() => {
@@ -25,15 +54,8 @@ function App() {
25
54
  }, [writingMode]);
26
55
 
27
56
  useEffect(() => {
28
- sessionStorage.setItem("slide-font-size", fontSize.toString());
29
- }, [fontSize]);
30
-
31
- useEffect(() => {
32
- sessionStorage.setItem(
33
- "slide-with-absolute-font-size",
34
- withAbsoluteFontSize.toString(),
35
- );
36
- }, [withAbsoluteFontSize]);
57
+ sessionStorage.setItem("slide-display-mode", displayMode);
58
+ }, [displayMode]);
37
59
 
38
60
  // スクリプトの読み込み
39
61
  useEffect(() => {
@@ -54,12 +76,23 @@ function App() {
54
76
  useEffect(() => {
55
77
  const hash = location.hash;
56
78
  if (hash) {
57
- const target = document.querySelector(hash);
58
- if (target) {
59
- target.scrollIntoView();
79
+ // ハッシュからページ番号を抽出(#page-2 -> 2)
80
+ const match = hash.match(/^#page-(\d+)/);
81
+ if (match) {
82
+ const pageIndex = match[1];
83
+ if (isDualMode) {
84
+ // デュアルモードでは両方のスライドをスクロール
85
+ const verticalTarget = document.getElementById(`page-${pageIndex}-v`);
86
+ const horizontalTarget = document.getElementById(`page-${pageIndex}-h`);
87
+ verticalTarget?.scrollIntoView();
88
+ horizontalTarget?.scrollIntoView();
89
+ } else {
90
+ const target = document.getElementById(`page-${pageIndex}`);
91
+ target?.scrollIntoView();
92
+ }
60
93
  }
61
94
  }
62
- }, [writingMode]);
95
+ }, [writingMode, isDualMode]);
63
96
 
64
97
  // IntersectionObserverでスクロール位置に応じてページ番号を変更
65
98
  useEffect(() => {
@@ -68,7 +101,7 @@ function App() {
68
101
  entries.forEach((entry) => {
69
102
  if (entry.isIntersecting) {
70
103
  const id = entry.target.id;
71
- const index = parseInt(id.replace("page-", ""));
104
+ const index = parseInt(id.replace("page-", "").replace("-v", "").replace("-h", ""));
72
105
  history.replaceState(null, "", `#page-${index}`);
73
106
  }
74
107
  });
@@ -85,15 +118,82 @@ function App() {
85
118
  };
86
119
  }, []);
87
120
 
121
+ // デュアルモード時の同期スクロール
122
+ useEffect(() => {
123
+ if (!isDualMode) return;
124
+
125
+ const verticalSlides = slidesRef.current;
126
+ const horizontalSlides = slidesRefHorizontal.current;
127
+ if (!verticalSlides || !horizontalSlides) return;
128
+
129
+ let isSyncing = false;
130
+
131
+ const syncScroll = (source: HTMLElement, target: HTMLElement) => {
132
+ if (isSyncing) return;
133
+ isSyncing = true;
134
+
135
+ // 現在表示中のスライドを特定
136
+ const sourceSlides = source.querySelectorAll(".slide");
137
+ let visibleIndex = 0;
138
+ sourceSlides.forEach((slide, index) => {
139
+ const rect = slide.getBoundingClientRect();
140
+ const sourceRect = source.getBoundingClientRect();
141
+ // スライドの中心がコンテナ内にあるかチェック
142
+ const slideCenterX = rect.left + rect.width / 2;
143
+ const slideCenterY = rect.top + rect.height / 2;
144
+ if (
145
+ slideCenterX >= sourceRect.left &&
146
+ slideCenterX <= sourceRect.right &&
147
+ slideCenterY >= sourceRect.top &&
148
+ slideCenterY <= sourceRect.bottom
149
+ ) {
150
+ visibleIndex = index;
151
+ }
152
+ });
153
+
154
+ // ターゲット側の同じインデックスのスライドにスクロール
155
+ const targetSlide = target.querySelector(`[id$="-${visibleIndex === 0 ? "0" : visibleIndex}"]`) ||
156
+ target.querySelectorAll(".slide")[visibleIndex];
157
+ if (targetSlide) {
158
+ targetSlide.scrollIntoView({ behavior: "instant", block: "center", inline: "center" });
159
+ }
160
+
161
+ requestAnimationFrame(() => {
162
+ isSyncing = false;
163
+ });
164
+ };
165
+
166
+ const handleVerticalScroll = () => syncScroll(verticalSlides, horizontalSlides);
167
+ const handleHorizontalScroll = () => syncScroll(horizontalSlides, verticalSlides);
168
+
169
+ verticalSlides.addEventListener("scroll", handleVerticalScroll);
170
+ horizontalSlides.addEventListener("scroll", handleHorizontalScroll);
171
+
172
+ return () => {
173
+ verticalSlides.removeEventListener("scroll", handleVerticalScroll);
174
+ horizontalSlides.removeEventListener("scroll", handleHorizontalScroll);
175
+ };
176
+ }, [isDualMode]);
177
+
88
178
  const gotoNextSlide = useCallback((forward = true) => {
89
179
  const currentHash = location.hash;
90
- const currentIndex = parseInt(currentHash.replace("#page-", ""));
180
+ const currentIndex = parseInt(currentHash.replace("#page-", "").replace("-v", "").replace("-h", "")) || 0;
91
181
  const nextIndex = forward ? currentIndex + 1 : currentIndex - 1;
92
182
  if (nextIndex < 0 || nextIndex >= slidesContent.length) {
93
183
  return;
94
184
  }
95
- location.hash = `#page-${nextIndex}`;
96
- }, []);
185
+
186
+ if (isDualMode) {
187
+ // デュアルモードでは両方のスライドをスクロール
188
+ const verticalSlide = document.getElementById(`page-${nextIndex}-v`);
189
+ const horizontalSlide = document.getElementById(`page-${nextIndex}-h`);
190
+ verticalSlide?.scrollIntoView({ behavior: "instant", block: "center", inline: "center" });
191
+ horizontalSlide?.scrollIntoView({ behavior: "instant", block: "center", inline: "center" });
192
+ history.replaceState(null, "", `#page-${nextIndex}`);
193
+ } else {
194
+ location.hash = `#page-${nextIndex}`;
195
+ }
196
+ }, [isDualMode]);
97
197
 
98
198
  // keydownイベントでページ送り
99
199
  useEffect(() => {
@@ -123,93 +223,118 @@ function App() {
123
223
  };
124
224
  }, [gotoNextSlide]);
125
225
 
226
+ const renderSlide = (content: (typeof slidesContent)[number], index: number, suffix: string = "") => {
227
+ if (typeof content === "string") {
228
+ return (
229
+ <div className="slide" id={`page-${index}${suffix}`} key={`${index}${suffix}`}>
230
+ <div
231
+ className="slide-content"
232
+ dangerouslySetInnerHTML={{
233
+ __html: content,
234
+ }}
235
+ />
236
+ </div>
237
+ );
238
+ } else {
239
+ const SlideComponent = content;
240
+ return (
241
+ <div className="slide" id={`page-${index}${suffix}`} key={`${index}${suffix}`}>
242
+ <div className="slide-content">
243
+ <SlideComponent />
244
+ </div>
245
+ </div>
246
+ );
247
+ }
248
+ };
249
+
126
250
  return (
127
251
  <div
128
- className="slides-container"
252
+ className={`slides-container ${isDualMode ? "dual-mode" : ""}`}
129
253
  style={{ "--slide-writing-mode": writingMode }}
130
254
  >
131
- <div className="slides" ref={slidesRef}>
132
- {slidesContent.map((content, index) => {
133
- if (typeof content === "string") {
134
- return (
135
- <div className="slide" id={`page-${index}`} key={index}>
136
- <div
137
- className="slide-content"
138
- style={
139
- withAbsoluteFontSize ? { fontSize: `${fontSize}px` } : {}
140
- }
141
- dangerouslySetInnerHTML={{
142
- __html: content,
143
- }}
144
- />
145
- </div>
146
- );
147
- } else {
148
- const SlideComponent = content;
149
- return (
150
- <div className="slide" id={`page-${index}`} key={index}>
151
- <div
152
- className="slide-content"
153
- style={
154
- withAbsoluteFontSize ? { fontSize: `${fontSize}px` } : {}
155
- }
156
- >
157
- <SlideComponent />
158
- </div>
159
- </div>
160
- );
161
- }
162
- })}
163
- </div>
164
- <div className="controls">
165
- <div>
255
+ {isDualMode ? (
256
+ <div className="dual-slides-wrapper">
257
+ <div className="dual-pane">
258
+ <div className="slides" ref={slidesRef} style={{ "--slide-writing-mode": "vertical-rl" } as React.CSSProperties}>
259
+ {slidesContent.map((content, index) => renderSlide(content, index, "-v"))}
260
+ </div>
261
+ </div>
262
+ <div className="dual-pane">
263
+ <div className="slides" ref={slidesRefHorizontal} style={{ "--slide-writing-mode": "horizontal-tb" } as React.CSSProperties}>
264
+ {slidesContent.map((content, index) => renderSlide(content, index, "-h"))}
265
+ </div>
266
+ </div>
267
+ </div>
268
+ ) : (
269
+ <div className="slides" ref={slidesRef}>
270
+ {slidesContent.map((content, index) => renderSlide(content, index))}
271
+ </div>
272
+ )}
273
+ <div className={`controls ${controlsHidden ? "hidden" : ""}`}>
274
+ <div className="controls-group">
275
+ <button
276
+ type="button"
277
+ onClick={() =>
278
+ (location.hash = `#page-${slidesContent.length - 1}`)
279
+ }
280
+ >
281
+ 末尾
282
+ </button>
166
283
  <button type="button" onClick={() => gotoNextSlide()}>
167
284
 
168
285
  </button>
286
+ <button type="button" onClick={() => gotoNextSlide(false)}>
287
+
288
+ </button>
289
+ <button type="button" onClick={() => (location.hash = "#page-0")}>
290
+ 先頭
291
+ </button>
292
+ </div>
293
+ <div className="controls-group">
294
+ <button
295
+ type="button"
296
+ onClick={() =>
297
+ setDisplayMode(isDualMode ? "single" : "dual")
298
+ }
299
+ >
300
+ {isDualMode ? "単一" : "並列"}
301
+ </button>
169
302
  <button
170
303
  type="button"
171
304
  onClick={() =>
172
305
  setWritingMode(isVertical ? "horizontal-tb" : "vertical-rl")
173
306
  }
307
+ disabled={isDualMode}
174
308
  >
175
- {isVertical ? "横書きにする" : "縦書きにする"}
309
+ {isVertical ? "横書き" : "縦書き"}
176
310
  </button>
177
- <button type="button" onClick={() => gotoNextSlide(false)}>
178
-
311
+ <button type="button" onClick={() => setShowTestSlide(true)}>
312
+ 確認
179
313
  </button>
180
314
  </div>
181
- <div>
182
- <label>
183
- フォントサイズを指定する
184
- <input
185
- type="checkbox"
186
- checked={withAbsoluteFontSize}
187
- onChange={(e) => setWithAbsoluteFontSize(e.target.checked)}
188
- />
189
- </label>
190
- <label>
191
- <input
192
- type="number"
193
- min="10"
194
- step="1"
195
- value={fontSize}
196
- onChange={(e) => {
197
- const t = Number(e.target.value);
198
- setFontSize(Number.isNaN(t) || t < 4 ? 4 : t);
199
- }}
200
- onKeyDown={(e) => {
201
- if (e.key === "ArrowUp" || e.key === "ArrowDown") {
202
- e.stopPropagation();
203
- }
204
- }}
205
- style={{
206
- inlineSize: `${fontSize.toString(10).length / 2 + 2}em`,
207
- }}
208
- />
209
- px
210
- </label>
211
- </div>
212
315
  </div>
316
+ <WritingModeHint
317
+ onSwitchToHorizontal={() => setWritingMode("horizontal-tb")}
318
+ isHorizontal={!isVertical}
319
+ />
320
+ {showTestSlide && (
321
+ <div
322
+ className="test-slide-overlay"
323
+ onClick={() => setShowTestSlide(false)}
324
+ >
325
+ <div
326
+ className="test-slide-modal"
327
+ style={{ "--slide-writing-mode": writingMode } as React.CSSProperties}
328
+ >
329
+ <div className="slide">
330
+ <div
331
+ className="slide-content"
332
+ dangerouslySetInnerHTML={{ __html: testSlideContent }}
333
+ />
334
+ </div>
335
+ </div>
336
+ </div>
337
+ )}
213
338
  </div>
214
339
  );
215
340
  }
@@ -0,0 +1,89 @@
1
+ import { useState, useEffect, useCallback } from "react";
2
+ import { isUserInCJKLocale } from "./language-utils";
3
+
4
+ interface WritingModeHintProps {
5
+ onSwitchToHorizontal?: () => void;
6
+ isHorizontal: boolean;
7
+ }
8
+
9
+ /**
10
+ * 自動翻訳検出とCJK圏外ユーザー向けの横書きモードヒント
11
+ */
12
+ export function WritingModeHint({
13
+ onSwitchToHorizontal,
14
+ isHorizontal,
15
+ }: WritingModeHintProps) {
16
+ const [visible, setVisible] = useState(false);
17
+
18
+ const dismiss = useCallback(() => {
19
+ setVisible(false);
20
+ }, []);
21
+
22
+ const handleSwitchClick = useCallback(() => {
23
+ onSwitchToHorizontal?.();
24
+ dismiss();
25
+ }, [onSwitchToHorizontal, dismiss]);
26
+
27
+ useEffect(() => {
28
+ const STORAGE_KEY = "writingModeHintShown";
29
+
30
+ // 初回チェック: CJK圏外ユーザーかつセッション内で未表示なら表示
31
+ if (!isUserInCJKLocale() && !sessionStorage.getItem(STORAGE_KEY)) {
32
+ setVisible(true);
33
+ sessionStorage.setItem(STORAGE_KEY, "true");
34
+ return;
35
+ }
36
+
37
+ // 自動翻訳検出: Google翻訳等はhtml要素のclass/langを変更する
38
+ // 翻訳時は毎回表示(sessionStorageは使わない)
39
+ const checkTranslation = () => {
40
+ const html = document.documentElement;
41
+
42
+ // Google翻訳の検出
43
+ const hasTranslatedClass =
44
+ html.classList.contains("translated-ltr") ||
45
+ html.classList.contains("translated-rtl");
46
+
47
+ // lang属性の変更検出(元が'ja'から変わった場合)
48
+ const langChanged = html.lang !== "" && html.lang !== "ja";
49
+
50
+ if (hasTranslatedClass || langChanged) {
51
+ setVisible(true);
52
+ }
53
+ };
54
+
55
+ const observer = new MutationObserver(checkTranslation);
56
+
57
+ observer.observe(document.documentElement, {
58
+ attributes: true,
59
+ attributeFilter: ["class", "lang"],
60
+ });
61
+
62
+ return () => {
63
+ observer.disconnect();
64
+ };
65
+ }, []);
66
+
67
+ // 横書きの場合は表示しない
68
+ if (!visible || isHorizontal) return null;
69
+
70
+ return (
71
+ <div className="writing-mode-hint">
72
+ <div className="writing-mode-hint-content">
73
+ <p>
74
+ 💡 This presentation uses vertical writing.
75
+ <br />
76
+ For translated content, horizontal mode may be easier to read.
77
+ </p>
78
+ <div className="writing-mode-hint-actions">
79
+ <button type="button" onClick={handleSwitchClick} translate="no">
80
+ Switch to Horizontal
81
+ </button>
82
+ <button type="button" onClick={dismiss} translate="no">
83
+ Keep Vertical
84
+ </button>
85
+ </div>
86
+ </div>
87
+ </div>
88
+ );
89
+ }
package/src/index.css CHANGED
@@ -1,7 +1,9 @@
1
1
  @import "screen.css" screen;
2
2
  @import "print.css" print;
3
3
 
4
- * {
4
+ *,
5
+ ::before,
6
+ ::after {
5
7
  box-sizing: border-box;
6
8
  }
7
9
 
@@ -11,6 +13,7 @@ html {
11
13
  sans-serif;
12
14
  font-weight: 600;
13
15
  font-style: normal;
16
+ text-spacing-trim: trim-start;
14
17
 
15
18
  /* Prevent text size adjustments */
16
19
  -webkit-text-size-adjust: 100%;
@@ -19,6 +22,12 @@ html {
19
22
  text-rendering: geometricPrecision;
20
23
  }
21
24
 
25
+ body {
26
+ overflow-wrap: anywhere;
27
+ word-break: normal;
28
+ line-break: strict;
29
+ }
30
+
22
31
  code {
23
32
  font-family: "Noto Sans Mono", monospace;
24
33
  font-optical-sizing: auto;
@@ -32,6 +41,24 @@ code {
32
41
  }
33
42
 
34
43
  .slide-content {
44
+ /* セマンティックフォントサイズ (cqhベース) */
45
+ --fs-caption: 4cqh;
46
+ --fs-small: 4cqh;
47
+ --fs-body: 5cqh;
48
+ --fs-heading-3: 5cqh;
49
+ --fs-heading-2: 6cqh;
50
+ --fs-heading-1: 7cqh;
51
+ --fs-display: 8cqh;
52
+
53
+ /* セマンティックスペーシング */
54
+ --space-heading-gap: 1cqh;
55
+ --space-paragraph-gap: 3cqh;
56
+ --space-section-gap: 4cqh;
57
+ --space-list-gap: 5cqh;
58
+ --space-wrapper: 4cqh;
59
+
60
+ font-size: var(--fs-body);
61
+
35
62
  width: 100%;
36
63
  height: 100%;
37
64
 
@@ -40,7 +67,7 @@ code {
40
67
  .wrapper {
41
68
  width: 100%;
42
69
  height: 100%;
43
- padding: 2rem;
70
+ padding: var(--space-wrapper);
44
71
 
45
72
  position: relative;
46
73
 
@@ -51,6 +78,7 @@ code {
51
78
  align-items: center;
52
79
  }
53
80
  }
81
+
54
82
  .header-and-content {
55
83
  display: block flex;
56
84
  flex-direction: column;
@@ -70,35 +98,40 @@ code {
70
98
  }
71
99
 
72
100
  h1 {
73
- font-size: 1.4em;
101
+ font-size: var(--fs-heading-1);
74
102
  font-weight: bold;
75
103
  margin: 0;
76
- margin-block-end: 0.2em;
104
+ margin-block-end: var(--space-heading-gap);
77
105
  }
78
106
 
79
107
  h2 {
80
- font-size: 1.2em;
108
+ font-size: var(--fs-heading-2);
81
109
  font-weight: bold;
82
110
  margin: 0;
83
- margin-block-end: 0.2em;
111
+ margin-block-end: var(--space-heading-gap);
84
112
  }
85
113
 
86
114
  h3 {
87
- font-size: 1em;
115
+ font-size: var(--fs-heading-3);
88
116
  font-weight: bold;
89
117
  margin: 0;
90
- margin-block-end: 0.2em;
118
+ margin-block-end: var(--space-heading-gap);
119
+ }
120
+
121
+ h1, h2, h3, h4, h5, h6 {
122
+ text-wrap: pretty;
91
123
  }
92
124
 
93
125
  ul,
94
126
  ol {
95
127
  list-style-position: outside;
96
128
  margin: 0;
97
- margin-block-end: 1em;
129
+ margin-block-end: var(--space-section-gap);
130
+ padding-inline-start: var(--space-list-gap);
98
131
  }
99
132
  ul ul,
100
133
  ol ol {
101
- margin-block-end: 1em;
134
+ margin-block-end: var(--space-section-gap);
102
135
  }
103
136
  ul ul ul,
104
137
  ol ol ol {
@@ -106,7 +139,25 @@ code {
106
139
  }
107
140
  p {
108
141
  margin: 0;
109
- margin-block-end: 0.5em;
142
+ margin-block-end: var(--space-paragraph-gap);
143
+ }
144
+
145
+ figure {
146
+ writing-mode: horizontal-tb;
147
+ margin: auto;
148
+ padding: var(--space-section-gap);
149
+ }
150
+
151
+ figcaption {
152
+ text-align: center;
153
+ font-size: var(--fs-caption);
154
+ color: #888;
155
+ }
156
+
157
+ a:any-link {
158
+ text-decoration: underline;
159
+ text-underline-position: left;
160
+ color: #22c;
110
161
  }
111
162
 
112
163
  .wm-toggle {
@@ -0,0 +1,26 @@
1
+ /**
2
+ * CJK(中国語・日本語・韓国語)言語圏の判定ユーティリティ
3
+ */
4
+
5
+ const CJK_LANGUAGE_CODES = [
6
+ "ja", // 日本語
7
+ "zh", // 中国語
8
+ "ko", // 韓国語
9
+ "yue", // 広東語
10
+ ];
11
+
12
+ /**
13
+ * 言語コードがCJK言語かどうかを判定
14
+ */
15
+ export function isCJKLanguage(lang: string): boolean {
16
+ const primaryCode = lang.split("-")[0].toLowerCase();
17
+ return CJK_LANGUAGE_CODES.includes(primaryCode);
18
+ }
19
+
20
+ /**
21
+ * ユーザーの優先言語がCJK圏かどうかを判定
22
+ */
23
+ export function isUserInCJKLocale(): boolean {
24
+ return isCJKLanguage(navigator.language);
25
+ }
26
+
package/src/print.css CHANGED
@@ -15,7 +15,7 @@
15
15
  --print-page-height: 1080px;
16
16
  --print-slide-width: calc(var(--print-page-width) - 0px);
17
17
  --print-slide-height: calc(var(--print-page-height) - 0px);
18
- --print-slide-font-size: calc(var(--print-slide-height) / 20);
18
+ --print-slide-font-size: 5cqh; /* 既存との互換性 */
19
19
  }
20
20
 
21
21
  @page {
@@ -49,12 +49,24 @@ body {
49
49
  display: none !important;
50
50
  }
51
51
 
52
+ /* Container definitions for cqh units */
53
+ .slides-container {
54
+ container-name: slide-container;
55
+ container-type: size;
56
+ width: var(--print-slide-width);
57
+ height: var(--print-slide-height);
58
+ }
59
+
52
60
  /* Slides wrapper adjustments */
53
61
  .slides {
54
62
  position: relative;
55
63
  }
56
64
 
57
65
  .slide {
66
+ /* Container for cqh units */
67
+ container-name: slide-container;
68
+ container-type: size;
69
+
58
70
  /* Size and position */
59
71
  width: 100%;
60
72
  height: 100%;
@@ -82,6 +94,7 @@ body {
82
94
  writing-mode: inherit !important;
83
95
  }
84
96
 
97
+
85
98
  .debug {
86
99
  background-color: orange;
87
100
 
@@ -4,12 +4,30 @@ import type { Root, Image } from "mdast";
4
4
 
5
5
  interface RemarkSlideImagesOptions {
6
6
  base: string;
7
+ collection?: string; // フルURL時のみ使用(複数スライドホスティング対応)
8
+ }
9
+
10
+ /**
11
+ * baseとcollectionから画像パスのプレフィックスを生成
12
+ * - フルURL(http/https)の場合: collection名を含める
13
+ * - 相対パスの場合: collection名なし(ローカル開発用)
14
+ */
15
+ function buildImagePathPrefix(base: string, collection?: string): string {
16
+ if (
17
+ collection &&
18
+ (base.startsWith("http://") || base.startsWith("https://"))
19
+ ) {
20
+ const normalizedBase = base.endsWith("/") ? base : `${base}/`;
21
+ return `${normalizedBase}${collection}/slide-assets/images/`;
22
+ }
23
+ return `${base}slide-assets/images/`;
7
24
  }
8
25
 
9
26
  const remarkSlideImages: Plugin<[RemarkSlideImagesOptions], Root> = (
10
27
  options,
11
28
  ) => {
12
- const { base } = options;
29
+ const { base, collection } = options;
30
+ const imagePathPrefix = buildImagePathPrefix(base, collection);
13
31
 
14
32
  return (tree) => {
15
33
  visit(tree, (node) => {
@@ -17,7 +35,7 @@ const remarkSlideImages: Plugin<[RemarkSlideImagesOptions], Root> = (
17
35
  if (node.type === "image") {
18
36
  const imageNode = node as Image;
19
37
  if (imageNode.url.startsWith("@slide/")) {
20
- imageNode.url = `${base}slide-assets/images/${imageNode.url.slice(7)}`;
38
+ imageNode.url = `${imagePathPrefix}${imageNode.url.slice(7)}`;
21
39
  }
22
40
  }
23
41
 
@@ -35,7 +53,7 @@ const remarkSlideImages: Plugin<[RemarkSlideImagesOptions], Root> = (
35
53
  typeof src.value === "string" &&
36
54
  src.value.startsWith("@slide/")
37
55
  ) {
38
- src.value = `${base}slide-assets/images/${src.value.slice(7)}`;
56
+ src.value = `${imagePathPrefix}${src.value.slice(7)}`;
39
57
  }
40
58
  }
41
59
 
@@ -50,7 +68,7 @@ const remarkSlideImages: Plugin<[RemarkSlideImagesOptions], Root> = (
50
68
  (_, attributes, src) => {
51
69
  return `<img ${attributes.replace(
52
70
  src,
53
- `${base}slide-assets/images/${src.slice(7)}`,
71
+ `${imagePathPrefix}${src.slice(7)}`,
54
72
  )}>`;
55
73
  },
56
74
  );
package/src/screen.css CHANGED
@@ -25,7 +25,7 @@ body,
25
25
  align-items: center;
26
26
 
27
27
  border: 1px solid #ccc;
28
- background-color: #eee;
28
+ background-color: #666;
29
29
  }
30
30
 
31
31
  .slides {
@@ -80,15 +80,68 @@ body,
80
80
  display: none;
81
81
  }
82
82
 
83
+ /* デュアルモード(縦書き・横書き並列表示) */
84
+ .slides-container.dual-mode {
85
+ display: flex;
86
+ flex-direction: column;
87
+ }
88
+
89
+ .dual-slides-wrapper {
90
+ display: flex;
91
+ width: 100%;
92
+ height: 100%;
93
+ gap: 4px;
94
+ background-color: #666;
95
+ }
96
+
97
+ /* 横長画面: 左右に並べる */
98
+ @container slide-container (aspect-ratio > 1) {
99
+ .dual-slides-wrapper {
100
+ flex-direction: row;
101
+ }
102
+ }
103
+
104
+ /* 縦長画面: 上下に並べる */
105
+ @container slide-container (aspect-ratio <= 1) {
106
+ .dual-slides-wrapper {
107
+ flex-direction: column;
108
+ }
109
+ }
110
+
111
+ /* デュアルモード時の各ペイン */
112
+ .dual-pane {
113
+ /* flexアイテムとして半分のスペースを取る */
114
+ flex: 1;
115
+ min-width: 0;
116
+ min-height: 0;
117
+ /* container queryの基準にする */
118
+ container-type: size;
119
+ container-name: slide-container;
120
+ /* 中の.slidesを中央配置 */
121
+ display: flex;
122
+ justify-content: center;
123
+ align-items: center;
124
+ /* 背景色で区別 */
125
+ background-color: #666;
126
+ }
127
+
128
+ /* デュアルモード時の.slidesは元のスタイルをそのまま使う(cqw/cqhが.dual-paneを参照) */
129
+
83
130
  .controls {
84
131
  position: fixed;
85
132
  left: 16px;
86
133
  bottom: 16px;
87
134
  background-color: rgba(255, 255, 255, 0.9);
135
+ padding: 12px;
136
+ border-radius: 12px;
137
+ transition: opacity 0.3s ease-out;
138
+ display: flex;
139
+ flex-direction: column;
140
+ gap: 8px;
141
+ }
142
+
143
+ .controls.hidden {
88
144
  opacity: 0;
89
- padding: 16px;
90
- border-radius: 8px;
91
- transition: opacity 0.2s ease-out;
92
145
  }
93
146
 
94
147
  .controls:hover,
@@ -96,6 +149,124 @@ body,
96
149
  opacity: 1;
97
150
  }
98
151
 
99
- .slide-content {
100
- font-size: 5cqh;
152
+ .controls-group {
153
+ display: flex;
154
+ gap: 6px;
155
+ }
156
+
157
+ .controls button {
158
+ padding: 12px 16px;
159
+ font-size: 14px;
160
+ font-weight: 600;
161
+ border: none;
162
+ border-radius: 8px;
163
+ background-color: #e8e8e8;
164
+ cursor: pointer;
165
+ transition: background-color 0.15s;
166
+ min-width: 48px;
167
+ }
168
+
169
+ .controls button:hover {
170
+ background-color: #d0d0d0;
171
+ }
172
+
173
+ .controls button:active {
174
+ background-color: #c0c0c0;
175
+ }
176
+
177
+ .controls button:disabled {
178
+ opacity: 0.4;
179
+ cursor: not-allowed;
180
+ }
181
+
182
+ /* 動作確認モーダル */
183
+ .test-slide-overlay {
184
+ position: fixed;
185
+ inset: 0;
186
+ background-color: rgba(0, 0, 0, 0.7);
187
+ display: flex;
188
+ justify-content: center;
189
+ align-items: center;
190
+ z-index: 1000;
191
+ /* container queryの基準にする */
192
+ container-type: size;
193
+ }
194
+
195
+ .test-slide-modal {
196
+ /* .slidesと同じ寸法計算 */
197
+ width: 100cqw;
198
+ height: 100cqh;
199
+ max-width: calc(100cqh * 16 / 9 - 0.01px);
200
+ max-height: calc(100cqw * 9 / 16 - 0.01px);
201
+ /* container queryの基準にする */
202
+ container-type: size;
203
+ container-name: slide-container;
204
+ }
205
+
206
+ /* 横書きモードヒント(翻訳ユーザー向け) */
207
+ .writing-mode-hint {
208
+ position: fixed;
209
+ bottom: 80px;
210
+ left: 16px;
211
+ max-width: 360px;
212
+ background-color: rgba(255, 255, 255, 0.95);
213
+ border-radius: 8px;
214
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
215
+ z-index: 100;
216
+ animation: hint-slide-in 0.3s ease-out;
217
+ }
218
+
219
+ @keyframes hint-slide-in {
220
+ from {
221
+ opacity: 0;
222
+ transform: translateY(20px);
223
+ }
224
+ to {
225
+ opacity: 1;
226
+ transform: translateY(0);
227
+ }
228
+ }
229
+
230
+ .writing-mode-hint-content {
231
+ padding: 16px;
232
+ }
233
+
234
+ .writing-mode-hint-content p {
235
+ margin: 0 0 12px 0;
236
+ font-size: 14px;
237
+ line-height: 1.5;
238
+ color: #333;
239
+ }
240
+
241
+ .writing-mode-hint-actions {
242
+ display: flex;
243
+ gap: 8px;
244
+ }
245
+
246
+ .writing-mode-hint-actions button {
247
+ flex: 1;
248
+ padding: 8px 12px;
249
+ border: none;
250
+ border-radius: 4px;
251
+ font-size: 13px;
252
+ cursor: pointer;
253
+ transition: background-color 0.15s;
254
+ }
255
+
256
+ .writing-mode-hint-actions button:first-child {
257
+ background-color: #0066cc;
258
+ color: white;
259
+ }
260
+
261
+ .writing-mode-hint-actions button:first-child:hover {
262
+ background-color: #0055aa;
263
+ }
264
+
265
+ .writing-mode-hint-actions button:last-child {
266
+ background-color: #e0e0e0;
267
+ color: #333;
268
+ }
269
+
270
+ .writing-mode-hint-actions button:last-child:hover {
271
+ background-color: #d0d0d0;
101
272
  }
@@ -54,6 +54,7 @@ function rehypeExtractStyles(extractedStyles: string[]) {
54
54
  async function processMarkdown(
55
55
  markdown: string,
56
56
  base: string,
57
+ collection: string,
57
58
  extractedStyles: string[],
58
59
  extractedScripts: ParsedScript,
59
60
  ) {
@@ -65,7 +66,7 @@ async function processMarkdown(
65
66
 
66
67
  return await unified()
67
68
  .use(remarkParse)
68
- .use(remarkSlideImages, { base })
69
+ .use(remarkSlideImages, { base, collection })
69
70
  .use(remarkRehype, { allowDangerousHtml: true })
70
71
  .use(rehypeExtractStyles(extractedStyles))
71
72
  .use(rehypeStringify, { allowDangerousHtml: true })
@@ -388,7 +389,13 @@ export default async function slidesPlugin(
388
389
  const extractedScripts: ParsedScript = { external: [], inline: [] };
389
390
  const processedSlides = await Promise.all(
390
391
  slides.map((slide) =>
391
- processMarkdown(slide, base, extractedStyles, extractedScripts),
392
+ processMarkdown(
393
+ slide,
394
+ base,
395
+ config.collection,
396
+ extractedStyles,
397
+ extractedScripts,
398
+ ),
392
399
  ),
393
400
  );
394
401
 
@@ -457,7 +464,9 @@ export default async function slidesPlugin(
457
464
  development: false,
458
465
  jsxImportSource: "react",
459
466
  jsxRuntime: "automatic",
460
- remarkPlugins: [[remarkSlideImages, { base }]],
467
+ remarkPlugins: [
468
+ [remarkSlideImages, { base, collection: config.collection }],
469
+ ],
461
470
  });
462
471
  return result.value as string;
463
472
  }),