@open-slide/core 1.7.0 → 1.9.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/dist/{build-tLrkKUHr.js → build-ZM7IfDO-.js} +1 -1
- package/dist/cli/bin.js +3 -3
- package/dist/{config-PwUHqZ_X.js → config-BAZeaz2P.js} +289 -246
- package/dist/{config-CfMThYN9.d.ts → config-D_5nlXFU.d.ts} +6 -1
- package/dist/{dev-DpCIRbhT.js → dev-BQkNTG_t.js} +1 -1
- package/dist/format-CYOb2cAQ.js +1573 -0
- package/dist/index.d.ts +4 -4
- package/dist/index.js +38 -4
- package/dist/locale/index.d.ts +1 -1
- package/dist/locale/index.js +1 -1144
- package/dist/{preview-BSGlM6Se.js → preview-D8hUtbRA.js} +1 -1
- package/dist/{types-B-KrjgX8.d.ts → types-AalTbxMj.d.ts} +17 -3
- package/dist/vite/index.d.ts +2 -2
- package/dist/vite/index.js +1 -1
- package/package.json +2 -1
- package/skills/create-theme/SKILL.md +1 -1
- package/src/app/components/inspector/comment-widget.tsx +16 -2
- package/src/app/components/language-toggle.tsx +39 -0
- package/src/app/components/player.tsx +12 -17
- package/src/app/components/pptx-progress-toast.tsx +32 -0
- package/src/app/components/sidebar/folder-item.tsx +7 -2
- package/src/app/components/sidebar/sidebar-footer.tsx +51 -0
- package/src/app/components/sidebar/sidebar.tsx +95 -17
- package/src/app/lib/design-presets.ts +1 -1
- package/src/app/lib/export-pptx.ts +284 -0
- package/src/app/lib/folders.ts +28 -0
- package/src/app/lib/inspector/fiber.test.ts +154 -0
- package/src/app/lib/inspector/fiber.ts +12 -1
- package/src/app/lib/locale-store.ts +67 -0
- package/src/app/lib/use-click-page-navigation.ts +52 -0
- package/src/app/lib/use-is-mobile.ts +21 -0
- package/src/app/lib/use-locale.ts +4 -16
- package/src/app/routes/home-shell.tsx +8 -0
- package/src/app/routes/home.tsx +1 -1
- package/src/app/routes/slide.tsx +145 -53
- package/src/app/virtual.d.ts +1 -0
- package/src/locale/en.ts +18 -3
- package/src/locale/ja.ts +19 -3
- package/src/locale/types.ts +18 -3
- package/src/locale/zh-cn.ts +17 -3
- package/src/locale/zh-tw.ts +17 -3
- package/dist/en-BDnM5zKJ.js +0 -378
- package/src/app/components/click-nav-zones.tsx +0 -36
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { createElement } from 'react';
|
|
2
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
3
|
+
import { designToCssVars } from './design';
|
|
4
|
+
import { SlidePageProvider } from './page-context';
|
|
5
|
+
import { isFrameAnimationSettled, waitForDataWaitfor, waitForFonts } from './print-ready';
|
|
6
|
+
import type { SlideModule } from './sdk';
|
|
7
|
+
|
|
8
|
+
const SLIDE_W = 1920;
|
|
9
|
+
const SLIDE_H = 1080;
|
|
10
|
+
// 16:9 widescreen in English Metric Units (914400 EMU per inch → 13.333in × 7.5in).
|
|
11
|
+
const EMU_W = 12192000;
|
|
12
|
+
const EMU_H = 6858000;
|
|
13
|
+
const CAPTURE_PIXEL_RATIO = 2;
|
|
14
|
+
|
|
15
|
+
const ANIMATION_TIMEOUT_MS = 15_000;
|
|
16
|
+
const POLL_INTERVAL_MS = 100;
|
|
17
|
+
|
|
18
|
+
const CAPTURE_CLASS = 'os-pptx-capture';
|
|
19
|
+
const CAPTURE_STYLE_ID = 'os-pptx-capture-style';
|
|
20
|
+
// Properties intro animations drive from a hidden start state to a visible end
|
|
21
|
+
// state. We read them back once settled and pin them inline so the capture clone
|
|
22
|
+
// can't re-run the keyframes from their invisible 0% frame (see freezeForCapture).
|
|
23
|
+
const FROZEN_PROPS = ['opacity', 'transform', 'filter', 'clip-path'] as const;
|
|
24
|
+
|
|
25
|
+
export type PptxExportProgress = {
|
|
26
|
+
phase: 'processing' | 'generating' | 'done';
|
|
27
|
+
/** Number of pages captured so far (0..total). */
|
|
28
|
+
current: number;
|
|
29
|
+
total: number;
|
|
30
|
+
/** 0–95 while capturing, 98 while assembling, 100 when done. */
|
|
31
|
+
percent: number;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export async function exportSlideAsImagePptx(
|
|
35
|
+
slide: SlideModule,
|
|
36
|
+
slideId: string,
|
|
37
|
+
onProgress?: (progress: PptxExportProgress) => void,
|
|
38
|
+
): Promise<void> {
|
|
39
|
+
const pages = slide.default ?? [];
|
|
40
|
+
if (pages.length === 0) return;
|
|
41
|
+
|
|
42
|
+
const total = pages.length;
|
|
43
|
+
onProgress?.({ phase: 'processing', current: 0, total, percent: 0 });
|
|
44
|
+
|
|
45
|
+
const container = document.createElement('div');
|
|
46
|
+
container.className = CAPTURE_CLASS;
|
|
47
|
+
container.setAttribute('aria-hidden', 'true');
|
|
48
|
+
Object.assign(container.style, {
|
|
49
|
+
position: 'fixed',
|
|
50
|
+
left: '-99999px',
|
|
51
|
+
top: '0',
|
|
52
|
+
pointerEvents: 'none',
|
|
53
|
+
});
|
|
54
|
+
document.body.appendChild(container);
|
|
55
|
+
|
|
56
|
+
// html-to-image clones each frame and copies its computed style — including the
|
|
57
|
+
// intro animation — into the clone, which then re-runs the keyframes from their
|
|
58
|
+
// hidden 0% frame in the rasterised SVG. Fast-forward every animation to its end
|
|
59
|
+
// frame in the live DOM (a large negative delay lands past a 1ms duration, so
|
|
60
|
+
// even pseudo-elements paint their final state on the first frame).
|
|
61
|
+
const captureStyle = document.createElement('style');
|
|
62
|
+
captureStyle.id = CAPTURE_STYLE_ID;
|
|
63
|
+
captureStyle.textContent = `.${CAPTURE_CLASS} *, .${CAPTURE_CLASS} *::before, .${CAPTURE_CLASS} *::after {
|
|
64
|
+
animation-delay: -1s !important;
|
|
65
|
+
animation-duration: 1ms !important;
|
|
66
|
+
animation-iteration-count: 1 !important;
|
|
67
|
+
animation-fill-mode: forwards !important;
|
|
68
|
+
transition: none !important;
|
|
69
|
+
}`;
|
|
70
|
+
document.head.appendChild(captureStyle);
|
|
71
|
+
|
|
72
|
+
const designVars = slide.design ? designToCssVars(slide.design) : null;
|
|
73
|
+
|
|
74
|
+
const reactRoots: Root[] = [];
|
|
75
|
+
const frames: HTMLElement[] = [];
|
|
76
|
+
for (let i = 0; i < pages.length; i++) {
|
|
77
|
+
const Page = pages[i];
|
|
78
|
+
if (!Page) continue;
|
|
79
|
+
const host = document.createElement('div');
|
|
80
|
+
host.setAttribute('data-osd-canvas', '');
|
|
81
|
+
host.style.width = `${SLIDE_W}px`;
|
|
82
|
+
host.style.height = `${SLIDE_H}px`;
|
|
83
|
+
host.style.overflow = 'hidden';
|
|
84
|
+
host.style.background = '#fff';
|
|
85
|
+
if (designVars) {
|
|
86
|
+
for (const [k, v] of Object.entries(designVars)) host.style.setProperty(k, v);
|
|
87
|
+
}
|
|
88
|
+
container.appendChild(host);
|
|
89
|
+
frames.push(host);
|
|
90
|
+
const r = createRoot(host);
|
|
91
|
+
r.render(
|
|
92
|
+
createElement(SlidePageProvider, { index: i, total: pages.length }, createElement(Page)),
|
|
93
|
+
);
|
|
94
|
+
reactRoots.push(r);
|
|
95
|
+
}
|
|
96
|
+
// Yield once so React commits all pages and intro animations actually start.
|
|
97
|
+
await nextPaint();
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
await waitForFonts();
|
|
101
|
+
|
|
102
|
+
const deadline = performance.now() + ANIMATION_TIMEOUT_MS;
|
|
103
|
+
while (performance.now() < deadline) {
|
|
104
|
+
const settled = frames.every((frame) => isFrameAnimationSettled(frame));
|
|
105
|
+
if (settled) break;
|
|
106
|
+
await sleep(POLL_INTERVAL_MS);
|
|
107
|
+
}
|
|
108
|
+
await waitForDataWaitfor(container);
|
|
109
|
+
|
|
110
|
+
const { toBlob } = await import('html-to-image');
|
|
111
|
+
const images: Uint8Array[] = [];
|
|
112
|
+
for (let i = 0; i < frames.length; i++) {
|
|
113
|
+
freezeForCapture(frames[i]);
|
|
114
|
+
const blob = await toBlob(frames[i], {
|
|
115
|
+
width: SLIDE_W,
|
|
116
|
+
height: SLIDE_H,
|
|
117
|
+
pixelRatio: CAPTURE_PIXEL_RATIO,
|
|
118
|
+
backgroundColor: '#ffffff',
|
|
119
|
+
cacheBust: true,
|
|
120
|
+
});
|
|
121
|
+
if (!blob) throw new Error(`failed to capture page ${i + 1}`);
|
|
122
|
+
images.push(new Uint8Array(await blob.arrayBuffer()));
|
|
123
|
+
onProgress?.({
|
|
124
|
+
phase: 'processing',
|
|
125
|
+
current: i + 1,
|
|
126
|
+
total,
|
|
127
|
+
percent: Math.min(95, ((i + 1) / total) * 95),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
onProgress?.({ phase: 'generating', current: total, total, percent: 98 });
|
|
132
|
+
const pptx = await buildImagePptx(images);
|
|
133
|
+
downloadBlob(
|
|
134
|
+
new Blob([pptx as BlobPart], {
|
|
135
|
+
type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
136
|
+
}),
|
|
137
|
+
`${slideId}.pptx`,
|
|
138
|
+
);
|
|
139
|
+
} finally {
|
|
140
|
+
onProgress?.({ phase: 'done', current: total, total, percent: 100 });
|
|
141
|
+
for (const r of reactRoots) r.unmount();
|
|
142
|
+
container.remove();
|
|
143
|
+
captureStyle.remove();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Pin each element's settled visual state inline and remove its animation so the
|
|
148
|
+
// clone html-to-image rasterises renders the final frame instead of replaying the
|
|
149
|
+
// (initially invisible) keyframes. Pseudo-elements are handled by CAPTURE_STYLE_ID.
|
|
150
|
+
function freezeForCapture(root: HTMLElement): void {
|
|
151
|
+
for (const el of root.querySelectorAll<HTMLElement>('*')) {
|
|
152
|
+
const cs = getComputedStyle(el);
|
|
153
|
+
for (const prop of FROZEN_PROPS) {
|
|
154
|
+
el.style.setProperty(prop, cs.getPropertyValue(prop), 'important');
|
|
155
|
+
}
|
|
156
|
+
el.style.setProperty('animation', 'none', 'important');
|
|
157
|
+
el.style.setProperty('transition', 'none', 'important');
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const XML_DECL = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n';
|
|
162
|
+
const REL_NS = 'http://schemas.openxmlformats.org/package/2006/relationships';
|
|
163
|
+
const OD_REL = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
|
|
164
|
+
|
|
165
|
+
async function buildImagePptx(images: Uint8Array[]): Promise<Uint8Array> {
|
|
166
|
+
const { zipSync, strToU8 } = await import('fflate');
|
|
167
|
+
const n = images.length;
|
|
168
|
+
const files: Record<string, Uint8Array> = {};
|
|
169
|
+
|
|
170
|
+
files['[Content_Types].xml'] = strToU8(contentTypesXml(n));
|
|
171
|
+
files['_rels/.rels'] = strToU8(rootRelsXml());
|
|
172
|
+
files['ppt/presentation.xml'] = strToU8(presentationXml(n));
|
|
173
|
+
files['ppt/_rels/presentation.xml.rels'] = strToU8(presentationRelsXml(n));
|
|
174
|
+
files['ppt/presProps.xml'] = strToU8(presPropsXml());
|
|
175
|
+
files['ppt/theme/theme1.xml'] = strToU8(themeXml());
|
|
176
|
+
files['ppt/slideMasters/slideMaster1.xml'] = strToU8(slideMasterXml());
|
|
177
|
+
files['ppt/slideMasters/_rels/slideMaster1.xml.rels'] = strToU8(slideMasterRelsXml());
|
|
178
|
+
files['ppt/slideLayouts/slideLayout1.xml'] = strToU8(slideLayoutXml());
|
|
179
|
+
files['ppt/slideLayouts/_rels/slideLayout1.xml.rels'] = strToU8(slideLayoutRelsXml());
|
|
180
|
+
|
|
181
|
+
for (let i = 0; i < n; i++) {
|
|
182
|
+
const idx = i + 1;
|
|
183
|
+
files[`ppt/slides/slide${idx}.xml`] = strToU8(slideXml());
|
|
184
|
+
files[`ppt/slides/_rels/slide${idx}.xml.rels`] = strToU8(slideRelsXml(idx));
|
|
185
|
+
files[`ppt/media/image${idx}.png`] = images[i];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return zipSync(files);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function contentTypesXml(n: number): string {
|
|
192
|
+
const slideOverrides = Array.from(
|
|
193
|
+
{ length: n },
|
|
194
|
+
(_, i) =>
|
|
195
|
+
`<Override PartName="/ppt/slides/slide${i + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>`,
|
|
196
|
+
).join('');
|
|
197
|
+
return `${XML_DECL}<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Default Extension="png" ContentType="image/png"/><Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/><Override PartName="/ppt/presProps.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presProps+xml"/><Override PartName="/ppt/slideMasters/slideMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml"/><Override PartName="/ppt/slideLayouts/slideLayout1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"/><Override PartName="/ppt/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/>${slideOverrides}</Types>`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function rootRelsXml(): string {
|
|
201
|
+
return `${XML_DECL}<Relationships xmlns="${REL_NS}"><Relationship Id="rId1" Type="${OD_REL}/officeDocument" Target="ppt/presentation.xml"/></Relationships>`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function presentationXml(n: number): string {
|
|
205
|
+
const sldIds = Array.from(
|
|
206
|
+
{ length: n },
|
|
207
|
+
(_, i) => `<p:sldId id="${256 + i}" r:id="rId${i + 3}"/>`,
|
|
208
|
+
).join('');
|
|
209
|
+
return `${XML_DECL}<p:presentation xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="${OD_REL}" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId1"/></p:sldMasterIdLst><p:sldIdLst>${sldIds}</p:sldIdLst><p:sldSz cx="${EMU_W}" cy="${EMU_H}"/><p:notesSz cx="6858000" cy="9144000"/></p:presentation>`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function presentationRelsXml(n: number): string {
|
|
213
|
+
const rels = [
|
|
214
|
+
`<Relationship Id="rId1" Type="${OD_REL}/slideMaster" Target="slideMasters/slideMaster1.xml"/>`,
|
|
215
|
+
`<Relationship Id="rId2" Type="${OD_REL}/presProps" Target="presProps.xml"/>`,
|
|
216
|
+
];
|
|
217
|
+
for (let i = 0; i < n; i++) {
|
|
218
|
+
rels.push(
|
|
219
|
+
`<Relationship Id="rId${i + 3}" Type="${OD_REL}/slide" Target="slides/slide${i + 1}.xml"/>`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
return `${XML_DECL}<Relationships xmlns="${REL_NS}">${rels.join('')}</Relationships>`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function presPropsXml(): string {
|
|
226
|
+
return `${XML_DECL}<p:presentationPr xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="${OD_REL}" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"/>`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function slideMasterXml(): string {
|
|
230
|
+
return `${XML_DECL}<p:sldMaster xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="${OD_REL}" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr></p:spTree></p:cSld><p:clrMap bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/><p:sldLayoutIdLst><p:sldLayoutId id="2147483649" r:id="rId1"/></p:sldLayoutIdLst></p:sldMaster>`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function slideMasterRelsXml(): string {
|
|
234
|
+
return `${XML_DECL}<Relationships xmlns="${REL_NS}"><Relationship Id="rId1" Type="${OD_REL}/slideLayout" Target="../slideLayouts/slideLayout1.xml"/><Relationship Id="rId2" Type="${OD_REL}/theme" Target="../theme/theme1.xml"/></Relationships>`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function slideLayoutXml(): string {
|
|
238
|
+
return `${XML_DECL}<p:sldLayout xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="${OD_REL}" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" type="blank" preserve="1"><p:cSld name="Blank"><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr></p:spTree></p:cSld><p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:sldLayout>`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function slideLayoutRelsXml(): string {
|
|
242
|
+
return `${XML_DECL}<Relationships xmlns="${REL_NS}"><Relationship Id="rId1" Type="${OD_REL}/slideMaster" Target="../slideMasters/slideMaster1.xml"/></Relationships>`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function slideXml(): string {
|
|
246
|
+
return `${XML_DECL}<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="${OD_REL}" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr><p:pic><p:nvPicPr><p:cNvPr id="2" name="Slide"/><p:cNvPicPr><a:picLocks noChangeAspect="1"/></p:cNvPicPr><p:nvPr/></p:nvPicPr><p:blipFill><a:blip r:embed="rId2"/><a:stretch><a:fillRect/></a:stretch></p:blipFill><p:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="${EMU_W}" cy="${EMU_H}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr></p:pic></p:spTree></p:cSld><p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:sld>`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function slideRelsXml(idx: number): string {
|
|
250
|
+
return `${XML_DECL}<Relationships xmlns="${REL_NS}"><Relationship Id="rId1" Type="${OD_REL}/slideLayout" Target="../slideLayouts/slideLayout1.xml"/><Relationship Id="rId2" Type="${OD_REL}/image" Target="../media/image${idx}.png"/></Relationships>`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function themeXml(): string {
|
|
254
|
+
return `${XML_DECL}<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="Office Theme"><a:themeElements><a:clrScheme name="Office"><a:dk1><a:sysClr val="windowText" lastClr="000000"/></a:dk1><a:lt1><a:sysClr val="window" lastClr="FFFFFF"/></a:lt1><a:dk2><a:srgbClr val="44546A"/></a:dk2><a:lt2><a:srgbClr val="E7E6E6"/></a:lt2><a:accent1><a:srgbClr val="4472C4"/></a:accent1><a:accent2><a:srgbClr val="ED7D31"/></a:accent2><a:accent3><a:srgbClr val="A5A5A5"/></a:accent3><a:accent4><a:srgbClr val="FFC000"/></a:accent4><a:accent5><a:srgbClr val="5B9BD5"/></a:accent5><a:accent6><a:srgbClr val="70AD47"/></a:accent6><a:hlink><a:srgbClr val="0563C1"/></a:hlink><a:folHlink><a:srgbClr val="954F72"/></a:folHlink></a:clrScheme><a:fontScheme name="Office"><a:majorFont><a:latin typeface="Calibri Light"/><a:ea typeface=""/><a:cs typeface=""/></a:majorFont><a:minorFont><a:latin typeface="Calibri"/><a:ea typeface=""/><a:cs typeface=""/></a:minorFont></a:fontScheme><a:fmtScheme name="Office"><a:fillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:gradFill rotWithShape="1"><a:gsLst><a:gs pos="0"><a:schemeClr val="phClr"><a:lumMod val="110000"/><a:satMod val="105000"/><a:tint val="67000"/></a:schemeClr></a:gs><a:gs pos="50000"><a:schemeClr val="phClr"><a:lumMod val="105000"/><a:satMod val="103000"/><a:tint val="73000"/></a:schemeClr></a:gs><a:gs pos="100000"><a:schemeClr val="phClr"><a:lumMod val="105000"/><a:satMod val="109000"/><a:tint val="81000"/></a:schemeClr></a:gs></a:gsLst><a:lin ang="5400000" scaled="0"/></a:gradFill></a:fillStyleLst><a:lnStyleLst><a:ln w="6350" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:prstDash val="solid"/><a:miter lim="800000"/></a:ln><a:ln w="12700" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:prstDash val="solid"/><a:miter lim="800000"/></a:ln><a:ln w="19050" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:prstDash val="solid"/><a:miter lim="800000"/></a:ln></a:lnStyleLst><a:effectStyleLst><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle></a:effectStyleLst><a:bgFillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"><a:tint val="95000"/><a:satMod val="170000"/></a:schemeClr></a:solidFill><a:gradFill rotWithShape="1"><a:gsLst><a:gs pos="0"><a:schemeClr val="phClr"><a:tint val="93000"/><a:satMod val="150000"/><a:shade val="98000"/><a:lumMod val="102000"/></a:schemeClr></a:gs><a:gs pos="50000"><a:schemeClr val="phClr"><a:tint val="98000"/><a:satMod val="130000"/><a:shade val="90000"/><a:lumMod val="103000"/></a:schemeClr></a:gs><a:gs pos="100000"><a:schemeClr val="phClr"><a:shade val="63000"/><a:satMod val="120000"/></a:schemeClr></a:gs></a:gsLst><a:lin ang="5400000" scaled="0"/></a:gradFill></a:bgFillStyleLst></a:fmtScheme></a:themeElements></a:theme>`;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function sleep(ms: number): Promise<void> {
|
|
258
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function nextPaint(): Promise<void> {
|
|
262
|
+
return new Promise((resolve) => {
|
|
263
|
+
let settled = false;
|
|
264
|
+
const settle = () => {
|
|
265
|
+
if (settled) return;
|
|
266
|
+
settled = true;
|
|
267
|
+
resolve();
|
|
268
|
+
};
|
|
269
|
+
requestAnimationFrame(settle);
|
|
270
|
+
setTimeout(settle, 50);
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function downloadBlob(blob: Blob, filename: string): void {
|
|
275
|
+
const url = URL.createObjectURL(blob);
|
|
276
|
+
const a = document.createElement('a');
|
|
277
|
+
a.href = url;
|
|
278
|
+
a.download = filename;
|
|
279
|
+
a.rel = 'noopener';
|
|
280
|
+
document.body.appendChild(a);
|
|
281
|
+
a.click();
|
|
282
|
+
a.remove();
|
|
283
|
+
setTimeout(() => URL.revokeObjectURL(url), 0);
|
|
284
|
+
}
|
package/src/app/lib/folders.ts
CHANGED
|
@@ -88,12 +88,22 @@ async function putAssign(slideId: string, folderId: string | null): Promise<void
|
|
|
88
88
|
if (!res.ok) throw new Error(`PUT /__folders/assign ${res.status}`);
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
async function putReorder(ids: string[]): Promise<void> {
|
|
92
|
+
const res = await fetch('/__folders/reorder', {
|
|
93
|
+
method: 'PUT',
|
|
94
|
+
headers: { 'content-type': 'application/json' },
|
|
95
|
+
body: JSON.stringify({ ids }),
|
|
96
|
+
});
|
|
97
|
+
if (!res.ok) throw new Error(`PUT /__folders/reorder ${res.status}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
91
100
|
export type UseFoldersResult = {
|
|
92
101
|
manifest: FoldersManifest;
|
|
93
102
|
loading: boolean;
|
|
94
103
|
create: (name: string, icon: FolderIcon) => Promise<Folder>;
|
|
95
104
|
update: (id: string, patch: { name?: string; icon?: FolderIcon }) => Promise<void>;
|
|
96
105
|
remove: (id: string) => Promise<void>;
|
|
106
|
+
reorder: (ids: string[]) => Promise<void>;
|
|
97
107
|
assign: (slideId: string, folderId: string | null) => Promise<void>;
|
|
98
108
|
renameSlide: (slideId: string, name: string) => Promise<void>;
|
|
99
109
|
duplicateSlide: (slideId: string, newId?: string) => Promise<string>;
|
|
@@ -163,6 +173,23 @@ export function useFolders(): UseFoldersResult {
|
|
|
163
173
|
[refresh],
|
|
164
174
|
);
|
|
165
175
|
|
|
176
|
+
const reorder = useCallback(
|
|
177
|
+
async (ids: string[]) => {
|
|
178
|
+
const prev = manifest;
|
|
179
|
+
const byId = new Map(prev.folders.map((f) => [f.id, f]));
|
|
180
|
+
const next = ids.map((id) => byId.get(id)).filter((f): f is Folder => Boolean(f));
|
|
181
|
+
if (next.length !== prev.folders.length) return;
|
|
182
|
+
setManifest({ ...prev, folders: next });
|
|
183
|
+
try {
|
|
184
|
+
await putReorder(ids);
|
|
185
|
+
} catch (err) {
|
|
186
|
+
setManifest(prev);
|
|
187
|
+
throw err;
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
[manifest],
|
|
191
|
+
);
|
|
192
|
+
|
|
166
193
|
const assign = useCallback(
|
|
167
194
|
async (slideId: string, folderId: string | null) => {
|
|
168
195
|
await putAssign(slideId, folderId);
|
|
@@ -202,6 +229,7 @@ export function useFolders(): UseFoldersResult {
|
|
|
202
229
|
create,
|
|
203
230
|
update,
|
|
204
231
|
remove,
|
|
232
|
+
reorder,
|
|
205
233
|
assign,
|
|
206
234
|
renameSlide,
|
|
207
235
|
duplicateSlide,
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { findSlideSource } from './fiber.ts';
|
|
3
|
+
|
|
4
|
+
class FakeHTMLElement {
|
|
5
|
+
dataset: Record<string, string> = {};
|
|
6
|
+
private closestSelf: FakeHTMLElement | null = null;
|
|
7
|
+
setClosestSelfForSlideLoc() {
|
|
8
|
+
this.closestSelf = this;
|
|
9
|
+
}
|
|
10
|
+
closest(selector: string): FakeHTMLElement | null {
|
|
11
|
+
if (selector === '[data-slide-loc]') return this.closestSelf;
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type DebugSource = { fileName?: string; lineNumber?: number; columnNumber?: number };
|
|
17
|
+
type FakeFiber = {
|
|
18
|
+
return: FakeFiber | null;
|
|
19
|
+
stateNode?: unknown;
|
|
20
|
+
_debugSource?: DebugSource;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function makeEl(opts: { slideLoc?: string; fiber?: FakeFiber } = {}): FakeHTMLElement {
|
|
24
|
+
const el = new FakeHTMLElement();
|
|
25
|
+
if (opts.slideLoc) {
|
|
26
|
+
el.dataset.slideLoc = opts.slideLoc;
|
|
27
|
+
el.setClosestSelfForSlideLoc();
|
|
28
|
+
}
|
|
29
|
+
if (opts.fiber) {
|
|
30
|
+
(el as unknown as Record<string, FakeFiber>).__reactFiber$test = opts.fiber;
|
|
31
|
+
}
|
|
32
|
+
return el;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function makeFiber(opts: {
|
|
36
|
+
fileName?: string;
|
|
37
|
+
line?: number;
|
|
38
|
+
column?: number;
|
|
39
|
+
host?: boolean;
|
|
40
|
+
parent?: FakeFiber | null;
|
|
41
|
+
}): FakeFiber {
|
|
42
|
+
const source: DebugSource | undefined =
|
|
43
|
+
opts.fileName !== undefined
|
|
44
|
+
? { fileName: opts.fileName, lineNumber: opts.line, columnNumber: opts.column }
|
|
45
|
+
: undefined;
|
|
46
|
+
return {
|
|
47
|
+
return: opts.parent ?? null,
|
|
48
|
+
stateNode: opts.host ? new FakeHTMLElement() : undefined,
|
|
49
|
+
_debugSource: source,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
beforeAll(() => {
|
|
54
|
+
vi.stubGlobal('HTMLElement', FakeHTMLElement);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterAll(() => {
|
|
58
|
+
vi.unstubAllGlobals();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('findSlideSource primary path', () => {
|
|
62
|
+
it('reads line:column from data-slide-loc', () => {
|
|
63
|
+
const el = makeEl({ slideLoc: '42:7' });
|
|
64
|
+
const hit = findSlideSource(el as unknown as HTMLElement, 'cover');
|
|
65
|
+
expect(hit).not.toBeNull();
|
|
66
|
+
expect(hit?.line).toBe(42);
|
|
67
|
+
expect(hit?.column).toBe(7);
|
|
68
|
+
expect(hit?.anchor).toBe(el as unknown as HTMLElement);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('findSlideSource fallback', () => {
|
|
73
|
+
it('matches a POSIX fileName', () => {
|
|
74
|
+
const fiber = makeFiber({
|
|
75
|
+
fileName: '/repo/slides/cover/index.tsx',
|
|
76
|
+
line: 10,
|
|
77
|
+
column: 4,
|
|
78
|
+
host: true,
|
|
79
|
+
});
|
|
80
|
+
const el = makeEl({ fiber });
|
|
81
|
+
const hit = findSlideSource(el as unknown as HTMLElement, 'cover');
|
|
82
|
+
expect(hit).not.toBeNull();
|
|
83
|
+
expect(hit?.line).toBe(10);
|
|
84
|
+
expect(hit?.column).toBe(4);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('matches a Windows-backslash fileName', () => {
|
|
88
|
+
const fiber = makeFiber({
|
|
89
|
+
fileName: 'C:\\repo\\slides\\cover\\index.tsx',
|
|
90
|
+
line: 11,
|
|
91
|
+
column: 2,
|
|
92
|
+
host: true,
|
|
93
|
+
});
|
|
94
|
+
const el = makeEl({ fiber });
|
|
95
|
+
const hit = findSlideSource(el as unknown as HTMLElement, 'cover');
|
|
96
|
+
expect(hit).not.toBeNull();
|
|
97
|
+
expect(hit?.line).toBe(11);
|
|
98
|
+
expect(hit?.column).toBe(2);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('matches a fileName carrying an HMR ?t= query', () => {
|
|
102
|
+
const fiber = makeFiber({
|
|
103
|
+
fileName: '/repo/slides/cover/index.tsx?t=1700000000000',
|
|
104
|
+
line: 12,
|
|
105
|
+
column: 0,
|
|
106
|
+
host: true,
|
|
107
|
+
});
|
|
108
|
+
const el = makeEl({ fiber });
|
|
109
|
+
const hit = findSlideSource(el as unknown as HTMLElement, 'cover');
|
|
110
|
+
expect(hit).not.toBeNull();
|
|
111
|
+
expect(hit?.line).toBe(12);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('matches a Windows fileName with an HMR query', () => {
|
|
115
|
+
const fiber = makeFiber({
|
|
116
|
+
fileName: 'C:\\repo\\slides\\cover\\index.tsx?t=1700000000000',
|
|
117
|
+
line: 13,
|
|
118
|
+
column: 1,
|
|
119
|
+
host: true,
|
|
120
|
+
});
|
|
121
|
+
const el = makeEl({ fiber });
|
|
122
|
+
const hit = findSlideSource(el as unknown as HTMLElement, 'cover');
|
|
123
|
+
expect(hit).not.toBeNull();
|
|
124
|
+
expect(hit?.line).toBe(13);
|
|
125
|
+
expect(hit?.column).toBe(1);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('returns null when the fiber fileName points at a different slideId', () => {
|
|
129
|
+
const fiber = makeFiber({
|
|
130
|
+
fileName: '/repo/slides/other/index.tsx',
|
|
131
|
+
line: 10,
|
|
132
|
+
column: 4,
|
|
133
|
+
host: true,
|
|
134
|
+
});
|
|
135
|
+
const el = makeEl({ fiber });
|
|
136
|
+
const hit = findSlideSource(el as unknown as HTMLElement, 'cover');
|
|
137
|
+
expect(hit).toBeNull();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('walks up the fiber chain until it finds a matching source', () => {
|
|
141
|
+
const parent = makeFiber({
|
|
142
|
+
fileName: '/repo/slides/cover/index.tsx',
|
|
143
|
+
line: 99,
|
|
144
|
+
column: 3,
|
|
145
|
+
host: true,
|
|
146
|
+
});
|
|
147
|
+
const leaf = makeFiber({ parent, host: true });
|
|
148
|
+
const el = makeEl({ fiber: leaf });
|
|
149
|
+
const hit = findSlideSource(el as unknown as HTMLElement, 'cover');
|
|
150
|
+
expect(hit).not.toBeNull();
|
|
151
|
+
expect(hit?.line).toBe(99);
|
|
152
|
+
expect(hit?.column).toBe(3);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -28,6 +28,12 @@ function getSource(fiber: FiberLike) {
|
|
|
28
28
|
return fiber._debugSource ?? fiber.memoizedProps?.__source;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// `_debugSource.fileName` may carry Vite's HMR query (`?t=…`) and, on
|
|
32
|
+
// Windows, backslash separators. Both break the naive `endsWith` match.
|
|
33
|
+
function normalizeDebugFileName(fileName: string): string {
|
|
34
|
+
return fileName.split(/[?#]/)[0].replace(/\\/g, '/');
|
|
35
|
+
}
|
|
36
|
+
|
|
31
37
|
export function findSlideSource(
|
|
32
38
|
el: HTMLElement,
|
|
33
39
|
slideId: string,
|
|
@@ -58,7 +64,12 @@ export function findSlideSource(
|
|
|
58
64
|
while (fiber) {
|
|
59
65
|
const src = getSource(fiber);
|
|
60
66
|
const isHost = fiber.stateNode instanceof HTMLElement;
|
|
61
|
-
if (
|
|
67
|
+
if (
|
|
68
|
+
src?.fileName &&
|
|
69
|
+
normalizeDebugFileName(src.fileName).endsWith(needle) &&
|
|
70
|
+
src.lineNumber &&
|
|
71
|
+
(!opts?.hostOnly || isHost)
|
|
72
|
+
) {
|
|
62
73
|
return {
|
|
63
74
|
line: src.lineNumber,
|
|
64
75
|
column: src.columnNumber ?? 0,
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import config from 'virtual:open-slide/config';
|
|
2
|
+
import { useSyncExternalStore } from 'react';
|
|
3
|
+
import { en } from '../../locale/en';
|
|
4
|
+
import { ja } from '../../locale/ja';
|
|
5
|
+
import type { Locale } from '../../locale/types';
|
|
6
|
+
import { zhCN } from '../../locale/zh-cn';
|
|
7
|
+
import { zhTW } from '../../locale/zh-tw';
|
|
8
|
+
|
|
9
|
+
export type LocaleId = Locale['id'];
|
|
10
|
+
|
|
11
|
+
const LOCALES: Record<LocaleId, Locale> = {
|
|
12
|
+
en,
|
|
13
|
+
'zh-TW': zhTW,
|
|
14
|
+
'zh-CN': zhCN,
|
|
15
|
+
ja,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const LOCALE_OPTIONS: ReadonlyArray<{ id: LocaleId; label: string }> = [
|
|
19
|
+
{ id: 'en', label: 'English' },
|
|
20
|
+
{ id: 'zh-TW', label: '繁體中文' },
|
|
21
|
+
{ id: 'zh-CN', label: '简体中文' },
|
|
22
|
+
{ id: 'ja', label: '日本語' },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const STORAGE_KEY = 'open-slide:locale';
|
|
26
|
+
const configLocale = config.locale as Locale | undefined;
|
|
27
|
+
|
|
28
|
+
function isLocaleId(value: string | null): value is LocaleId {
|
|
29
|
+
return value === 'en' || value === 'zh-TW' || value === 'zh-CN' || value === 'ja';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readStored(): Locale {
|
|
33
|
+
try {
|
|
34
|
+
const stored = localStorage.getItem(STORAGE_KEY);
|
|
35
|
+
if (isLocaleId(stored)) return LOCALES[stored];
|
|
36
|
+
} catch {}
|
|
37
|
+
return configLocale ?? en;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// A module-level store (rather than React context) so every React root the
|
|
41
|
+
// runtime mounts — the app shell plus the standalone roots used for HTML/PDF
|
|
42
|
+
// export — shares one locale without needing a provider above each of them.
|
|
43
|
+
let current: Locale = readStored();
|
|
44
|
+
const listeners = new Set<() => void>();
|
|
45
|
+
|
|
46
|
+
function subscribe(listener: () => void): () => void {
|
|
47
|
+
listeners.add(listener);
|
|
48
|
+
return () => {
|
|
49
|
+
listeners.delete(listener);
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getSnapshot(): Locale {
|
|
54
|
+
return current;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function setLocale(id: LocaleId): void {
|
|
58
|
+
current = LOCALES[id];
|
|
59
|
+
try {
|
|
60
|
+
localStorage.setItem(STORAGE_KEY, id);
|
|
61
|
+
} catch {}
|
|
62
|
+
for (const listener of listeners) listener();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function useLocaleValue(): Locale {
|
|
66
|
+
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
67
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { type RefObject, useEffect } from 'react';
|
|
2
|
+
|
|
3
|
+
// Clicks that land on (or inside) these never navigate — interactive slide
|
|
4
|
+
// content keeps its click, and present chrome is excluded via data-osd-chrome.
|
|
5
|
+
// Authors can opt any element out with a data-osd-interactive attribute.
|
|
6
|
+
const NAV_PASSTHROUGH =
|
|
7
|
+
'a, button, input, textarea, select, label, summary, iframe, video, audio, embed, object, [role="button"], [role="link"], [contenteditable="true"], [data-osd-interactive], [data-osd-chrome]';
|
|
8
|
+
|
|
9
|
+
type UseClickPageNavigationOptions<T extends HTMLElement> = {
|
|
10
|
+
ref: RefObject<T>;
|
|
11
|
+
enabled?: boolean;
|
|
12
|
+
/** Fraction of the width on each side that navigates; the center is inert. */
|
|
13
|
+
edgeRatio?: number;
|
|
14
|
+
canPrev: boolean;
|
|
15
|
+
canNext: boolean;
|
|
16
|
+
onPrev: () => void;
|
|
17
|
+
onNext: () => void;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function useClickPageNavigation<T extends HTMLElement>({
|
|
21
|
+
ref,
|
|
22
|
+
enabled = true,
|
|
23
|
+
edgeRatio = 0.3,
|
|
24
|
+
canPrev,
|
|
25
|
+
canNext,
|
|
26
|
+
onPrev,
|
|
27
|
+
onNext,
|
|
28
|
+
}: UseClickPageNavigationOptions<T>) {
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
const el = ref.current;
|
|
31
|
+
if (!el || !enabled) return;
|
|
32
|
+
|
|
33
|
+
const onClick = (event: MouseEvent) => {
|
|
34
|
+
if (event.button !== 0 || event.defaultPrevented) return;
|
|
35
|
+
const target = event.target;
|
|
36
|
+
if (target instanceof HTMLElement && target.closest(NAV_PASSTHROUGH)) return;
|
|
37
|
+
if (window.getSelection()?.toString()) return;
|
|
38
|
+
|
|
39
|
+
const rect = el.getBoundingClientRect();
|
|
40
|
+
if (rect.width === 0) return;
|
|
41
|
+
const x = (event.clientX - rect.left) / rect.width;
|
|
42
|
+
if (x < edgeRatio) {
|
|
43
|
+
if (canPrev) onPrev();
|
|
44
|
+
} else if (x > 1 - edgeRatio) {
|
|
45
|
+
if (canNext) onNext();
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
el.addEventListener('click', onClick);
|
|
50
|
+
return () => el.removeEventListener('click', onClick);
|
|
51
|
+
}, [ref, enabled, edgeRatio, canPrev, canNext, onPrev, onNext]);
|
|
52
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
// Matches Tailwind's `md` breakpoint — below it the slide viewer hides desktop
|
|
4
|
+
// navigation chrome and relies on tap-to-navigate instead.
|
|
5
|
+
const QUERY = '(max-width: 767.98px)';
|
|
6
|
+
|
|
7
|
+
export function useIsMobile(): boolean {
|
|
8
|
+
const [mobile, setMobile] = useState(() => {
|
|
9
|
+
if (typeof window === 'undefined') return false;
|
|
10
|
+
return window.matchMedia(QUERY).matches;
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
const mql = window.matchMedia(QUERY);
|
|
15
|
+
const onChange = (e: MediaQueryListEvent) => setMobile(e.matches);
|
|
16
|
+
mql.addEventListener('change', onChange);
|
|
17
|
+
return () => mql.removeEventListener('change', onChange);
|
|
18
|
+
}, []);
|
|
19
|
+
|
|
20
|
+
return mobile;
|
|
21
|
+
}
|