slidev-theme-watabegg 1.1.3 → 1.1.4

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.ja.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![NPM version](https://img.shields.io/npm/v/slidev-theme-watabegg?color=3AB9D4&label=)](https://www.npmjs.com/package/slidev-theme-watabegg)
4
4
 
5
- 教育現場(集団授業・試験対策)向けに視認性と操作性を重視した Slidev テーマです。普通に自分用に開発しました。
5
+ watabegg 向けに作っている個人用 Slidev テーマです。日本語表示、構造化されたコンテンツ、キーボード操作を重視しています。
6
6
 
7
7
  ## 特徴
8
8
  - 教育特化のミニマルデザイン
@@ -31,16 +31,21 @@ theme: ./
31
31
 
32
32
  ## フロントマター例
33
33
  ```yaml
34
+ themeConfig:
35
+ watabegg:
36
+ color: green
37
+ link: 'https://example.com'
38
+
34
39
  title: Theme Demo
35
40
  subtitle: サブタイトル
36
41
  author: 講師名
37
42
  date: '2025/08/03'
38
- color: green # red | yellow | green | blue | purple
39
- link: 'https://example.com' # フッターリンク(省略可)
43
+ color: blue # スライド単位の上書き(省略可)
44
+ link: 'https://example.com/slide' # スライド単位の上書き(省略可)
40
45
  transition: fade
41
46
  ```
42
47
 
43
- `color` を省略するとデフォルトの `green` が適用されます。
48
+ `themeConfig.watabegg.color` と `themeConfig.watabegg.link` で deck 全体の既定値を設定できます。スライド側に `color` や `link` があればそちらが優先されます。
44
49
 
45
50
  下図は`blue`指定の例。
46
51
 
@@ -118,7 +123,7 @@ imageScroll:
118
123
  ```
119
124
  カウンター種別: `decimal | hiragana | katakana | kanji | upperalpha | loweralpha | none`
120
125
  デコレータ: `circle | square | paren | dot | q | big-q | none`
121
- アイテム内 `label` があればそれを優先表示。
126
+ アイテム内 `label` があればそれを優先表示。`text` 内の生 HTML はエスケープされ、Markdown リンクも `http` / `https` / `mailto` / `tel` と相対 URL のみ許可されます。
122
127
 
123
128
  ![QuestionList 例](https://raw.githubusercontent.com/watabegg/slidev-theme-watabegg/refs/heads/main/example/3.png)
124
129
 
@@ -154,11 +159,14 @@ Props: `formula`(必須), `block`(既定 false), `tag`(省略時 `div`/`span`
154
159
  ```bash
155
160
  pnpm install
156
161
  pnpm dev
162
+ pnpm dev:polling
157
163
  pnpm build
158
164
  pnpm export
159
165
  pnpm screenshot
160
166
  ```
161
167
 
168
+ Linux で `pnpm dev` が file watcher 上限 (`ENOSPC`) に当たる場合は `pnpm dev:polling` を使用してください。
169
+
162
170
  ## FAQ
163
171
  **Q. フォント設定は必要?** → いいえ、テーマ内で Google Fonts を読み込みます。
164
172
 
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  English README (Japanese version: see `README.ja.md`).
6
6
 
7
7
  ## Overview
8
- Education-focused Slidev theme for exam prep & classroom lessons, optimized for Japanese typography, high readability, structured question hierarchies, and keyboard-centric navigation. Originally built for personal teaching use.
8
+ A personal Slidev theme for watabegg, tuned for Japanese typography, structured content, and keyboard-first navigation.
9
9
 
10
10
  ## Features
11
11
  - Minimal design specialized for teaching
@@ -35,16 +35,21 @@ theme: ./
35
35
 
36
36
  ## Frontmatter Example
37
37
  ```yaml
38
+ themeConfig:
39
+ watabegg:
40
+ color: green
41
+ link: 'https://example.com'
42
+
38
43
  title: Theme Demo
39
44
  subtitle: Subtitle
40
45
  author: Instructor Name
41
46
  date: '2025/08/03'
42
- color: green # red | yellow | green | blue | purple
43
- link: 'https://example.com' # optional footer link
47
+ color: blue # optional slide-level override
48
+ link: 'https://example.com/slide' # optional slide-level override
44
49
  transition: fade
45
50
  ```
46
51
 
47
- If `color` is omitted, the default `green` palette is used.
52
+ `themeConfig.watabegg.color` and `themeConfig.watabegg.link` define deck-level defaults. Slide-level `color` and `link` override them when present.
48
53
 
49
54
  Below: `color: blue` example.
50
55
 
@@ -123,6 +128,7 @@ Nested questions/answers with per-level styles and Markdown rendering.
123
128
  Counter types: `decimal | hiragana | katakana | kanji | upperalpha | loweralpha | none`
124
129
  Decorators: `circle | square | paren | dot | q | big-q | none`
125
130
  If an item includes a `label`, it overrides the computed label.
131
+ Raw HTML inside `text` is escaped. Markdown links only allow safe protocols (`http`, `https`, `mailto`, `tel`) plus relative URLs.
126
132
 
127
133
  ![QuestionList Example](https://raw.githubusercontent.com/watabegg/slidev-theme-watabegg/refs/heads/main/example/3.png)
128
134
 
@@ -158,11 +164,14 @@ Props: `formula` (required), `block` (default `false`), `tag` (auto `div`/`span`
158
164
  ```bash
159
165
  pnpm install
160
166
  pnpm dev
167
+ pnpm dev:polling
161
168
  pnpm build
162
169
  pnpm export
163
170
  pnpm screenshot
164
171
  ```
165
172
 
173
+ If `pnpm dev` hits Linux file watcher limits (`ENOSPC`), use `pnpm dev:polling`.
174
+
166
175
  ## FAQ
167
176
  **Q. Do I need to configure fonts manually?**
168
177
  No. Google Fonts are loaded inside the theme.
@@ -8,53 +8,54 @@
8
8
  </template>
9
9
 
10
10
  <script setup lang="ts">
11
- import { computed, onMounted, onUpdated, ref, useAttrs } from 'vue';
12
- import katex from 'katex';
13
- import 'katex/dist/katex.min.css';
11
+ import { computed, onMounted, onUpdated, ref, useAttrs } from 'vue'
12
+ import katex from 'katex'
13
+ import 'katex/dist/katex.min.css'
14
14
 
15
- defineOptions({ inheritAttrs: false });
15
+ defineOptions({ inheritAttrs: false })
16
16
 
17
17
  const props = withDefaults(defineProps<{
18
- formula: string;
19
- block?: boolean;
20
- tag?: string;
18
+ formula: string
19
+ block?: boolean
20
+ tag?: string
21
21
  }>(), {
22
22
  block: false,
23
23
  tag: undefined,
24
- });
24
+ })
25
25
 
26
- const attrs = useAttrs();
27
- const root = ref<HTMLElement | null>(null);
26
+ const attrs = useAttrs()
27
+ const root = ref<HTMLElement | null>(null)
28
28
 
29
29
  const forwardedAttrs = computed(() => {
30
- const { class: _class, ...rest } = attrs as Record<string, any>;
31
- return rest;
32
- });
30
+ const { class: _class, ...rest } = attrs as Record<string, unknown>
31
+ return rest
32
+ })
33
33
 
34
- const resolvedTag = computed(() => props.tag ?? (props.block ? 'div' : 'span'));
34
+ const resolvedTag = computed(() => props.tag ?? (props.block ? 'div' : 'span'))
35
35
 
36
36
  const renderFormula = () => {
37
- const target = root.value;
38
- if (!target) return;
37
+ const target = root.value
38
+ if (!target)
39
+ return
39
40
 
40
41
  if (!props.formula) {
41
- target.innerHTML = '';
42
- return;
42
+ target.innerHTML = ''
43
+ return
43
44
  }
44
45
 
45
46
  try {
46
47
  katex.render(props.formula, target, {
47
48
  displayMode: props.block,
48
49
  throwOnError: false,
49
- });
50
+ })
50
51
  } catch (error) {
51
- console.warn('[KaTexReveal] failed to render formula', error);
52
- target.textContent = props.formula;
52
+ console.warn('[KaTexReveal] failed to render formula', error)
53
+ target.textContent = props.formula
53
54
  }
54
- };
55
+ }
55
56
 
56
- onMounted(renderFormula);
57
- onUpdated(renderFormula);
57
+ onMounted(renderFormula)
58
+ onUpdated(renderFormula)
58
59
  </script>
59
60
 
60
61
  <style scoped>
@@ -2,7 +2,7 @@
2
2
  <div class="question-list" :style="{ '--level': currentLevel }">
3
3
  <div v-for="(item, index) in items" :key="index" class="question-item">
4
4
  <div class="item-content">
5
- <span class="item-label" v-html="getLabel(index)"></span>
5
+ <span class="item-label">{{ getLabel(index) }}</span>
6
6
  <span v-if="hasRenderableContent(item)" class="item-text">
7
7
  <KaTexReveal
8
8
  v-if="isKatexItem(item)"
@@ -10,7 +10,7 @@
10
10
  :block="isBlockFormula(item)"
11
11
  v-bind="getKatexAttrs(item)"
12
12
  />
13
- <component v-else :is="renderMarkdown(getItemText(item))" />
13
+ <span v-else v-html="renderItemText(item)" />
14
14
  </span>
15
15
  </div>
16
16
  <div v-if="hasSubItems(item)" class="sub-list" :style="{ marginTop: getItemText(item) ? '1rem' : '0' }">
@@ -26,198 +26,324 @@
26
26
  </template>
27
27
 
28
28
  <script setup lang="ts">
29
- import { computed } from 'vue';
30
- import KaTexReveal from './KaTexReveal.vue';
31
- import { renderMarkdown } from '../utils/render';
29
+ import { computed } from 'vue'
30
+
31
+ import KaTexReveal from './KaTexReveal.vue'
32
+ import { renderInlineMarkdown } from '../utils/render'
33
+
34
+ type QuestionListItem = string | QuestionListItemObject
35
+
36
+ interface QuestionListItemObject {
37
+ attrs?: Record<string, unknown>
38
+ block?: boolean
39
+ formula?: string
40
+ items?: QuestionListItem[]
41
+ label?: string
42
+ text?: string
43
+ tex?: boolean
44
+ type?: string
45
+ [key: string]: unknown
46
+ }
47
+
48
+ type CounterType =
49
+ | 'decimal'
50
+ | 'hiragana'
51
+ | 'katakana'
52
+ | 'kanji'
53
+ | 'upperalpha'
54
+ | 'loweralpha'
55
+ | 'none'
56
+
57
+ type DecoratorType =
58
+ | 'circle'
59
+ | 'square'
60
+ | 'paren'
61
+ | 'dot'
62
+ | 'q'
63
+ | 'big-q'
64
+ | 'none'
32
65
 
33
66
  const props = withDefaults(defineProps<{
34
- items: (string | Record<string, any>)[];
35
- styles?: string[];
36
- level?: number;
37
- start?: (number | string)[];
67
+ items: QuestionListItem[]
68
+ styles?: string[]
69
+ level?: number
70
+ start?: (number | string)[]
38
71
  }>(), {
39
- styles: () => ['decimal-circle', 'katakana-paren', 'lower-alpha-paren', 'decimal-dot'],
72
+ styles: () => ['decimal-circle', 'katakana-paren', 'loweralpha-paren', 'decimal-dot'],
40
73
  level: 0,
41
74
  start: () => [],
42
- });
75
+ })
43
76
 
44
- const currentLevel = computed(() => props.level ?? 0);
77
+ const currentLevel = computed(() => props.level ?? 0)
78
+
79
+ const counterAliases: Record<string, CounterType> = {
80
+ decimal: 'decimal',
81
+ hiragana: 'hiragana',
82
+ katakana: 'katakana',
83
+ kanji: 'kanji',
84
+ loweralpha: 'loweralpha',
85
+ 'lower-alpha': 'loweralpha',
86
+ none: 'none',
87
+ upperalpha: 'upperalpha',
88
+ 'upper-alpha': 'upperalpha',
89
+ }
90
+
91
+ const supportedCounters = Object.keys(counterAliases)
92
+ .sort((left, right) => right.length - left.length)
93
+
94
+ const supportedDecorators: DecoratorType[] = ['big-q', 'circle', 'square', 'paren', 'dot', 'q', 'none']
95
+
96
+ const circledNumbers = [
97
+ '①',
98
+ '②',
99
+ '③',
100
+ '④',
101
+ '⑤',
102
+ '⑥',
103
+ '⑦',
104
+ '⑧',
105
+ '⑨',
106
+ '⑩',
107
+ '⑪',
108
+ '⑫',
109
+ '⑬',
110
+ '⑭',
111
+ '⑮',
112
+ '⑯',
113
+ '⑰',
114
+ '⑱',
115
+ '⑲',
116
+ '⑳',
117
+ ]
45
118
 
46
119
  const TEX_WRAPPERS = [
47
120
  { start: '$$', end: '$$' },
48
121
  { start: '\\[', end: '\\]' },
49
122
  { start: '\\(', end: '\\)' },
50
- ];
123
+ ]
124
+
125
+ function isObjectItem(item: QuestionListItem): item is QuestionListItemObject {
126
+ return typeof item === 'object' && item !== null
127
+ }
51
128
 
52
129
  const stripTexDelimiters = (value: string): string => {
53
- const trimmed = value.trim();
130
+ const trimmed = value.trim()
54
131
  for (const { start, end } of TEX_WRAPPERS) {
55
132
  if (trimmed.startsWith(start) && trimmed.endsWith(end)) {
56
- return trimmed.slice(start.length, trimmed.length - end.length).trim();
133
+ return trimmed.slice(start.length, trimmed.length - end.length).trim()
57
134
  }
58
135
  }
59
- return trimmed;
60
- };
136
+ return trimmed
137
+ }
61
138
 
62
139
  const extractFormulaFromText = (value?: string): string => {
63
- if (!value) return '';
64
- const trimmed = value.trim();
140
+ if (!value) return ''
141
+ const trimmed = value.trim()
65
142
  for (const { start, end } of TEX_WRAPPERS) {
66
143
  if (trimmed.startsWith(start) && trimmed.endsWith(end)) {
67
- return stripTexDelimiters(trimmed);
144
+ return stripTexDelimiters(trimmed)
68
145
  }
69
146
  }
70
- return '';
71
- };
147
+ return ''
148
+ }
149
+
150
+ function parseStyle(style: string | undefined): { counterType: CounterType, decoratorType: DecoratorType } {
151
+ const normalized = style?.trim().toLowerCase() ?? ''
152
+ if (!normalized)
153
+ return { counterType: 'decimal', decoratorType: 'dot' }
154
+
155
+ if (normalized === 'none')
156
+ return { counterType: 'none', decoratorType: 'none' }
157
+
158
+ const counterKey = supportedCounters.find((candidate) => normalized === candidate || normalized.startsWith(`${candidate}-`))
159
+ const counterType = counterKey
160
+ ? counterAliases[counterKey]
161
+ : 'decimal'
162
+
163
+ const rawDecorator = counterKey && normalized.length > counterKey.length
164
+ ? normalized.slice(counterKey.length + 1)
165
+ : 'dot'
166
+
167
+ const decoratorType = supportedDecorators.includes(rawDecorator as DecoratorType)
168
+ ? rawDecorator as DecoratorType
169
+ : 'dot'
170
+
171
+ return { counterType, decoratorType }
172
+ }
173
+
174
+ function getTextContent(item: QuestionListItemObject | string | undefined) {
175
+ if (typeof item === 'string')
176
+ return item
177
+
178
+ return typeof item?.text === 'string'
179
+ ? item.text
180
+ : ''
181
+ }
182
+
183
+ function getStartIndex(counterType: CounterType, value: number | string | undefined) {
184
+ if (typeof value === 'number')
185
+ return value - 1
186
+
187
+ if (typeof value === 'string' && value.length > 0) {
188
+ const firstChar = value[0]
189
+ if (counterType === 'upperalpha')
190
+ return firstChar.toUpperCase().charCodeAt(0) - 'A'.charCodeAt(0)
191
+
192
+ if (counterType === 'loweralpha')
193
+ return firstChar.toLowerCase().charCodeAt(0) - 'a'.charCodeAt(0)
194
+ }
72
195
 
73
- const hasRenderableContent = (item: string | Record<string, any>): boolean => {
74
- if (isKatexItem(item)) return true;
75
- return Boolean(getItemText(item).trim());
76
- };
196
+ return 0
197
+ }
198
+
199
+ const hasRenderableContent = (item: QuestionListItem): boolean => {
200
+ if (isKatexItem(item)) return true
201
+ return Boolean(getItemText(item).trim())
202
+ }
77
203
 
78
- const normalizedItemType = (item: Record<string, any>): string => {
79
- if (typeof item.type !== 'string') return '';
80
- return item.type.toLowerCase();
81
- };
204
+ const normalizedItemType = (item: QuestionListItemObject): string => {
205
+ if (typeof item.type !== 'string') return ''
206
+ return item.type.toLowerCase()
207
+ }
82
208
 
83
- const getFormula = (item: string | Record<string, any>): string => {
84
- if (typeof item === 'object') {
209
+ const getFormula = (item: QuestionListItem): string => {
210
+ if (isObjectItem(item)) {
85
211
  if (typeof item.formula === 'string' && item.formula.trim()) {
86
- return stripTexDelimiters(item.formula);
212
+ return stripTexDelimiters(item.formula)
87
213
  }
88
- const hint = item.tex === true || ['katex', 'tex', 'math'].includes(normalizedItemType(item));
214
+ const hint = item.tex === true || ['katex', 'tex', 'math'].includes(normalizedItemType(item))
89
215
  if (hint && typeof item.text === 'string') {
90
- return stripTexDelimiters(item.text);
216
+ return stripTexDelimiters(item.text)
91
217
  }
92
- const extracted = extractFormulaFromText(item.text);
93
- return extracted;
218
+ return extractFormulaFromText(item.text)
94
219
  }
95
- return extractFormulaFromText(item) || '';
96
- };
220
+ return extractFormulaFromText(item) || ''
221
+ }
97
222
 
98
- const isKatexItem = (item: string | Record<string, any>): boolean => {
99
- return Boolean(getFormula(item));
100
- };
223
+ const isKatexItem = (item: QuestionListItem): boolean => Boolean(getFormula(item))
101
224
 
102
- const isBlockFormula = (item: string | Record<string, any>): boolean => {
103
- if (typeof item === 'object' && typeof item.block === 'boolean') {
104
- return item.block;
225
+ const isBlockFormula = (item: QuestionListItem): boolean => {
226
+ if (isObjectItem(item) && typeof item.block === 'boolean') {
227
+ return item.block
105
228
  }
106
229
 
107
- const source = typeof item === 'string'
108
- ? item
109
- : typeof item.formula === 'string'
230
+ const source = isObjectItem(item)
231
+ ? typeof item.formula === 'string'
110
232
  ? item.formula
111
233
  : typeof item.text === 'string'
112
234
  ? item.text
113
- : '';
114
- const trimmed = source.trim();
115
- if (!trimmed) return true;
116
- if (trimmed.startsWith('\\(') && trimmed.endsWith('\\)')) return false;
117
- if (trimmed.startsWith('$') && trimmed.endsWith('$') && !trimmed.startsWith('$$')) return false;
118
- if (trimmed.startsWith('$$') && trimmed.endsWith('$$')) return true;
119
- if (trimmed.startsWith('\\[') && trimmed.endsWith('\\]')) return true;
120
- return true;
121
- };
122
-
123
- const reservedItemKeys = new Set(['text', 'items', 'label', 'block', 'type', 'tex', 'formula', 'attrs']);
124
-
125
- const getKatexAttrs = (item: string | Record<string, any>): Record<string, any> => {
126
- if (typeof item !== 'object') {
127
- return {};
235
+ : ''
236
+ : item
237
+ const trimmed = source.trim()
238
+ if (!trimmed) return true
239
+ if (trimmed.startsWith('\\(') && trimmed.endsWith('\\)')) return false
240
+ if (trimmed.startsWith('$') && trimmed.endsWith('$') && !trimmed.startsWith('$$')) return false
241
+ if (trimmed.startsWith('$$') && trimmed.endsWith('$$')) return true
242
+ if (trimmed.startsWith('\\[') && trimmed.endsWith('\\]')) return true
243
+ return true
244
+ }
245
+
246
+ const reservedItemKeys = new Set(['text', 'items', 'label', 'block', 'type', 'tex', 'formula', 'attrs'])
247
+
248
+ const getKatexAttrs = (item: QuestionListItem): Record<string, unknown> => {
249
+ if (!isObjectItem(item)) {
250
+ return {}
128
251
  }
129
252
  if (item.attrs && typeof item.attrs === 'object') {
130
- return item.attrs;
253
+ return item.attrs
131
254
  }
132
- const attrs: Record<string, any> = {};
255
+ const attrs: Record<string, unknown> = {}
133
256
  Object.keys(item).forEach((key) => {
134
257
  if (!reservedItemKeys.has(key)) {
135
- attrs[key] = item[key];
258
+ attrs[key] = item[key]
136
259
  }
137
- });
138
- return attrs;
139
- };
260
+ })
261
+ return attrs
262
+ }
140
263
 
141
- const getItemText = (item: string | Record<string, any>): string =>
142
- (typeof item === 'string' ? item : item.text) || '';
264
+ const getItemText = (item: QuestionListItem): string => getTextContent(item)
143
265
 
144
- const hasSubItems = (item: string | Record<string, any>): boolean =>
145
- typeof item === 'object' && Array.isArray(item.items);
266
+ const hasSubItems = (item: QuestionListItem): boolean => isObjectItem(item) && Array.isArray(item.items)
146
267
 
147
- const getSubItems = (item: string | Record<string, any>): any[] =>
148
- (typeof item === 'object' && Array.isArray(item.items)) ? item.items : [];
268
+ const getSubItems = (item: QuestionListItem): QuestionListItem[] =>
269
+ isObjectItem(item) && Array.isArray(item.items)
270
+ ? item.items
271
+ : []
149
272
 
150
273
  const getLabel = (index: number): string => {
151
- const item = props.items[index];
152
- // 1. Custom item-specific labels override everything
153
- if (typeof item === 'object' && typeof item.label === 'string') {
154
- return item.label;
274
+ const item = props.items[index]
275
+ if (isObjectItem(item) && typeof item.label === 'string') {
276
+ return item.label
155
277
  }
156
278
 
157
- const style = props.styles[currentLevel.value] || 'decimal-dot';
158
- const [counterType, decoratorType] = style.split('-');
279
+ const { counterType, decoratorType } = parseStyle(props.styles[currentLevel.value])
280
+ if (counterType === 'none' || decoratorType === 'none')
281
+ return ''
159
282
 
160
- // 2. Calculate the counter
161
- const startIndex = props.start[currentLevel.value] ? (typeof props.start[currentLevel.value] === 'number' ? (props.start[currentLevel.value] as number) - 1 : String(props.start[currentLevel.value]).charCodeAt(0) - 'a'.charCodeAt(0)) : 0;
162
- const currentIndex = startIndex + index;
163
-
164
- let counter = '';
283
+ const startIndex = getStartIndex(counterType, props.start[currentLevel.value])
284
+ const currentIndex = startIndex + index
285
+
286
+ let counter = ''
165
287
  switch (counterType) {
166
288
  case 'hiragana':
167
- counter = toHiragana(currentIndex); break;
289
+ counter = toHiragana(currentIndex)
290
+ break
168
291
  case 'katakana':
169
- counter = toKatakana(currentIndex); break;
292
+ counter = toKatakana(currentIndex)
293
+ break
170
294
  case 'kanji':
171
- counter = toKanji(currentIndex + 1); break;
295
+ counter = toKanji(currentIndex + 1)
296
+ break
172
297
  case 'upperalpha':
173
- counter = String.fromCharCode('A'.charCodeAt(0) + currentIndex); break;
298
+ counter = String.fromCharCode('A'.charCodeAt(0) + currentIndex)
299
+ break
174
300
  case 'loweralpha':
175
- counter = String.fromCharCode('a'.charCodeAt(0) + currentIndex); break;
301
+ counter = String.fromCharCode('a'.charCodeAt(0) + currentIndex)
302
+ break
176
303
  case 'decimal':
177
- counter = (currentIndex + 1).toString(); break;
178
- case 'none':
179
- return ''; // No label
304
+ counter = (currentIndex + 1).toString()
305
+ break
180
306
  default:
181
- counter = (currentIndex + 1).toString();
307
+ counter = (currentIndex + 1).toString()
182
308
  }
183
309
 
184
- // 3. Apply decorator
185
310
  switch (decoratorType) {
186
311
  case 'circle':
187
- return `&#x${(9311 + currentIndex + 1).toString(16)};`; // ①, ②, ...
312
+ return circledNumbers[currentIndex] ?? `${counter}.`
188
313
  case 'square':
189
- return `[${counter}]`;
314
+ return `[${counter}]`
190
315
  case 'paren':
191
- return `(${counter})`;
316
+ return `(${counter})`
192
317
  case 'dot':
193
- return `${counter}.`;
318
+ return `${counter}.`
194
319
  case 'q':
195
- return `問${counter}`;
320
+ return `問${counter}`
196
321
  case 'big-q':
197
- return `大問${counter}`;
198
- case 'none':
199
- return '';
322
+ return `大問${counter}`
200
323
  default:
201
- return counter;
324
+ return counter
202
325
  }
203
- };
326
+ }
327
+
328
+ const renderItemText = (item: QuestionListItem) => renderInlineMarkdown(getItemText(item))
204
329
 
205
- // --- Helper functions for counters ---
206
330
  const toKatakana = (n: number) => {
207
- const katakana = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン';
208
- return katakana[n % katakana.length];
209
- };
331
+ const katakana = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン'
332
+ return katakana[((n % katakana.length) + katakana.length) % katakana.length]
333
+ }
334
+
210
335
  const toHiragana = (n: number) => {
211
- const hiragana = 'あいうえおかきくけこさしすせそたちつてとなにぬねのはひふへほまみむめもやゆよらりるれろわをん';
212
- return hiragana[n % hiragana.length];
213
- };
336
+ const hiragana = 'あいうえおかきくけこさしすせそたちつてとなにぬねのはひふへほまみむめもやゆよらりるれろわをん'
337
+ return hiragana[((n % hiragana.length) + hiragana.length) % hiragana.length]
338
+ }
339
+
214
340
  const toKanji = (n: number) => {
215
- const kanji = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'];
216
- if (n <= 10) return kanji[n - 1];
217
- // Basic support for > 10
218
- if (n <= 19) return '十' + kanji[n - 11];
219
- return n.toString(); // Fallback
220
- };
341
+ const kanji = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十']
342
+ if (n <= 0) return n.toString()
343
+ if (n <= 10) return kanji[n - 1]
344
+ if (n < 20) return `十${kanji[n - 11]}`
345
+ return n.toString()
346
+ }
221
347
 
222
348
  </script>
223
349
 
@@ -237,7 +363,7 @@ const toKanji = (n: number) => {
237
363
  font-weight: bold;
238
364
  color: var(--slidev-theme-primary);
239
365
  margin-right: 0.75em;
240
- min-width: 2em; /* Allocate space for label */
366
+ min-width: 2em;
241
367
  text-align: right;
242
368
  }
243
369
  .item-text {
@@ -88,22 +88,22 @@ const computedClasses = computed(() => {
88
88
 
89
89
  /* Text color variants */
90
90
  .text-blue {
91
- color: #3b82f6 !important;
91
+ color: #3b82f6;
92
92
  }
93
93
 
94
94
  .text-green {
95
- color: #10b981 !important;
95
+ color: #10b981;
96
96
  }
97
97
 
98
98
  .text-red {
99
- color: #ef4444 !important;
99
+ color: #ef4444;
100
100
  }
101
101
 
102
102
  .text-purple {
103
- color: #8b5cf6 !important;
103
+ color: #8b5cf6;
104
104
  }
105
105
 
106
106
  .text-orange {
107
- color: #f97316 !important;
107
+ color: #f97316;
108
108
  }
109
109
  </style>
package/global-bottom.vue CHANGED
@@ -1,18 +1,26 @@
1
1
  <script setup lang="ts">
2
- import { watchEffect } from 'vue'
3
- import { useSlideContext } from '@slidev/client'
2
+ import { computed, watchEffect } from 'vue'
3
+ import { useNav, useSlideContext } from '@slidev/client'
4
4
 
5
5
  import { useActiveThemePalette } from './utils/useThemePalette'
6
6
  import getLink from './utils/link'
7
7
 
8
- const { $frontmatter, $nav, $slidev } = useSlideContext()
8
+ const { $nav, $slidev } = useSlideContext()
9
+ const { currentSlideRoute } = useNav()
9
10
 
10
- const link = getLink({
11
- frontmatter: $frontmatter,
11
+ const currentFrontmatter = computed<Record<string, unknown>>(
12
+ () => currentSlideRoute.value.meta?.slide?.frontmatter ?? {},
13
+ )
14
+
15
+ const link = computed(() => getLink({
16
+ frontmatter: currentFrontmatter.value,
12
17
  slidevConfigs: $slidev?.configs as Record<string, unknown> | undefined,
13
- })
18
+ }))
14
19
 
15
- const { palette: currentPalette } = useActiveThemePalette(() => $frontmatter.color)
20
+ const { palette: currentPalette } = useActiveThemePalette({
21
+ slidevConfigs: $slidev?.configs as Record<string, unknown> | undefined,
22
+ slideValue: () => currentFrontmatter.value.color,
23
+ })
16
24
 
17
25
  watchEffect(() => {
18
26
  if (typeof document === 'undefined')
package/layouts/cover.vue CHANGED
@@ -11,7 +11,15 @@
11
11
  {{ $frontmatter.author }}
12
12
  </div>
13
13
  <div class="wave">
14
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 700" width="100%" height="100%" preserveAspectRatio="none">
14
+ <svg
15
+ xmlns="http://www.w3.org/2000/svg"
16
+ viewBox="0 0 1280 700"
17
+ width="100%"
18
+ height="100%"
19
+ preserveAspectRatio="none"
20
+ aria-hidden="true"
21
+ focusable="false"
22
+ >
15
23
  <defs>
16
24
  <linearGradient id="wave-1754178473929" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="700" y2="700" gradientTransform="scale(1.83, 1)">
17
25
  <stop offset="0%" :stop-color="currentPalette.gradientStart"/>
@@ -30,9 +38,12 @@ import { useSlideContext } from '@slidev/client'
30
38
 
31
39
  import { useActiveThemePalette } from '../utils/useThemePalette'
32
40
 
33
- const { $frontmatter } = useSlideContext()
41
+ const { $frontmatter, $slidev } = useSlideContext()
34
42
 
35
- const { palette: currentPalette } = useActiveThemePalette(() => $frontmatter.color)
43
+ const { palette: currentPalette } = useActiveThemePalette({
44
+ slidevConfigs: $slidev?.configs as Record<string, unknown> | undefined,
45
+ slideValue: () => $frontmatter.color,
46
+ })
36
47
 
37
48
  const cssVars = computed(() => ({
38
49
  '--slidev-theme-primary': currentPalette.value.primary,
@@ -32,7 +32,9 @@ import { useSlideContext } from '@slidev/client'
32
32
 
33
33
  const { $frontmatter } = useSlideContext()
34
34
 
35
- const imageSrc = computed(() => $frontmatter.image)
35
+ const imageSrc = computed(() => typeof $frontmatter.image === 'string'
36
+ ? $frontmatter.image
37
+ : '')
36
38
 
37
39
  const wrapperRef = ref<HTMLElement | null>(null)
38
40
  const scrollerRef = ref<HTMLElement | null>(null)
package/layouts/image.vue CHANGED
@@ -1,8 +1,8 @@
1
1
  <template>
2
2
  <div class="slidev-layout image">
3
- <img
4
- v-if="$frontmatter.image"
5
- :src="$frontmatter.image"
3
+ <img
4
+ v-if="imageSrc"
5
+ :src="imageSrc"
6
6
  class="background-image"
7
7
  alt="Background"
8
8
  />
@@ -13,8 +13,14 @@
13
13
  </template>
14
14
 
15
15
  <script setup lang="ts">
16
+ import { computed } from 'vue'
16
17
  import { useSlideContext } from '@slidev/client'
18
+
17
19
  const { $frontmatter } = useSlideContext()
20
+
21
+ const imageSrc = computed(() => typeof $frontmatter.image === 'string'
22
+ ? $frontmatter.image
23
+ : '')
18
24
  </script>
19
25
 
20
26
  <style scoped>
package/package.json CHANGED
@@ -1,38 +1,67 @@
1
1
  {
2
2
  "name": "slidev-theme-watabegg",
3
- "version": "1.1.3",
3
+ "version": "1.1.4",
4
4
  "type": "module",
5
+ "packageManager": "pnpm@11.1.3",
5
6
  "author": "watabegg",
6
- "description": "Slidev theme for educational purposes, especially teaching.",
7
+ "description": "A personal Slidev theme for watabegg.",
7
8
  "license": "MIT",
9
+ "homepage": "https://github.com/watabegg/slidev-theme-watabegg#readme",
10
+ "bugs": {
11
+ "url": "https://github.com/watabegg/slidev-theme-watabegg/issues"
12
+ },
8
13
  "keywords": [
9
14
  "slidev-theme",
10
15
  "slidev",
11
- "education",
12
- "exam",
13
- "teaching"
16
+ "watabegg",
17
+ "personal-theme"
14
18
  ],
15
19
  "engines": {
16
- "node": ">=18.0.0"
20
+ "node": ">=20.12.0",
21
+ "slidev": ">=52.15.2"
17
22
  },
18
23
  "repository": {
19
24
  "type": "git",
20
25
  "url": "https://github.com/watabegg/slidev-theme-watabegg"
21
26
  },
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "files": [
31
+ "components",
32
+ "layouts",
33
+ "setup",
34
+ "styles",
35
+ "utils",
36
+ "global-bottom.vue"
37
+ ],
22
38
  "scripts": {
23
39
  "build": "slidev build example.md",
24
40
  "dev": "slidev example.md",
41
+ "dev:polling": "CHOKIDAR_USEPOLLING=1 slidev example.md",
42
+ "typecheck": "vue-tsc --noEmit -p tsconfig.json",
43
+ "lint": "biome lint .",
44
+ "format": "biome format --write .",
45
+ "format:check": "biome format .",
46
+ "check": "pnpm typecheck && pnpm lint && pnpm format:check && pnpm build && pnpm pack --dry-run",
47
+ "audit:prod": "pnpm audit --prod --audit-level=high",
25
48
  "export": "slidev export example.md",
26
49
  "screenshot": "slidev export example.md --format png"
27
50
  },
28
51
  "dependencies": {
29
- "@slidev/types": "^52.1.0",
30
- "katex": "^0.16.25"
52
+ "@slidev/types": "^52.15.2",
53
+ "katex": "^0.16.47",
54
+ "marked": "^18.0.4"
31
55
  },
32
56
  "devDependencies": {
33
- "@slidev/cli": "^52.1.0",
34
- "bumpp": "^10.3.2",
35
- "playwright-chromium": "^1.54.2"
57
+ "@biomejs/biome": "^2.4.15",
58
+ "@slidev/cli": "^52.15.2",
59
+ "@slidev/client": "^52.15.2",
60
+ "bumpp": "^11.1.0",
61
+ "playwright-chromium": "^1.60.0",
62
+ "typescript": "^6.0.3",
63
+ "vue": "^3.5.34",
64
+ "vue-tsc": "^3.3.1"
36
65
  },
37
66
  "//": "Learn more: https://sli.dev/guide/write-theme.html",
38
67
  "slidev": {
@@ -46,4 +75,4 @@
46
75
  "lineNumbers": false
47
76
  }
48
77
  }
49
- }
78
+ }
@@ -0,0 +1,3 @@
1
+ declare const css: string
2
+
3
+ export default css
@@ -0,0 +1,3 @@
1
+ declare const css: string
2
+
3
+ export default css
package/utils/colors.ts CHANGED
@@ -43,8 +43,7 @@ export const themePalettes: Record<ThemeColorKey, ThemePalette> = {
43
43
  }
44
44
 
45
45
  export function resolveThemeColor(rawValue: unknown): ThemeColorKey {
46
- if (typeof rawValue !== 'string')
47
- return defaultThemeColor
46
+ if (typeof rawValue !== 'string') return defaultThemeColor
48
47
 
49
48
  const normalized = rawValue.trim().toLowerCase() as ThemeColorKey
50
49
  return normalized in themePalettes ? normalized : defaultThemeColor
@@ -54,4 +53,3 @@ export function getThemePalette(rawValue: unknown): ThemePalette {
54
53
  const key = resolveThemeColor(rawValue)
55
54
  return themePalettes[key]
56
55
  }
57
-
package/utils/link.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { toSafeUrl } from './safeUrl'
2
+ import { getWatabeggThemeConfig } from './themeConfig'
3
+
1
4
  const getLink = ({
2
5
  frontmatter,
3
6
  slidevConfigs,
@@ -5,13 +8,13 @@ const getLink = ({
5
8
  frontmatter: Record<string, unknown>
6
9
  slidevConfigs: Record<string, unknown> | undefined
7
10
  }) => {
8
- if (typeof frontmatter.link === 'string' && frontmatter.link.length > 0)
9
- return frontmatter.link
10
-
11
- const globalLink = slidevConfigs?.['link']
12
- return typeof globalLink === 'string' && globalLink.length > 0
13
- ? globalLink
14
- : undefined
11
+ const frontmatterLink = toSafeUrl(frontmatter.link)
12
+ if (frontmatterLink) return frontmatterLink
13
+
14
+ const themeConfigLink = toSafeUrl(getWatabeggThemeConfig(slidevConfigs).link)
15
+ if (themeConfigLink) return themeConfigLink
16
+
17
+ return toSafeUrl(slidevConfigs?.link)
15
18
  }
16
19
 
17
- export default getLink
20
+ export default getLink
package/utils/render.ts CHANGED
@@ -1,5 +1,43 @@
1
- import { marked } from 'marked';
1
+ import { Marked } from 'marked'
2
2
 
3
- export const renderMarkdown = (text: string) => ({
4
- template: `<div class="inline-block">${marked.parse(text)}</div>`
5
- });
3
+ import { toSafeUrl } from './safeUrl'
4
+
5
+ function escapeHtml(value: string) {
6
+ return value
7
+ .replaceAll('&', '&amp;')
8
+ .replaceAll('<', '&lt;')
9
+ .replaceAll('>', '&gt;')
10
+ .replaceAll('"', '&quot;')
11
+ .replaceAll("'", '&#39;')
12
+ }
13
+
14
+ const inlineMarkdown = new Marked({
15
+ async: false,
16
+ gfm: true,
17
+ renderer: {
18
+ image(token) {
19
+ const src = toSafeUrl(token.href)
20
+ const alt = escapeHtml(token.text)
21
+ const title =
22
+ typeof token.title === 'string' && token.title ? ` title="${escapeHtml(token.title)}"` : ''
23
+
24
+ if (!src) return alt
25
+
26
+ return `<img src="${escapeHtml(src)}" alt="${alt}"${title}>`
27
+ },
28
+ link(token) {
29
+ const href = toSafeUrl(token.href)
30
+ const text = this.parser.parseInline(token.tokens)
31
+ const title =
32
+ typeof token.title === 'string' && token.title ? ` title="${escapeHtml(token.title)}"` : ''
33
+
34
+ if (!href) return text
35
+
36
+ return `<a href="${escapeHtml(href)}"${title} rel="noopener noreferrer">${text}</a>`
37
+ },
38
+ },
39
+ })
40
+
41
+ export function renderInlineMarkdown(text: string) {
42
+ return inlineMarkdown.parseInline(escapeHtml(text))
43
+ }
@@ -0,0 +1,27 @@
1
+ const SAFE_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:'])
2
+
3
+ function isRelativeUrl(value: string) {
4
+ return (
5
+ value.startsWith('#') ||
6
+ value.startsWith('/') ||
7
+ value.startsWith('./') ||
8
+ value.startsWith('../') ||
9
+ value.startsWith('?')
10
+ )
11
+ }
12
+
13
+ export function toSafeUrl(value: unknown): string | undefined {
14
+ if (typeof value !== 'string') return undefined
15
+
16
+ const trimmed = value.trim()
17
+ if (!trimmed) return undefined
18
+
19
+ if (isRelativeUrl(trimmed)) return trimmed
20
+
21
+ try {
22
+ const url = new URL(trimmed)
23
+ return SAFE_PROTOCOLS.has(url.protocol) ? url.toString() : undefined
24
+ } catch {
25
+ return undefined
26
+ }
27
+ }
@@ -0,0 +1,18 @@
1
+ export interface WatabeggThemeConfig {
2
+ color?: unknown
3
+ link?: unknown
4
+ }
5
+
6
+ function isRecord(value: unknown): value is Record<string, unknown> {
7
+ return typeof value === 'object' && value !== null
8
+ }
9
+
10
+ export function getWatabeggThemeConfig(
11
+ slidevConfigs: Record<string, unknown> | undefined,
12
+ ): WatabeggThemeConfig {
13
+ const themeConfig = slidevConfigs?.themeConfig
14
+ if (!isRecord(themeConfig)) return {}
15
+
16
+ const watabegg = themeConfig.watabegg
17
+ return isRecord(watabegg) ? watabegg : {}
18
+ }
@@ -1,26 +1,22 @@
1
- import { computed, ref, watchEffect } from 'vue'
1
+ import { computed } from 'vue'
2
2
 
3
3
  import {
4
4
  defaultThemeColor,
5
+ getThemePalette,
5
6
  resolveThemeColor,
6
- themePalettes,
7
7
  type ThemeColorKey,
8
8
  type ThemePalette,
9
9
  } from './colors'
10
-
11
- const activeColorKey = ref<ThemeColorKey>(defaultThemeColor)
10
+ import { getWatabeggThemeConfig } from './themeConfig'
12
11
 
13
12
  function normalizeColor(value: unknown): ThemeColorKey | undefined {
14
- if (value === undefined)
15
- return undefined
13
+ if (value === undefined) return undefined
16
14
 
17
- if (value === null)
18
- return defaultThemeColor
15
+ if (value === null) return defaultThemeColor
19
16
 
20
17
  if (typeof value === 'string') {
21
18
  const trimmed = value.trim()
22
- if (!trimmed)
23
- return defaultThemeColor
19
+ if (!trimmed) return defaultThemeColor
24
20
 
25
21
  return resolveThemeColor(trimmed)
26
22
  }
@@ -28,22 +24,22 @@ function normalizeColor(value: unknown): ThemeColorKey | undefined {
28
24
  return defaultThemeColor
29
25
  }
30
26
 
31
- export function useActiveThemePalette(source: () => unknown) {
32
- watchEffect(() => {
33
- const normalized = normalizeColor(source())
34
- if (normalized !== undefined)
35
- activeColorKey.value = normalized
27
+ export function useActiveThemePalette({
28
+ slidevConfigs,
29
+ slideValue,
30
+ }: {
31
+ slidevConfigs?: Record<string, unknown>
32
+ slideValue: () => unknown
33
+ }) {
34
+ const colorKey = computed<ThemeColorKey>(() => {
35
+ const frontmatterColor = normalizeColor(slideValue())
36
+ if (frontmatterColor !== undefined) return frontmatterColor
37
+
38
+ const themeConfigColor = normalizeColor(getWatabeggThemeConfig(slidevConfigs).color)
39
+ return themeConfigColor ?? defaultThemeColor
36
40
  })
37
41
 
38
- const palette = computed<ThemePalette>(() => themePalettes[activeColorKey.value])
42
+ const palette = computed<ThemePalette>(() => getThemePalette(colorKey.value))
39
43
 
40
- return {
41
- palette,
42
- colorKey: computed(() => activeColorKey.value),
43
- }
44
+ return { palette, colorKey }
44
45
  }
45
-
46
- export function getActiveThemePalette(): ThemePalette {
47
- return themePalettes[activeColorKey.value]
48
- }
49
-
package/example.md DELETED
@@ -1,197 +0,0 @@
1
- ---
2
- theme: ./
3
- title: Theme Demo
4
- subtitle: slidev-theme-watabeggの紹介
5
- author: watabegg
6
- date: '2025/08/03'
7
- link: 'https://example.com'
8
- color: blue
9
- transition: fade
10
- ---
11
-
12
- # 基本機能とスタイル
13
-
14
- このスライドではテーマの基本スタイルをテストします。
15
-
16
- - **太字** / *斜体* / `code`
17
- - 入れ子リスト
18
- - 第二階層
19
- - もう一つ
20
- - テーマカラーは frontmatter の `color` で `red | yellow | green | blue | purple` から選択
21
-
22
- ```ts
23
- function hello(name: string) {
24
- console.log(`Hello, ${name}`)
25
- }
26
- ```
27
-
28
- > ヒント: 最初の h1 は`image`, `image-scroll`Layout以外で固定ヘッダになります。
29
-
30
- ---
31
- layout: two-cols
32
- title: 2カラムレイアウト
33
- ---
34
-
35
- ::left::
36
- ### 左側
37
- - ポイント1
38
- - ポイント2
39
- - ポイント3
40
-
41
- 固定ヘッダとフッターの余白を意識した構造。
42
-
43
- ::right::
44
- ### 右側
45
- ```js
46
- const nums = [1,2,3]
47
- const doubled = nums.map(n=>n*2)
48
- console.log(doubled)
49
- ```
50
- 図表 / コード / 説明などを分割表示。
51
-
52
- ---
53
-
54
- # QuestionList 基本
55
-
56
- <QuestionList
57
- :items="[
58
- '正答のためのリストコンポーネント **Markdown OK**',
59
- {
60
- text: '2番目 (子を含む)',
61
- items: [
62
- { label: 'A', text: 'A の内容'},
63
- { label: 'B', text: 'B の内容'},
64
- { text: 'さらにネスト', items: ['深い1', '深い2'] }
65
- ]
66
- },
67
- { label: '★', text: 'labelスタイルも複数用意しカスタムも可能。' }
68
- ]"
69
- :styles="['decimal-circle','katakana-paren','loweralpha-dot']"
70
- />
71
-
72
- ---
73
-
74
- # QuestionList start 指定
75
-
76
- 配列で各階層の開始番号/文字を指定可能。
77
-
78
- <QuestionList
79
- :items="[
80
- { text: '大問1: サブ', items: ['1つ目','2つ目'] },
81
- { text: '大問2: サブ', items: ['A','B','C'] }
82
- ]"
83
- :styles="['decimal-q','hiragana-paren']"
84
- :start="[1,1]"
85
- />
86
-
87
- ---
88
-
89
- # KaTeX と QuestionList
90
-
91
- KaTeX コンポーネント `<KaTexReveal>` を QuestionList 内で使用可能。
92
-
93
- <KaTexReveal formula="\\int_0^{2\\pi} \\sin x\\,dx = 0" block class="text-2xl" />
94
-
95
- <KaTexReveal
96
- formula="E = mc^2"
97
- :block="false"
98
- class="text-primary font-bold"
99
- v-click="1"
100
- />
101
-
102
- ---
103
- layout: center
104
- ---
105
-
106
- # KaTeX と Markdown の混在
107
-
108
- <QuestionList
109
- :items="[
110
- {
111
- label: '①',
112
- text: 'Markdownと **KaTeX** を混在',
113
- items: [
114
- { formula: 'a^2 + b^2 = c^2', block: true, class: 'text-lg text-center', tex: true },
115
- { text: '$$\\frac{d}{dx} \\sin x = \\cos x$$' }
116
- ]
117
- },
118
- {
119
- label: '②',
120
- formula: '\\sum_{k=1}^n k = \\frac{n(n+1)}{2}',
121
- block: false,
122
- class: 'text-xl'
123
- }
124
- ]"
125
- :styles="['decimal-circle','loweralpha-paren']"
126
- :start="[1]"
127
- />
128
-
129
- ---
130
- layout: image
131
- image: 'https://images.unsplash.com/photo-1501594907352-04cda38ebc29?auto=format&fit=crop&w=2370&q=80'
132
- ---
133
-
134
- <TextBox :x="80" :y="140" :width="360" v-click="1">1番目に表示される注釈。</TextBox>
135
- <TextBox :x="200" :y="380" :width="340" textBg="green" v-click="2">背景色付き 2番目。</TextBox>
136
- <TextBox :x="500" :y="120" :width="300" color="red">常時表示 (赤文字)。</TextBox>
137
- <TextBox :x="40" :y="20" :width="420" textBg="yellow" v-click="3">最後に表示される黄色背景。</TextBox>
138
-
139
- ---
140
- layout: image-scroll
141
- image: 'https://images.unsplash.com/photo-1501785888041-af3ef285b470?auto=format&fit=crop&w=2400&q=80'
142
- imageScroll:
143
- offsetY: -120
144
- ---
145
-
146
- ## image-scroll: 縦長画像を「横幅フィット+縦スクロール」で表示
147
-
148
- - 画像はスライド幅に合わせて表示
149
- - 縦方向はホイール / タッチでスクロール
150
- - `offsetY` で初期位置を調整(中心からのpx)
151
-
152
- ここでは `offsetY: -120` で少し上寄せしています。
153
-
154
- ---
155
- layout: image-scroll
156
- image: 'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=2400&q=80'
157
- ---
158
-
159
- ## offsetY の例(中心から上へ 300px)
160
-
161
- 背景の見せたい位置が決まっているときに便利です。
162
-
163
- ---
164
-
165
- # ユーティリティ
166
-
167
- 普通のテキストと <span class="text-highlight">ハイライト</span>。
168
-
169
- <div class="card mt-8" v-click="1">
170
- <h3>カードスタイル</h3>
171
- <p>重要事項をまとめるのに便利です。</p>
172
- </div>
173
-
174
- ---
175
-
176
- # フッターとショートカット
177
-
178
- このテーマでは Enter / Backspace キーで進行を制御できます。
179
-
180
- - Enter: 次のスライド / v-click
181
- - Backspace: 前へ戻る
182
- - フッター: (cover / image / image-scroll 以外) 日付 + ページ番号
183
-
184
- ---
185
-
186
- # まとめ
187
-
188
- - レイアウト: cover / two-cols / image / image-scroll / end
189
- - コンポーネント: QuestionList / TextBox / KaTexReveal
190
- - 自動フッター & 固定ヘッダ
191
- - ラベルスタイル多彩 & Markdown 埋め込み
192
-
193
- ご利用ありがとうございます
194
-
195
- ---
196
- layout: end
197
- ---