slidev-theme-watabegg 1.1.1 → 1.1.3

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
@@ -52,7 +52,7 @@ transition: fade
52
52
  | cover | 表紙 | グラデーション波 + title/subtitle/author |
53
53
  | two-cols | 2カラム | `::left::` / `::right::` スロット |
54
54
  | image | 背景画像 | `image:` 指定 + `TextBox` で自由配置 |
55
- | image-scroll | 背景画像(パンとズーム) | `image:` 指定 + 縦長背景 + `TextBox` |
55
+ | image-scroll | 背景画像(縦スクロール) | `image:` 指定 + 縦長背景 + `TextBox` |
56
56
  | end | 終了スライド | シンプルな終了画面 |
57
57
 
58
58
  ### two-cols 例
@@ -82,11 +82,30 @@ image: /path/to/bg.jpg
82
82
  ---
83
83
  layout: image-scroll
84
84
  image: /path/to/long-bg.jpg
85
+ imageScroll:
86
+ offsetY: -120
85
87
  ---
86
88
  ```
87
89
 
88
90
  ![image-scroll 例](https://raw.githubusercontent.com/watabegg/slidev-theme-watabegg/refs/heads/main/example/7.png)
89
91
 
92
+ image-scroll オプション(frontmatter `imageScroll`):
93
+ - `offsetY`: 画像の中心からの初期スクロール量(px)
94
+
95
+ 操作:
96
+ - Wheel / トラックパッド: 縦スクロール
97
+ - タッチ: 縦スクロール
98
+
99
+ 初期位置の例:
100
+ ```markdown
101
+ ---
102
+ layout: image-scroll
103
+ image: /path/to/long-bg.jpg
104
+ imageScroll:
105
+ offsetY: 180
106
+ ---
107
+ ```
108
+
90
109
  ## コンポーネント
91
110
  ### QuestionList
92
111
  入れ子質問/解答・Markdown 埋め込み・階層別スタイル。
package/README.md CHANGED
@@ -56,7 +56,7 @@ Below: `color: blue` example.
56
56
  | cover | Title slide | Gradient wave + title/subtitle/author |
57
57
  | two-cols | Two columns | Slots: `::left::` / `::right::` |
58
58
  | image | Background image | Use `image:` + absolutely placed `TextBox` |
59
- | image-scroll | Background image (pan & zoom) | Use `image:` with tall background + `TextBox` |
59
+ | image-scroll | Background image (vertical scroll) | Use `image:` with tall background + `TextBox` |
60
60
  | end | Ending slide | Simple finish screen |
61
61
 
62
62
  ### two-cols Example
@@ -86,11 +86,30 @@ image: /path/to/bg.jpg
86
86
  ---
87
87
  layout: image-scroll
88
88
  image: /path/to/long-bg.jpg
89
+ imageScroll:
90
+ offsetY: -120
89
91
  ---
90
92
  ```
91
93
 
92
94
  ![image-scroll Example](https://raw.githubusercontent.com/watabegg/slidev-theme-watabegg/refs/heads/main/example/7.png)
93
95
 
96
+ image-scroll options (frontmatter `imageScroll`):
97
+ - `offsetY`: initial scroll offset from image center in px
98
+
99
+ Controls:
100
+ - Wheel / trackpad: vertical scroll
101
+ - Touch: vertical scroll
102
+
103
+ Example with initial position:
104
+ ```markdown
105
+ ---
106
+ layout: image-scroll
107
+ image: /path/to/long-bg.jpg
108
+ imageScroll:
109
+ offsetY: 180
110
+ ---
111
+ ```
112
+
94
113
  ## Components
95
114
  ### QuestionList
96
115
  Nested questions/answers with per-level styles and Markdown rendering.
package/example.md CHANGED
@@ -139,13 +139,26 @@ 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
+ offsetY: -120
142
144
  ---
143
145
 
144
- - マウスホイール: 拡大
145
- - Shift + ホイール: 縮小
146
- - 左クリックホールド + ドラッグ: 移動
146
+ ## image-scroll: 縦長画像を「横幅フィット+縦スクロール」で表示
147
147
 
148
- 画像を全画面で扱いつつ、ズーム・パンを試せます。
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
+ 背景の見せたい位置が決まっているときに便利です。
149
162
 
150
163
  ---
151
164
 
@@ -2,16 +2,22 @@
2
2
  <div
3
3
  class="slidev-layout image-scroll"
4
4
  ref="wrapperRef"
5
- @wheel="onWheel"
6
- @mousedown="onMouseDown"
5
+ @wheel.prevent="onWheel"
6
+ @pointerdown="onPointerDown"
7
+ @pointermove="onPointerMove"
8
+ @pointerup="onPointerUp"
9
+ @pointercancel="onPointerUp"
7
10
  >
8
- <div class="image-layer" :class="{ 'is-panning': isPanning }">
11
+ <div class="image-scroll__scroller" ref="scrollerRef">
9
12
  <img
10
13
  v-if="imageSrc"
14
+ ref="imageRef"
11
15
  :src="imageSrc"
12
16
  alt="Scrollable background"
17
+ class="image-scroll__image"
13
18
  :style="imageStyle"
14
19
  draggable="false"
20
+ @load="onImageLoad"
15
21
  />
16
22
  </div>
17
23
  <div class="content">
@@ -21,7 +27,7 @@
21
27
  </template>
22
28
 
23
29
  <script setup lang="ts">
24
- import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
30
+ import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
25
31
  import { useSlideContext } from '@slidev/client'
26
32
 
27
33
  const { $frontmatter } = useSlideContext()
@@ -29,77 +35,158 @@ const { $frontmatter } = useSlideContext()
29
35
  const imageSrc = computed(() => $frontmatter.image)
30
36
 
31
37
  const wrapperRef = ref<HTMLElement | null>(null)
32
- const scale = ref(1)
33
- const position = reactive({ x: 0, y: 0 })
34
- const isPanning = ref(false)
35
- const lastPointer = reactive({ x: 0, y: 0 })
38
+ const scrollerRef = ref<HTMLElement | null>(null)
39
+ const imageRef = ref<HTMLImageElement | null>(null)
36
40
 
37
- const MIN_SCALE = 0.4
38
- const MAX_SCALE = 4
39
- const ZOOM_STEP = 0.12
41
+ type ImageScrollFrontmatter = {
42
+ offsetY?: number
43
+ }
44
+
45
+ const imageScrollOptions = computed<ImageScrollFrontmatter>(() => {
46
+ const raw = $frontmatter.imageScroll
47
+ if (raw && typeof raw === 'object') return raw as ImageScrollFrontmatter
48
+ return {}
49
+ })
50
+
51
+ const offsetY = computed(() => {
52
+ const raw = imageScrollOptions.value.offsetY ?? 0
53
+ const value = Number(raw)
54
+ return Number.isFinite(value) ? value : 0
55
+ })
56
+
57
+ const imageSize = reactive({ width: 0, height: 0 })
58
+ const wrapperSize = reactive({ width: 0, height: 0 })
40
59
 
41
60
  const clamp = (val: number, min: number, max: number) => Math.min(max, Math.max(min, val))
42
61
 
43
- const resetTransform = () => {
44
- scale.value = 1
45
- position.x = 0
46
- position.y = 0
62
+ const isTouching = ref(false)
63
+ const lastTouchY = ref(0)
64
+
65
+ const normalizeWheel = (event: WheelEvent) => {
66
+ let { deltaY } = event
67
+ if (event.deltaMode === 1) {
68
+ deltaY *= 16
69
+ } else if (event.deltaMode === 2) {
70
+ deltaY *= wrapperSize.height || 1
71
+ }
72
+ return deltaY
47
73
  }
48
74
 
49
75
  const onWheel = (event: WheelEvent) => {
50
- if (!imageSrc.value) return
76
+ const scroller = scrollerRef.value
77
+ if (!scroller || !imageSrc.value) return
78
+ scroller.scrollTop += normalizeWheel(event)
79
+ }
51
80
 
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)
81
+ let resizeObserver: ResizeObserver | null = null
55
82
 
56
- if (next === scale.value) return
83
+ const onPointerDown = (event: PointerEvent) => {
84
+ if (!imageSrc.value) return
85
+ if (event.pointerType !== 'touch') return
86
+ wrapperRef.value?.setPointerCapture(event.pointerId)
87
+ isTouching.value = true
88
+ lastTouchY.value = event.clientY
89
+ }
57
90
 
91
+ const onPointerMove = (event: PointerEvent) => {
92
+ if (!isTouching.value || event.pointerType !== 'touch') return
93
+ const scroller = scrollerRef.value
94
+ if (!scroller) return
95
+ const deltaY = event.clientY - lastTouchY.value
96
+ scroller.scrollTop -= deltaY
97
+ lastTouchY.value = event.clientY
58
98
  event.preventDefault()
59
- scale.value = next
60
99
  }
61
100
 
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)
101
+ const onPointerUp = (event: PointerEvent) => {
102
+ if (event.pointerType !== 'touch') return
103
+ isTouching.value = false
104
+ wrapperRef.value?.releasePointerCapture(event.pointerId)
69
105
  }
70
106
 
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()
107
+ const scaledHeight = computed(() => {
108
+ if (!imageSize.width || !imageSize.height || !wrapperSize.width) return 0
109
+ return (imageSize.height * wrapperSize.width) / imageSize.width
110
+ })
111
+
112
+ const getRenderedImageHeight = () => {
113
+ const image = imageRef.value
114
+ if (!image) return 0
115
+ const rect = image.getBoundingClientRect()
116
+ return rect.height
80
117
  }
81
118
 
82
- const onMouseUp = () => {
83
- isPanning.value = false
84
- window.removeEventListener('mousemove', onMouseMove)
85
- window.removeEventListener('mouseup', onMouseUp)
119
+ const applyDefaultScroll = () => {
120
+ const scroller = scrollerRef.value
121
+ if (!scroller) return
122
+ const renderedHeight = getRenderedImageHeight()
123
+ const totalHeight = renderedHeight || scaledHeight.value
124
+ if (!totalHeight) return
125
+ const viewportHeight = scroller.clientHeight || wrapperSize.height
126
+ const maxScroll = Math.max(0, totalHeight - viewportHeight)
127
+ const desired = maxScroll / 2 + offsetY.value
128
+ scroller.scrollTop = clamp(desired, 0, maxScroll)
86
129
  }
87
130
 
88
- const imageStyle = computed(() => ({
89
- transform: `translate(${position.x}px, ${position.y}px) scale(${scale.value})`,
90
- }))
131
+ const scheduleApplyDefaultScroll = () => {
132
+ if (!imageSrc.value) return
133
+ nextTick(() => {
134
+ requestAnimationFrame(() => {
135
+ applyDefaultScroll()
136
+ })
137
+ })
138
+ }
139
+
140
+ const onImageLoad = (event: Event) => {
141
+ const target = event.target as HTMLImageElement
142
+ imageSize.width = target.naturalWidth
143
+ imageSize.height = target.naturalHeight
144
+ scheduleApplyDefaultScroll()
145
+ }
146
+
147
+ const updateWrapperSize = () => {
148
+ const rect = wrapperRef.value?.getBoundingClientRect()
149
+ if (!rect) return
150
+ wrapperSize.width = rect.width
151
+ wrapperSize.height = rect.height
152
+ }
153
+
154
+ const imageStyle = computed(() => {
155
+ if (!imageSize.width || !imageSize.height) return {}
156
+ return {
157
+ aspectRatio: `${imageSize.width} / ${imageSize.height}`,
158
+ }
159
+ })
91
160
 
92
161
  onMounted(() => {
93
- resetTransform()
162
+ updateWrapperSize()
163
+ if (imageRef.value?.complete && imageRef.value.naturalWidth) {
164
+ imageSize.width = imageRef.value.naturalWidth
165
+ imageSize.height = imageRef.value.naturalHeight
166
+ }
167
+ scheduleApplyDefaultScroll()
168
+ if (wrapperRef.value) {
169
+ resizeObserver = new ResizeObserver(() => {
170
+ updateWrapperSize()
171
+ scheduleApplyDefaultScroll()
172
+ })
173
+ resizeObserver.observe(wrapperRef.value)
174
+ }
94
175
  })
95
176
 
96
177
  onBeforeUnmount(() => {
97
- window.removeEventListener('mousemove', onMouseMove)
98
- window.removeEventListener('mouseup', onMouseUp)
178
+ resizeObserver?.disconnect()
179
+ resizeObserver = null
99
180
  })
100
181
 
101
182
  watch(imageSrc, () => {
102
- resetTransform()
183
+ imageSize.width = 0
184
+ imageSize.height = 0
185
+ if (scrollerRef.value) scrollerRef.value.scrollTop = 0
186
+ })
187
+
188
+ watch([scaledHeight, offsetY], () => {
189
+ scheduleApplyDefaultScroll()
103
190
  })
104
191
  </script>
105
192
 
@@ -107,26 +194,26 @@ watch(imageSrc, () => {
107
194
  .slidev-layout.image-scroll {
108
195
  position: relative;
109
196
  height: 100%;
197
+ padding: 0;
110
198
  overflow: hidden;
199
+ touch-action: none;
111
200
  }
112
201
 
113
- .image-layer {
202
+ .image-scroll__scroller {
114
203
  position: absolute;
115
204
  inset: 0;
116
- overflow: hidden;
117
- cursor: grab;
205
+ overflow-y: auto;
206
+ overflow-x: hidden;
207
+ scrollbar-gutter: stable;
208
+ overscroll-behavior: contain;
118
209
  }
119
210
 
120
- .image-layer.is-panning {
121
- cursor: grabbing;
122
- }
123
-
124
- .image-layer img {
211
+ .image-scroll__image {
212
+ display: block;
125
213
  width: 100%;
126
- height: 100%;
127
- object-fit: contain;
128
- will-change: transform;
129
- transition: transform 80ms ease-out;
214
+ max-width: none;
215
+ pointer-events: none;
216
+ user-select: none;
130
217
  }
131
218
 
132
219
  .content {
@@ -134,5 +221,6 @@ watch(imageSrc, () => {
134
221
  z-index: 1;
135
222
  height: 100%;
136
223
  padding: 2rem;
224
+ box-sizing: border-box;
137
225
  }
138
226
  </style>
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.3",
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 {