@netless/app-presentation 0.1.0-beta.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/src/preload.ts ADDED
@@ -0,0 +1,59 @@
1
+ import type { IDisposable } from "@wopjs/disposable";
2
+ import type { PresentationPage } from "./presentation";
3
+
4
+ export class Preload implements IDisposable {
5
+ readonly links: HTMLLinkElement[]
6
+
7
+ head = 0
8
+ tail = 0
9
+ timer = 0
10
+
11
+ get done(): boolean {
12
+ return this.head == 0 && this.tail == this.pages.length
13
+ }
14
+
15
+ constructor(readonly pages: PresentationPage[]) {
16
+ this.links = new Array(pages.length)
17
+ this.touch(0)
18
+ }
19
+
20
+ touch(index: number) {
21
+ if (this.done) return
22
+ this.head = this.tail = index
23
+ this.timer ||= setTimeout(this.handler.bind(this))
24
+ }
25
+
26
+ handler() {
27
+ const { links } = this
28
+
29
+ while (this.head > 0 && links[this.head]) this.head--
30
+ if (!links[this.head]) {
31
+ const link = document.createElement('link')
32
+ links[this.head] = link
33
+ link.rel = 'preload'
34
+ link.as = 'image'
35
+ link.href = this.pages[this.head].src
36
+ document.head.appendChild(link)
37
+ }
38
+
39
+ while (links[this.tail]) this.tail++
40
+ if (this.tail < this.pages.length && !links[this.tail]) {
41
+ const link = document.createElement('link')
42
+ links[this.tail] = link
43
+ link.rel = 'preload'
44
+ link.as = 'image'
45
+ link.href = this.pages[this.tail].src
46
+ document.head.appendChild(link)
47
+ }
48
+
49
+ this.timer = setTimeout(this.handler.bind(this), 2_000)
50
+ }
51
+
52
+ dispose() {
53
+ clearTimeout(this.timer)
54
+ for (const link of this.links) {
55
+ link && document.head.contains(link) && document.head.removeChild(link)
56
+ }
57
+ this.links.length = 0
58
+ }
59
+ }
@@ -0,0 +1,317 @@
1
+ import type { IDisposable } from '@wopjs/disposable'
2
+
3
+ import { disposableStore } from '@wopjs/disposable'
4
+ import { listen } from '@wopjs/dom'
5
+ import { default as LazyLoad, type ILazyLoadInstance } from 'vanilla-lazyload'
6
+ import { arrowLeftSVG, arrowRightSVG, sidebarSVG } from './icons';
7
+ import { Preload } from './preload';
8
+
9
+ export interface PresentationPage {
10
+ src: string;
11
+ width: number;
12
+ height: number;
13
+ thumbnail?: string | undefined;
14
+ }
15
+
16
+ export interface PresentationConfig {
17
+ readonly pages: PresentationPage[]
18
+ readonly readonly?: boolean
19
+ }
20
+
21
+ /**
22
+ * Standalone presentation slide viewer.
23
+ *
24
+ * ```html
25
+ * <div class="netless-app-presentation">
26
+ * <div class="netless-app-presentation-content netless-app-presentation-readonly">
27
+ * <div class="netless-app-presentation-preview-mask"></div>
28
+ * <div class="netless-app-presentation-preview">
29
+ * <a class="netless-app-presentation-preview-page">
30
+ * <img :data-src="thumbnail || src">
31
+ * <span class="netless-app-presentation-preview-page-name">1</span>
32
+ * </a>
33
+ * </div>
34
+ * <div class="netless-app-presentation-image">
35
+ * <img :src="src">
36
+ * </div>
37
+ * <div class="netless-app-presentation-wb-view" style="pointer-events: auto"></div>
38
+ * </div>
39
+ * <div class="netless-app-presentation-footer netless-app-presentation-readonly">
40
+ * <button class="netless-app-presentation-footer-btn netless-app-presentation-btn-sidebar">
41
+ * <svg class="netless-app-presentation-footer-icon-sidebar"></svg>
42
+ * </button>
43
+ * <div class="netless-app-presentation-page-jumps">
44
+ * <button-page-back />
45
+ * <button-page-next />
46
+ * </div>
47
+ * <div class="netless-app-presentation-page-number">
48
+ * <input class="netless-app-presentation-page-number-input">
49
+ * <span> / 10</span>
50
+ * </div>
51
+ * </div>
52
+ * </div>
53
+ * ```
54
+ */
55
+ export class Presentation implements IDisposable<void> {
56
+ readonly namespace = "netless-app-presentation"
57
+ readonly dispose = disposableStore()
58
+ readonly pages: PresentationPage[]
59
+ readonly preload: Preload
60
+
61
+ dom: Element | DocumentFragment
62
+ contentDOM: HTMLDivElement
63
+ previewDOM: HTMLDivElement
64
+ imageDOM: HTMLDivElement
65
+ image: HTMLImageElement
66
+ whiteboardDOM: HTMLDivElement
67
+ footerDOM: HTMLDivElement
68
+ pageNumberInputDOM: HTMLInputElement
69
+
70
+ readonly: boolean
71
+ initialized = false
72
+ showPreview = false
73
+ pageIndex = 0
74
+ previewLazyload: ILazyLoadInstance | null = null
75
+
76
+ constructor(config: PresentationConfig) {
77
+ this.pages = config.pages
78
+ this.preload = new Preload(this.pages)
79
+ this.dispose.add(this.preload)
80
+ this.readonly = config.readonly ?? false
81
+ this.dom = document.createElement('div')
82
+ this.dom.className = this.namespace
83
+ this.contentDOM = document.createElement('div')
84
+ this.contentDOM.className = this.c('content')
85
+ this.previewDOM = document.createElement('div')
86
+ this.previewDOM.className = this.c('preview')
87
+ this.imageDOM = document.createElement('div')
88
+ this.imageDOM.className = this.c('image')
89
+ this.image = document.createElement('img')
90
+ this.whiteboardDOM = document.createElement('div')
91
+ this.whiteboardDOM.className = this.c('wb-view')
92
+ this.footerDOM = document.createElement('div')
93
+ this.footerDOM.className = this.c('footer')
94
+ this.pageNumberInputDOM = document.createElement('input')
95
+ this.pageNumberInputDOM.className = this.c('page-number-input')
96
+ this.initialize()
97
+ }
98
+
99
+ initialize() {
100
+ if (this.initialized) return
101
+ this.initialized = true
102
+
103
+ this.dispose.add(() => this.previewLazyload?.destroy())
104
+
105
+ this.contentDOM.classList.toggle(this.c('readonly'), this.readonly)
106
+ this.dom.appendChild(this.contentDOM)
107
+
108
+ const previewMask = this.contentDOM.appendChild(document.createElement('div'))
109
+ previewMask.className = this.c('preview-mask')
110
+ this.dispose.add(listen(previewMask, "click", ev => {
111
+ if (this.readonly) return
112
+ if (ev.target == previewMask) {
113
+ this.togglePreview(false)
114
+ }
115
+ }))
116
+
117
+ this.contentDOM.appendChild(this.previewDOM)
118
+ this.previewDOM.classList.add("tele-fancy-scrollbar")
119
+ const c_previewPage = this.c('preview-page')
120
+ const c_previewPageName = this.c('preview-page-name')
121
+ for (let index = 0; index < this.pages.length; ++index) {
122
+ const page = this.pages[index]
123
+ const previewSRC = page.thumbnail || this.x_oss_process(page.src)
124
+
125
+ const previewPage = document.createElement('a')
126
+ previewPage.className = `${c_previewPage} ${this.c(`preview-page-${index}`)}`
127
+ previewPage.setAttribute('href', '#')
128
+ previewPage.dataset.pageIndex = String(index)
129
+
130
+ const img = document.createElement('img')
131
+ img.width = page.width
132
+ img.height = page.height
133
+ img.dataset.src = previewSRC
134
+ img.dataset.pageIndex = String(index)
135
+
136
+ const name = document.createElement('span')
137
+ name.className = c_previewPageName
138
+ name.textContent = String(index + 1)
139
+ name.dataset.pageIndex = String(index)
140
+
141
+ previewPage.appendChild(img)
142
+ previewPage.appendChild(name)
143
+ this.previewDOM.appendChild(previewPage)
144
+ }
145
+ this.previewLazyload?.update()
146
+
147
+ this.dispose.add(listen(this.previewDOM, "click", ev => {
148
+ if (this.readonly) return
149
+ const pageIndex = (ev.target as HTMLElement).dataset?.pageIndex
150
+ if (pageIndex) {
151
+ ev.preventDefault()
152
+ ev.stopPropagation()
153
+ ev.stopImmediatePropagation()
154
+ this.onNewPageIndex(Number(pageIndex), 'preview')
155
+ this.togglePreview(false)
156
+ }
157
+ }))
158
+
159
+ this.imageDOM.appendChild(this.image)
160
+ this.contentDOM.appendChild(this.imageDOM)
161
+ this.updateImage()
162
+
163
+ this.contentDOM.appendChild(this.whiteboardDOM)
164
+
165
+ this.footerDOM.classList.toggle(this.c('readonly'), this.readonly)
166
+ this.dom.appendChild(this.footerDOM)
167
+
168
+ const btnSidebar = document.createElement('button')
169
+ btnSidebar.className = `${this.c('footer-btn')} ${this.c('btn-sidebar')}`
170
+ btnSidebar.appendChild(sidebarSVG(this.namespace))
171
+ this.footerDOM.appendChild(btnSidebar)
172
+ this.dispose.add(listen(btnSidebar, "click", () => {
173
+ if (this.readonly) return
174
+ this.togglePreview()
175
+ }))
176
+
177
+ const pageJumps = document.createElement('div')
178
+ pageJumps.className = this.c('page-jumps')
179
+ this.footerDOM.appendChild(pageJumps)
180
+
181
+ const btnPageBack = document.createElement('button')
182
+ btnPageBack.className = `${this.c('footer-btn')} ${this.c('btn-page-back')}`
183
+ btnPageBack.appendChild(arrowLeftSVG(this.namespace))
184
+ pageJumps.appendChild(btnPageBack)
185
+ this.dispose.add(listen(btnPageBack, "click", () => {
186
+ if (this.readonly) return
187
+ if (this.pageIndex > 0) this.onNewPageIndex(this.pageIndex - 1, 'navigation')
188
+ }))
189
+
190
+ const btnPageNext = document.createElement('button')
191
+ btnPageNext.className = `${this.c('footer-btn')} ${this.c('btn-page-next')}`
192
+ btnPageNext.appendChild(arrowRightSVG(this.namespace))
193
+ pageJumps.appendChild(btnPageNext)
194
+ this.dispose.add(listen(btnPageNext, "click", () => {
195
+ if (this.readonly) return
196
+ if (this.pageIndex < this.pages.length - 1) this.onNewPageIndex(this.pageIndex + 1, 'navigation')
197
+ }))
198
+
199
+ const pageNumber = document.createElement('div')
200
+ pageNumber.className = this.c('page-number')
201
+ this.footerDOM.appendChild(pageNumber)
202
+
203
+ pageNumber.appendChild(this.pageNumberInputDOM)
204
+ this.pageNumberInputDOM.value = String(this.pageIndex + 1)
205
+ this.pageNumberInputDOM.disabled = this.readonly
206
+ this.dispose.add(listen(this.pageNumberInputDOM, "focus", () => {
207
+ if (this.readonly) return
208
+ this.pageNumberInputDOM.select()
209
+ }))
210
+ this.dispose.add(listen(this.pageNumberInputDOM, "change", () => {
211
+ if (this.readonly) return
212
+ if (this.pageNumberInputDOM.value) {
213
+ this.onNewPageIndex(Number(this.pageNumberInputDOM.value) - 1, 'input')
214
+ }
215
+ }))
216
+
217
+ const totalPage = document.createElement('span')
218
+ totalPage.textContent = ` / ${this.pages.length}`
219
+ pageNumber.appendChild(totalPage)
220
+
221
+ this.dispose.add(listen(window, "keydown", ev => {
222
+ if (this.readonly || this.isEditable(ev.target)) return
223
+ if ((ev.key == 'ArrowUp' || ev.key == 'ArrowLeft') && this.pageIndex > 0)
224
+ this.onNewPageIndex(this.pageIndex - 1, 'keydown')
225
+ else if ((ev.key == 'ArrowDown' || ev.key == 'ArrowRight') && this.pageIndex < this.pages.length - 1)
226
+ this.onNewPageIndex(this.pageIndex + 1, 'keydown')
227
+ }))
228
+ }
229
+
230
+ setDOM(dom: Element | DocumentFragment) {
231
+ if (this.dom == dom) return
232
+ dom.appendChild(this.contentDOM)
233
+ dom.appendChild(this.footerDOM)
234
+ this.dom = dom
235
+ }
236
+
237
+ setReadonly(readonly: boolean) {
238
+ if (this.readonly == readonly) return
239
+ this.contentDOM.classList.toggle(this.c('readonly'), readonly)
240
+ this.footerDOM.classList.toggle(this.c('readonly'), readonly)
241
+ this.pageNumberInputDOM.disabled = readonly
242
+ this.togglePreview(false)
243
+ this.readonly = readonly
244
+ }
245
+
246
+ setPageIndex(pageIndex: number) {
247
+ if (Number.isSafeInteger(pageIndex)) {
248
+ this.pageIndex = pageIndex
249
+ this.pageNumberInputDOM.value = String(pageIndex + 1)
250
+ this.updateImage()
251
+ }
252
+ }
253
+
254
+ togglePreview(showPreview?: boolean) {
255
+ this.showPreview = showPreview ?? !this.showPreview
256
+ this.contentDOM.classList.toggle(this.c('preview-active'), this.showPreview)
257
+ if (this.showPreview) {
258
+ const previewPageDOM = this.previewDOM.querySelector<HTMLElement>('.' + this.c(`preview-page-${this.pageIndex}`))
259
+ if (previewPageDOM) {
260
+ this.previewLazyload ||= new LazyLoad({
261
+ container: this.previewDOM,
262
+ elements_selector: `.${this.c('preview-page>img')}`
263
+ })
264
+ this.previewDOM.scrollTo({ top: previewPageDOM.offsetTop - 16 })
265
+ }
266
+ }
267
+ }
268
+
269
+ // [origin] means the cause:
270
+ // - navigation: user clicked on the left / right button on footer
271
+ // - input: user filled the bottom-right page number input box
272
+ // - preview: user clicked on the left preview menu's item
273
+ // - keydown: this navigation is caused by user press left or right
274
+ // Fastboard dispatchDocsEvent() triggers the "navigation" and "input" cause
275
+ onNewPageIndex(index: number, _origin: "navigation" | "keydown" | "input" | "preview") {
276
+ if (0 <= index && index < this.pages.length) {
277
+ this.setPageIndex(index)
278
+ }
279
+ }
280
+
281
+ page(): PresentationPage | undefined {
282
+ return this.pages[this.pageIndex]
283
+ }
284
+
285
+ updateImage() {
286
+ const page = this.page()
287
+ if (!page) {
288
+ this.image.src = ''
289
+ return
290
+ }
291
+ this.image.width = page.width
292
+ this.image.height = page.height
293
+ this.image.src = page.src
294
+ this.preload.touch(this.pageIndex)
295
+ }
296
+
297
+ private c(className: string): string {
298
+ return `${this.namespace}-${className}`
299
+ }
300
+
301
+ private isEditable(el: EventTarget | null): boolean {
302
+ if (!el) return false
303
+ const { tagName } = el as HTMLElement
304
+ return tagName == 'INPUT' || tagName == 'TEXTAREA' || tagName == 'SELECT'
305
+ }
306
+
307
+ private x_oss_process(src: string): string {
308
+ try {
309
+ const url = new URL(src)
310
+ url.searchParams.set('x-oss-process', 'image/resize,l_50')
311
+ return url.toString()
312
+ } catch (err) {
313
+ console.error(err)
314
+ return src
315
+ }
316
+ }
317
+ }
package/src/style.scss ADDED
@@ -0,0 +1,259 @@
1
+ $namespace: 'netless-app-presentation';
2
+
3
+ .#{$namespace} {
4
+ height: 100%;
5
+ display: flex;
6
+ flex-flow: column nowrap;
7
+ }
8
+
9
+ .#{$namespace}-content {
10
+ position: relative;
11
+ height: 100%;
12
+ overflow: hidden;
13
+ }
14
+
15
+ .#{$namespace}-preview-mask {
16
+ display: none;
17
+ position: absolute;
18
+ z-index: 200;
19
+ top: 0;
20
+ left: 0;
21
+ width: 100%;
22
+ height: 100%;
23
+ }
24
+
25
+ .#{$namespace}-preview {
26
+ display: flex;
27
+ flex-direction: column;
28
+ align-items: center;
29
+ position: absolute;
30
+ z-index: 300;
31
+ top: 0;
32
+ left: 0;
33
+ width: 33%;
34
+ max-width: 200px;
35
+ height: 100%;
36
+ padding-top: 10px;
37
+ transform: translateX(-100%);
38
+ background: rgba(237, 237, 240, 0.9);
39
+ box-shadow: inset -1px 0 0 rgba(0, 0, 0, 0.11);
40
+ transition: transform 0.4s;
41
+ overflow: auto;
42
+ overflow-y: scroll;
43
+ }
44
+
45
+ .#{$namespace}-preview-active {
46
+ .#{$namespace}-preview-mask {
47
+ display: block;
48
+ }
49
+
50
+ .#{$namespace}-preview {
51
+ transform: translateX(0);
52
+ }
53
+ }
54
+
55
+ .#{$namespace}-preview-page {
56
+ position: relative;
57
+ display: block;
58
+ width: 55%;
59
+ margin-bottom: 10px;
60
+ font-size: 0;
61
+ color: transparent;
62
+ outline: none;
63
+ border: 7px solid transparent;
64
+ border-radius: 4px;
65
+ transition: border-color 0.3s;
66
+ user-select: none;
67
+
68
+ &:hover,
69
+ &.#{$namespace}-preview-page-active {
70
+ border-color: rgba(68, 78, 96, 0.1);
71
+ }
72
+
73
+ & > img {
74
+ width: 100%;
75
+ height: auto;
76
+ box-sizing: border-box;
77
+ border: 1px solid rgba(0, 0, 0, 0.5);
78
+ border-radius: 1px;
79
+ background-color: rgba(255, 255, 255, 1);
80
+ box-shadow: 0 2px 8px rgb(0 0 0 / 30%);
81
+ }
82
+ }
83
+
84
+ .#{$namespace}-preview-page-name {
85
+ position: absolute;
86
+ top: 1px;
87
+ left: -10px;
88
+ transform: translate(-100%);
89
+ text-align: right;
90
+ font-size: 12px;
91
+ color: #5f5f5f;
92
+ user-select: none;
93
+ }
94
+
95
+ .#{$namespace}-footer {
96
+ box-sizing: border-box;
97
+ height: 26px;
98
+ display: flex;
99
+ align-items: center;
100
+ padding: 0 16px;
101
+ border-top: 1px solid #eeeef7;
102
+ color: #191919;
103
+ }
104
+
105
+ .#{$namespace}-float-footer {
106
+ width: 100%;
107
+ min-height: 26px;
108
+ position: absolute;
109
+ left: 0;
110
+ bottom: 0;
111
+ z-index: 2000;
112
+ background: rgba(249, 249, 252, 0.9);
113
+ transition: opacity 0.4s;
114
+ }
115
+
116
+ .#{$namespace}-footer-btn {
117
+ box-sizing: border-box;
118
+ width: 26px;
119
+ height: 26px;
120
+ font-size: 0;
121
+ margin: 0;
122
+ padding: 3px;
123
+ border: none;
124
+ border-radius: 1px;
125
+ outline: none;
126
+ color: currentColor;
127
+ background: transparent;
128
+ transition: background 0.4s;
129
+ cursor: pointer;
130
+ user-select: none;
131
+ -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
132
+
133
+ &:hover {
134
+ background: rgba(237, 237, 240, 0.9);
135
+ }
136
+
137
+ @media (hover: none) {
138
+ &:hover {
139
+ background: transparent !important;
140
+ }
141
+ }
142
+
143
+ & > svg {
144
+ width: 100%;
145
+ height: 100%;
146
+ }
147
+
148
+ & > svg:nth-of-type(2) {
149
+ display: none;
150
+ }
151
+
152
+ &.#{$namespace}-footer-btn-playing {
153
+ & > svg:nth-of-type(1) {
154
+ display: none;
155
+ }
156
+
157
+ & > svg:nth-of-type(2) {
158
+ display: initial;
159
+ }
160
+ }
161
+
162
+ & ~ & {
163
+ margin-left: 15px;
164
+ }
165
+ }
166
+
167
+ .#{$namespace}-page-jumps {
168
+ flex: 1;
169
+ display: flex;
170
+ justify-content: center;
171
+ align-items: center;
172
+ }
173
+
174
+ .#{$namespace}-page-number {
175
+ margin-left: auto;
176
+ font-size: 13px;
177
+ user-select: none;
178
+ white-space: nowrap;
179
+ word-break: keep-all;
180
+ }
181
+
182
+ .#{$namespace}-page-number-input {
183
+ border: none;
184
+ outline: none;
185
+ width: 3em;
186
+ margin: 0;
187
+ padding: 0 2px;
188
+ text-align: right;
189
+ font-size: 13px;
190
+ line-height: 1;
191
+ font-weight: normal;
192
+ font-family: inherit;
193
+ border-radius: 2px;
194
+ color: currentColor;
195
+ background: transparent;
196
+ transition: background 0.4s;
197
+ user-select: text;
198
+ -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
199
+
200
+ &:hover,
201
+ &:focus,
202
+ &:active {
203
+ background: #fff;
204
+ box-shadow: rgba(99, 99, 99, 0.2) 0px 2px 8px 0px;
205
+ }
206
+ }
207
+
208
+ .#{$namespace}-readonly {
209
+ &.#{$namespace}-footer {
210
+ display: none;
211
+ }
212
+ }
213
+
214
+ .#{$namespace}-wb-view {
215
+ position: absolute;
216
+ top: 0;
217
+ left: 0;
218
+ width: 100%;
219
+ height: 100%;
220
+ z-index: 100;
221
+ overflow: hidden;
222
+ transition: opacity 0.2s;
223
+ }
224
+
225
+ .#{$namespace}-image {
226
+ width: 100%;
227
+ height: 100%;
228
+ display: flex;
229
+ align-items: center;
230
+ justify-content: center;
231
+
232
+ >img {
233
+ max-width: 100%;
234
+ max-height: 100%;
235
+ object-fit: contain;
236
+ }
237
+ }
238
+
239
+ .telebox-color-scheme-dark {
240
+ .#{$namespace}-page-number-input {
241
+ color: #a6a6a8;
242
+ }
243
+ .#{$namespace}-page-number-input:active,
244
+ .#{$namespace}-page-number-input:focus,
245
+ .#{$namespace}-page-number-input:hover {
246
+ color: #222;
247
+ }
248
+ .#{$namespace}-footer {
249
+ color: #a6a6a8;
250
+ background: #2d2d33;
251
+ border-top: none;
252
+ }
253
+ .#{$namespace}-footer-btn:hover {
254
+ background: #212126;
255
+ }
256
+ .#{$namespace}-preview {
257
+ background: rgba(50, 50, 50, 0.9);
258
+ }
259
+ }