slidev-theme-watabegg 1.1.1 → 1.1.2

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
@@ -87,6 +87,29 @@ image: /path/to/long-bg.jpg
87
87
 
88
88
  ![image-scroll 例](https://raw.githubusercontent.com/watabegg/slidev-theme-watabegg/refs/heads/main/example/7.png)
89
89
 
90
+ image-scroll オプション(frontmatter `imageScroll`):
91
+ - `fit`: `contain | cover | width | height`(既定 `contain`)
92
+ - `position`: `center | top | bottom | left | right` もしくは組み合わせ(例: `top left`)
93
+ - `offset`: `[x, y]` または `{ x, y }`(px)
94
+
95
+ 操作:
96
+ - Wheel: 縦パン
97
+ - Shift + Wheel: 横パン
98
+ - Alt + Wheel: 10% ずつ拡大縮小
99
+ - タッチ: ドラッグでパン、ピンチでズーム
100
+
101
+ 初期フィット/位置の例:
102
+ ```markdown
103
+ ---
104
+ layout: image-scroll
105
+ image: /path/to/long-bg.jpg
106
+ imageScroll:
107
+ fit: width
108
+ position: center
109
+ offset: [0, -120]
110
+ ---
111
+ ```
112
+
90
113
  ## コンポーネント
91
114
  ### QuestionList
92
115
  入れ子質問/解答・Markdown 埋め込み・階層別スタイル。
package/README.md CHANGED
@@ -91,6 +91,29 @@ image: /path/to/long-bg.jpg
91
91
 
92
92
  ![image-scroll Example](https://raw.githubusercontent.com/watabegg/slidev-theme-watabegg/refs/heads/main/example/7.png)
93
93
 
94
+ image-scroll options (frontmatter `imageScroll`):
95
+ - `fit`: `contain | cover | width | height` (default `contain`)
96
+ - `position`: `center | top | bottom | left | right` or combined (e.g. `top left`)
97
+ - `offset`: `[x, y]` or `{ x, y }` in px
98
+
99
+ Controls:
100
+ - Wheel: pan vertically
101
+ - Shift + Wheel: pan horizontally
102
+ - Alt + Wheel: zoom in/out by 10%
103
+ - Touch: drag to pan, pinch to zoom
104
+
105
+ Example with initial fit/position:
106
+ ```markdown
107
+ ---
108
+ layout: image-scroll
109
+ image: /path/to/long-bg.jpg
110
+ imageScroll:
111
+ fit: width
112
+ position: center
113
+ offset: [0, -120]
114
+ ---
115
+ ```
116
+
94
117
  ## Components
95
118
  ### QuestionList
96
119
  Nested questions/answers with per-level styles and Markdown rendering.
package/example.md CHANGED
@@ -139,6 +139,9 @@ image: 'https://images.unsplash.com/photo-1501594907352-04cda38ebc29?auto=format
139
139
  ---
140
140
  layout: image-scroll
141
141
  image: 'https://images.unsplash.com/photo-1501785888041-af3ef285b470?auto=format&fit=crop&w=2400&q=80'
142
+ imageScroll:
143
+ fit: width
144
+ position: center
142
145
  ---
143
146
 
144
147
  - マウスホイール: 拡大
@@ -3,16 +3,23 @@
3
3
  class="slidev-layout image-scroll"
4
4
  ref="wrapperRef"
5
5
  @wheel="onWheel"
6
- @mousedown="onMouseDown"
6
+ @pointerdown="onPointerDown"
7
+ @pointermove="onPointerMove"
8
+ @pointerup="onPointerUp"
9
+ @pointercancel="onPointerUp"
7
10
  >
8
11
  <div class="image-layer" :class="{ 'is-panning': isPanning }">
9
- <img
10
- v-if="imageSrc"
11
- :src="imageSrc"
12
- alt="Scrollable background"
13
- :style="imageStyle"
14
- draggable="false"
15
- />
12
+ <div class="image-translate" :style="translateStyle">
13
+ <img
14
+ v-if="imageSrc"
15
+ ref="imageRef"
16
+ :src="imageSrc"
17
+ alt="Scrollable background"
18
+ :style="imageStyle"
19
+ draggable="false"
20
+ @load="onImageLoad"
21
+ />
22
+ </div>
16
23
  </div>
17
24
  <div class="content">
18
25
  <slot />
@@ -29,85 +36,344 @@ const { $frontmatter } = useSlideContext()
29
36
  const imageSrc = computed(() => $frontmatter.image)
30
37
 
31
38
  const wrapperRef = ref<HTMLElement | null>(null)
32
- const scale = ref(1)
33
- const position = reactive({ x: 0, y: 0 })
39
+ const imageRef = ref<HTMLImageElement | null>(null)
40
+
41
+ type FitMode = 'contain' | 'cover' | 'width' | 'height'
42
+
43
+ type ImageScrollFrontmatter = {
44
+ fit?: FitMode | string
45
+ position?: string
46
+ offset?: [number, number] | { x?: number; y?: number }
47
+ offsetX?: number
48
+ offsetY?: number
49
+ }
50
+
51
+ const imageScrollOptions = computed<ImageScrollFrontmatter>(() => {
52
+ const raw = $frontmatter.imageScroll
53
+ if (raw && typeof raw === 'object') return raw as ImageScrollFrontmatter
54
+ return {}
55
+ })
56
+
57
+ const fitMode = computed<FitMode>(() => {
58
+ const value = imageScrollOptions.value.fit
59
+ if (!value) return 'contain'
60
+ const normalized = String(value).toLowerCase()
61
+ if (normalized === 'cover') return 'cover'
62
+ if (normalized === 'width' || normalized === 'fit-width') return 'width'
63
+ if (normalized === 'height' || normalized === 'fit-height') return 'height'
64
+ return 'contain'
65
+ })
66
+
67
+ const positionMode = computed(() => imageScrollOptions.value.position ?? 'center')
68
+
69
+ const offsetMode = computed(() => {
70
+ const raw = imageScrollOptions.value
71
+ const offset = raw.offset
72
+ let x = Number(raw.offsetX ?? 0)
73
+ let y = Number(raw.offsetY ?? 0)
74
+ if (Array.isArray(offset)) {
75
+ x = Number(offset[0] ?? x)
76
+ y = Number(offset[1] ?? y)
77
+ } else if (offset && typeof offset === 'object') {
78
+ x = Number((offset as { x?: number }).x ?? x)
79
+ y = Number((offset as { y?: number }).y ?? y)
80
+ }
81
+ return { x, y }
82
+ })
83
+
84
+ const baseTransform = reactive({ x: 0, y: 0, scale: 1 })
85
+ const userTransform = reactive({ x: 0, y: 0, scale: 1 })
86
+
87
+ const imageSize = reactive({ width: 0, height: 0 })
88
+ const wrapperSize = reactive({ width: 0, height: 0 })
89
+
34
90
  const isPanning = ref(false)
35
91
  const lastPointer = reactive({ x: 0, y: 0 })
92
+ const activePointers = new Map<number, { x: number; y: number }>()
93
+ const pinchState = reactive({
94
+ active: false,
95
+ startDistance: 0,
96
+ startUserScale: 1,
97
+ anchorImagePoint: { x: 0, y: 0 },
98
+ })
36
99
 
37
100
  const MIN_SCALE = 0.4
38
101
  const MAX_SCALE = 4
39
- const ZOOM_STEP = 0.12
102
+ const ZOOM_STEP = 0.1
40
103
 
41
104
  const clamp = (val: number, min: number, max: number) => Math.min(max, Math.max(min, val))
42
105
 
43
106
  const resetTransform = () => {
44
- scale.value = 1
45
- position.x = 0
46
- position.y = 0
107
+ baseTransform.scale = 1
108
+ baseTransform.x = 0
109
+ baseTransform.y = 0
110
+ userTransform.scale = 1
111
+ userTransform.x = 0
112
+ userTransform.y = 0
113
+ }
114
+
115
+ const panBy = (dx: number, dy: number) => {
116
+ userTransform.x += dx
117
+ userTransform.y += dy
118
+ }
119
+
120
+ const getWrapperPoint = (event: { clientX: number; clientY: number }) => {
121
+ const rect = wrapperRef.value?.getBoundingClientRect()
122
+ if (!rect) return { x: 0, y: 0 }
123
+ return { x: event.clientX - rect.left, y: event.clientY - rect.top }
124
+ }
125
+
126
+ const getTotalTransform = () => ({
127
+ x: baseTransform.x + userTransform.x,
128
+ y: baseTransform.y + userTransform.y,
129
+ scale: baseTransform.scale * userTransform.scale,
130
+ })
131
+
132
+ const getImagePoint = (anchor: { x: number; y: number }, total: { x: number; y: number; scale: number }) => ({
133
+ x: (anchor.x - total.x) / total.scale,
134
+ y: (anchor.y - total.y) / total.scale,
135
+ })
136
+
137
+ const applyScaleAtPoint = (nextUserScale: number, anchor: { x: number; y: number }, anchorImagePoint?: { x: number; y: number }) => {
138
+ const currentTotal = getTotalTransform()
139
+ const imagePoint = anchorImagePoint ?? getImagePoint(anchor, currentTotal)
140
+ const nextTotalScale = baseTransform.scale * nextUserScale
141
+ const nextTotalX = anchor.x - imagePoint.x * nextTotalScale
142
+ const nextTotalY = anchor.y - imagePoint.y * nextTotalScale
143
+ userTransform.scale = nextUserScale
144
+ userTransform.x = nextTotalX - baseTransform.x
145
+ userTransform.y = nextTotalY - baseTransform.y
146
+ }
147
+
148
+ const normalizeWheel = (event: WheelEvent) => {
149
+ let { deltaX, deltaY } = event
150
+ if (event.deltaMode === 1) {
151
+ deltaX *= 16
152
+ deltaY *= 16
153
+ } else if (event.deltaMode === 2) {
154
+ deltaX *= wrapperSize.height || 1
155
+ deltaY *= wrapperSize.height || 1
156
+ }
157
+ return { deltaX, deltaY }
47
158
  }
48
159
 
49
160
  const onWheel = (event: WheelEvent) => {
50
161
  if (!imageSrc.value) return
51
162
 
52
- // Default: wheelで拡大。Shift押下時は縮小方向を優先。
53
- const direction = event.shiftKey ? -1 : event.deltaY < 0 ? 1 : -1
54
- const next = clamp(scale.value + direction * ZOOM_STEP, MIN_SCALE, MAX_SCALE)
163
+ event.preventDefault()
55
164
 
56
- if (next === scale.value) return
165
+ if (event.altKey) {
166
+ const anchor = getWrapperPoint(event)
167
+ const zoomFactor = event.deltaY < 0 ? 1 + ZOOM_STEP : 1 - ZOOM_STEP
168
+ const nextScale = clamp(userTransform.scale * zoomFactor, MIN_SCALE, MAX_SCALE)
169
+ if (nextScale === userTransform.scale) return
170
+ applyScaleAtPoint(nextScale, anchor)
171
+ return
172
+ }
57
173
 
58
- event.preventDefault()
59
- scale.value = next
60
- }
61
-
62
- const onMouseDown = (event: MouseEvent) => {
63
- if (!imageSrc.value || event.button !== 0) return
64
- isPanning.value = true
65
- lastPointer.x = event.clientX
66
- lastPointer.y = event.clientY
67
- window.addEventListener('mousemove', onMouseMove)
68
- window.addEventListener('mouseup', onMouseUp)
69
- }
70
-
71
- const onMouseMove = (event: MouseEvent) => {
72
- if (!isPanning.value) return
73
- const dx = event.clientX - lastPointer.x
74
- const dy = event.clientY - lastPointer.y
75
- position.x += dx
76
- position.y += dy
77
- lastPointer.x = event.clientX
78
- lastPointer.y = event.clientY
79
- event.preventDefault()
174
+ const { deltaX, deltaY } = normalizeWheel(event)
175
+ if (event.shiftKey) {
176
+ panBy(-deltaY, 0)
177
+ } else {
178
+ panBy(0, -deltaY)
179
+ }
80
180
  }
81
181
 
82
- const onMouseUp = () => {
83
- isPanning.value = false
84
- window.removeEventListener('mousemove', onMouseMove)
85
- window.removeEventListener('mouseup', onMouseUp)
182
+ let resizeObserver: ResizeObserver | null = null
183
+
184
+ const onPointerDown = (event: PointerEvent) => {
185
+ if (!imageSrc.value) return
186
+ if (event.pointerType === 'mouse' && event.button !== 0) return
187
+ wrapperRef.value?.setPointerCapture(event.pointerId)
188
+ const point = getWrapperPoint(event)
189
+ activePointers.set(event.pointerId, point)
190
+ if (activePointers.size === 1) {
191
+ isPanning.value = true
192
+ lastPointer.x = point.x
193
+ lastPointer.y = point.y
194
+ }
195
+ if (activePointers.size === 2) {
196
+ isPanning.value = false
197
+ const [first, second] = Array.from(activePointers.values())
198
+ const center = { x: (first.x + second.x) / 2, y: (first.y + second.y) / 2 }
199
+ const distance = Math.hypot(first.x - second.x, first.y - second.y)
200
+ const total = getTotalTransform()
201
+ pinchState.active = true
202
+ pinchState.startDistance = distance
203
+ pinchState.startUserScale = userTransform.scale
204
+ pinchState.anchorImagePoint = getImagePoint(center, total)
205
+ }
206
+ }
207
+
208
+ const onPointerMove = (event: PointerEvent) => {
209
+ if (!activePointers.has(event.pointerId)) return
210
+ const point = getWrapperPoint(event)
211
+ activePointers.set(event.pointerId, point)
212
+
213
+ if (activePointers.size === 1 && !pinchState.active) {
214
+ const dx = point.x - lastPointer.x
215
+ const dy = point.y - lastPointer.y
216
+ panBy(dx, dy)
217
+ lastPointer.x = point.x
218
+ lastPointer.y = point.y
219
+ event.preventDefault()
220
+ return
221
+ }
222
+
223
+ if (activePointers.size >= 2 && pinchState.active) {
224
+ const [first, second] = Array.from(activePointers.values())
225
+ const center = { x: (first.x + second.x) / 2, y: (first.y + second.y) / 2 }
226
+ const distance = Math.hypot(first.x - second.x, first.y - second.y)
227
+ if (pinchState.startDistance > 0) {
228
+ const factor = distance / pinchState.startDistance
229
+ const nextScale = clamp(pinchState.startUserScale * factor, MIN_SCALE, MAX_SCALE)
230
+ applyScaleAtPoint(nextScale, center, pinchState.anchorImagePoint)
231
+ event.preventDefault()
232
+ }
233
+ }
234
+ }
235
+
236
+ const onPointerUp = (event: PointerEvent) => {
237
+ if (activePointers.has(event.pointerId)) {
238
+ activePointers.delete(event.pointerId)
239
+ }
240
+ if (activePointers.size < 2) {
241
+ pinchState.active = false
242
+ }
243
+ if (activePointers.size === 1) {
244
+ const [remaining] = Array.from(activePointers.values())
245
+ lastPointer.x = remaining.x
246
+ lastPointer.y = remaining.y
247
+ isPanning.value = true
248
+ } else {
249
+ isPanning.value = false
250
+ }
251
+ }
252
+
253
+ const computeBaseTransform = () => {
254
+ if (!imageSrc.value) return
255
+ if (!imageSize.width || !imageSize.height) return
256
+ if (!wrapperSize.width || !wrapperSize.height) return
257
+
258
+ const scaleX = wrapperSize.width / imageSize.width
259
+ const scaleY = wrapperSize.height / imageSize.height
260
+ let scale = scaleX
261
+ switch (fitMode.value) {
262
+ case 'cover':
263
+ scale = Math.max(scaleX, scaleY)
264
+ break
265
+ case 'height':
266
+ scale = scaleY
267
+ break
268
+ case 'width':
269
+ scale = scaleX
270
+ break
271
+ default:
272
+ scale = Math.min(scaleX, scaleY)
273
+ }
274
+
275
+ const scaledWidth = imageSize.width * scale
276
+ const scaledHeight = imageSize.height * scale
277
+ const position = String(positionMode.value).toLowerCase()
278
+ const parts = position.split(/\s+/).filter(Boolean)
279
+ let xAlign: 'left' | 'center' | 'right' = 'center'
280
+ let yAlign: 'top' | 'center' | 'bottom' = 'center'
281
+
282
+ for (const part of parts) {
283
+ if (part === 'left' || part === 'center' || part === 'right') xAlign = part
284
+ if (part === 'top' || part === 'center' || part === 'bottom') yAlign = part
285
+ }
286
+ if (parts.length === 1) {
287
+ if (parts[0] === 'top' || parts[0] === 'bottom') {
288
+ yAlign = parts[0] as typeof yAlign
289
+ xAlign = 'center'
290
+ }
291
+ if (parts[0] === 'left' || parts[0] === 'right') {
292
+ xAlign = parts[0] as typeof xAlign
293
+ yAlign = 'center'
294
+ }
295
+ }
296
+
297
+ const alignAxis = (container: number, content: number, align: 'left' | 'center' | 'right' | 'top' | 'bottom') => {
298
+ if (align === 'left' || align === 'top') return 0
299
+ if (align === 'right' || align === 'bottom') return container - content
300
+ return (container - content) / 2
301
+ }
302
+
303
+ const offset = offsetMode.value
304
+ baseTransform.scale = scale
305
+ baseTransform.x = alignAxis(wrapperSize.width, scaledWidth, xAlign) + offset.x
306
+ baseTransform.y = alignAxis(wrapperSize.height, scaledHeight, yAlign) + offset.y
307
+ userTransform.scale = 1
308
+ userTransform.x = 0
309
+ userTransform.y = 0
310
+ }
311
+
312
+ const onImageLoad = (event: Event) => {
313
+ const target = event.target as HTMLImageElement
314
+ imageSize.width = target.naturalWidth
315
+ imageSize.height = target.naturalHeight
316
+ computeBaseTransform()
317
+ }
318
+
319
+ const updateWrapperSize = () => {
320
+ const rect = wrapperRef.value?.getBoundingClientRect()
321
+ if (!rect) return
322
+ wrapperSize.width = rect.width
323
+ wrapperSize.height = rect.height
86
324
  }
87
325
 
88
326
  const imageStyle = computed(() => ({
89
- transform: `translate(${position.x}px, ${position.y}px) scale(${scale.value})`,
327
+ width: imageSize.width ? `${imageSize.width}px` : 'auto',
328
+ height: imageSize.height ? `${imageSize.height}px` : 'auto',
329
+ transform: `scale(${baseTransform.scale * userTransform.scale})`,
330
+ transformOrigin: '0 0',
331
+ }))
332
+
333
+ const translateStyle = computed(() => ({
334
+ transform: `translate3d(${baseTransform.x + userTransform.x}px, ${baseTransform.y + userTransform.y}px, 0)`,
90
335
  }))
91
336
 
92
337
  onMounted(() => {
93
- resetTransform()
338
+ updateWrapperSize()
339
+ if (imageRef.value?.complete && imageRef.value.naturalWidth) {
340
+ imageSize.width = imageRef.value.naturalWidth
341
+ imageSize.height = imageRef.value.naturalHeight
342
+ computeBaseTransform()
343
+ }
344
+ if (wrapperRef.value) {
345
+ resizeObserver = new ResizeObserver(() => {
346
+ updateWrapperSize()
347
+ computeBaseTransform()
348
+ })
349
+ resizeObserver.observe(wrapperRef.value)
350
+ }
94
351
  })
95
352
 
96
353
  onBeforeUnmount(() => {
97
- window.removeEventListener('mousemove', onMouseMove)
98
- window.removeEventListener('mouseup', onMouseUp)
354
+ resizeObserver?.disconnect()
355
+ resizeObserver = null
356
+ activePointers.clear()
99
357
  })
100
358
 
101
359
  watch(imageSrc, () => {
360
+ imageSize.width = 0
361
+ imageSize.height = 0
102
362
  resetTransform()
103
363
  })
364
+
365
+ watch([fitMode, positionMode, offsetMode], () => {
366
+ computeBaseTransform()
367
+ })
104
368
  </script>
105
369
 
106
370
  <style scoped>
107
371
  .slidev-layout.image-scroll {
108
372
  position: relative;
109
373
  height: 100%;
374
+ padding: 0;
110
375
  overflow: hidden;
376
+ touch-action: none;
111
377
  }
112
378
 
113
379
  .image-layer {
@@ -115,18 +381,27 @@ watch(imageSrc, () => {
115
381
  inset: 0;
116
382
  overflow: hidden;
117
383
  cursor: grab;
384
+ user-select: none;
118
385
  }
119
386
 
120
387
  .image-layer.is-panning {
121
388
  cursor: grabbing;
122
389
  }
123
390
 
391
+ .image-translate {
392
+ position: absolute;
393
+ top: 0;
394
+ left: 0;
395
+ will-change: transform;
396
+ }
397
+
124
398
  .image-layer img {
125
- width: 100%;
126
- height: 100%;
127
- object-fit: contain;
399
+ display: block;
400
+ max-width: none;
401
+ max-height: none;
402
+ pointer-events: none;
403
+ user-select: none;
128
404
  will-change: transform;
129
- transition: transform 80ms ease-out;
130
405
  }
131
406
 
132
407
  .content {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slidev-theme-watabegg",
3
- "version": "1.1.1",
3
+ "version": "1.1.2",
4
4
  "type": "module",
5
5
  "author": "watabegg",
6
6
  "description": "Slidev theme for educational purposes, especially teaching.",
package/styles/layout.css CHANGED
@@ -110,24 +110,34 @@
110
110
  position: relative;
111
111
  padding: 0;
112
112
  overflow: hidden;
113
+ touch-action: none;
113
114
 
114
115
  .image-layer {
115
116
  position: absolute;
116
117
  inset: 0;
117
118
  cursor: grab;
118
119
  overflow: hidden;
120
+ user-select: none;
119
121
  }
120
122
 
121
123
  .image-layer.is-panning {
122
124
  cursor: grabbing;
123
125
  }
124
126
 
127
+ .image-translate {
128
+ position: absolute;
129
+ top: 0;
130
+ left: 0;
131
+ will-change: transform;
132
+ }
133
+
125
134
  .image-layer img {
126
- width: 100%;
127
- height: 100%;
128
- object-fit: contain;
135
+ display: block;
136
+ max-width: none;
137
+ max-height: none;
138
+ pointer-events: none;
139
+ user-select: none;
129
140
  will-change: transform;
130
- transition: transform 80ms ease-out;
131
141
  }
132
142
 
133
143
  .content {