design-folio 0.1.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/README.md +109 -0
- package/package.json +45 -0
- package/src/config/defaults.js +19 -0
- package/src/config/load-config.js +20 -0
- package/src/hooks/useHashRoute.js +93 -0
- package/src/hooks/usePanZoom.js +156 -0
- package/src/hooks/useTheme.js +21 -0
- package/src/icons.jsx +261 -0
- package/src/index.js +1 -0
- package/src/plugin/vite-plugin.js +57 -0
- package/src/styles/folio.css +3 -0
- package/src/viewer/ChangelogTab.jsx +38 -0
- package/src/viewer/DesignNotes.jsx +18 -0
- package/src/viewer/DocumentsTab.jsx +76 -0
- package/src/viewer/FolioViewer.jsx +140 -0
- package/src/viewer/GalleryView.jsx +103 -0
- package/src/viewer/Header.jsx +30 -0
- package/src/viewer/PrototypeTab.jsx +20 -0
- package/src/viewer/ScreenFrame.jsx +36 -0
- package/src/viewer/ScreensTab.jsx +158 -0
- package/src/viewer/ScreensToolbar.jsx +134 -0
- package/src/viewer/Sidebar.jsx +106 -0
- package/src/viewer/SimulatedWindow.jsx +47 -0
- package/src/viewer/StatePicker.jsx +83 -0
- package/src/viewer/TabNav.jsx +40 -0
- package/src/viewer/ThemeToggle.jsx +23 -0
package/README.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Design Folio
|
|
2
|
+
|
|
3
|
+
A design presentation system for vibe-coded prototypes. Bridges the gap between how designers build prototypes with Claude and how they share and present design work.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
Folio wraps your prototype in a viewer with:
|
|
8
|
+
|
|
9
|
+
- **Prototype tab** — your prototype at full size for testing interactions. No state picker, no design notes. Navigate using the links and UI in your prototype.
|
|
10
|
+
- **Screens tab** — all your screens laid out for design review. Grid view (4-up with pan/zoom) and gallery view (single screen with design notes). This is where you examine design decisions.
|
|
11
|
+
- **Changelog** — Claude-summarized history of what changed and when.
|
|
12
|
+
- **Documents** — links to Figma files, PRDs, research decks, and other external context.
|
|
13
|
+
|
|
14
|
+
## Setup
|
|
15
|
+
|
|
16
|
+
Folio is designed to be set up by the `/folio` Claude Code skill, which handles installation, configuration, and ongoing maintenance automatically. You shouldn't need to configure anything manually.
|
|
17
|
+
|
|
18
|
+
If you want to set it up manually:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install design-folio --save-dev
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Add to your Vite config:
|
|
25
|
+
|
|
26
|
+
```js
|
|
27
|
+
import { folioPlugin } from 'design-folio/vite';
|
|
28
|
+
|
|
29
|
+
export default defineConfig({
|
|
30
|
+
plugins: [folioPlugin()],
|
|
31
|
+
});
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Create a `folio.json` at your project root:
|
|
35
|
+
|
|
36
|
+
```json
|
|
37
|
+
{
|
|
38
|
+
"name": "Project Name",
|
|
39
|
+
"designer": "Your Name",
|
|
40
|
+
"description": "A short paragraph about the project.",
|
|
41
|
+
"viewport": "desktop",
|
|
42
|
+
"component": "./src/App.jsx",
|
|
43
|
+
"states": [
|
|
44
|
+
{
|
|
45
|
+
"key": "home",
|
|
46
|
+
"name": "1. Home",
|
|
47
|
+
"group": "Main",
|
|
48
|
+
"state": {},
|
|
49
|
+
"notes": "Design rationale for this screen."
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Configuration
|
|
56
|
+
|
|
57
|
+
All fields in `folio.json` are optional. Folio works with zero config by defaulting to `./src/App.jsx` with a desktop viewport.
|
|
58
|
+
|
|
59
|
+
| Field | Type | Default | Description |
|
|
60
|
+
|-------|------|---------|-------------|
|
|
61
|
+
| `name` | string | `""` | Project name shown in the header |
|
|
62
|
+
| `designer` | string | `""` | Designer name shown in the header |
|
|
63
|
+
| `description` | string | `""` | Project description shown in the prototype tab sidebar |
|
|
64
|
+
| `viewport` | string or object | `"desktop"` | `"desktop"`, `"wide-desktop"`, `"tablet"`, `"mobile"`, `"responsive"`, or `{ width, height }` |
|
|
65
|
+
| `component` | string | `"./src/App.jsx"` | Path to the prototype component |
|
|
66
|
+
| `syncTheme` | boolean | `false` | Pass Folio's light/dark theme to the prototype |
|
|
67
|
+
| `prototypeUrl` | string | `"/"` | URL for "Open prototype" link |
|
|
68
|
+
| `states` | array | `[]` | Screen states with keys, names, groups, state objects, and notes |
|
|
69
|
+
| `changelog` | array | `[]` | Changelog entries grouped by date |
|
|
70
|
+
| `links` | array | `[]` | External document links |
|
|
71
|
+
|
|
72
|
+
### Viewport presets
|
|
73
|
+
|
|
74
|
+
| Preset | Dimensions |
|
|
75
|
+
|--------|-----------|
|
|
76
|
+
| `desktop` | 1080 × 720 |
|
|
77
|
+
| `wide-desktop` | 1440 × 900 |
|
|
78
|
+
| `tablet` | 768 × 1024 |
|
|
79
|
+
| `mobile` | 375 × 812 |
|
|
80
|
+
| `responsive` | 100% width, auto height |
|
|
81
|
+
|
|
82
|
+
### State objects
|
|
83
|
+
|
|
84
|
+
Each state in the `states` array drives a screen. Your prototype component receives the `state` object as an `appState` prop and should render accordingly.
|
|
85
|
+
|
|
86
|
+
```json
|
|
87
|
+
{
|
|
88
|
+
"key": "home-empty",
|
|
89
|
+
"name": "2. Empty State",
|
|
90
|
+
"group": "Home",
|
|
91
|
+
"state": { "items": [], "loading": false },
|
|
92
|
+
"notes": "Shown before any data is loaded."
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## How it works
|
|
97
|
+
|
|
98
|
+
Folio installs as a Vite plugin that serves your prototype component inside its viewer. Your prototype code is never modified — Folio wraps around it.
|
|
99
|
+
|
|
100
|
+
The `/folio` skill handles:
|
|
101
|
+
- Detecting your prototype type and structure
|
|
102
|
+
- Generating `folio.json` with states, notes, and changelog
|
|
103
|
+
- Pulling the project description from your README
|
|
104
|
+
- Installing the Tailwind CSS dependency (required by the viewer)
|
|
105
|
+
- Keeping the config updated as you continue vibe coding
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "design-folio",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A design presentation system for vibe-coded prototypes",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js",
|
|
9
|
+
"./vite": "./src/plugin/vite-plugin.js",
|
|
10
|
+
"./styles": "./src/styles/folio.css"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"dev": "cd demo && npm run dev",
|
|
18
|
+
"test": "vitest run",
|
|
19
|
+
"test:watch": "vitest"
|
|
20
|
+
},
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
23
|
+
"react-dom": "^18.0.0 || ^19.0.0",
|
|
24
|
+
"tailwindcss": "^4.0.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@tailwindcss/vite": "^4.2.2",
|
|
28
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
29
|
+
"@testing-library/react": "^16.3.2",
|
|
30
|
+
"@vitejs/plugin-react": "^6.0.1",
|
|
31
|
+
"jsdom": "^29.0.1",
|
|
32
|
+
"react": "^19.2.4",
|
|
33
|
+
"react-dom": "^19.2.4",
|
|
34
|
+
"tailwindcss": "^4.2.2",
|
|
35
|
+
"vite": "^8.0.3",
|
|
36
|
+
"vitest": "^4.1.2"
|
|
37
|
+
},
|
|
38
|
+
"keywords": [
|
|
39
|
+
"design",
|
|
40
|
+
"prototype",
|
|
41
|
+
"viewer",
|
|
42
|
+
"vite-plugin"
|
|
43
|
+
],
|
|
44
|
+
"license": "UNLICENSED"
|
|
45
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export const VIEWPORT_PRESETS = {
|
|
2
|
+
desktop: { width: 1080, height: 720 },
|
|
3
|
+
'wide-desktop': { width: 1440, height: 900 },
|
|
4
|
+
tablet: { width: 768, height: 1024 },
|
|
5
|
+
mobile: { width: 375, height: 812 },
|
|
6
|
+
responsive: { width: '100%', height: 'auto' },
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_CONFIG = {
|
|
10
|
+
name: '',
|
|
11
|
+
designer: '',
|
|
12
|
+
description: '',
|
|
13
|
+
viewport: 'desktop',
|
|
14
|
+
component: './src/App.jsx',
|
|
15
|
+
syncTheme: false,
|
|
16
|
+
states: [],
|
|
17
|
+
changelog: [],
|
|
18
|
+
links: [],
|
|
19
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { DEFAULT_CONFIG } from './defaults.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Merges a user config object over the defaults.
|
|
5
|
+
* Returns a new object — does not mutate DEFAULT_CONFIG.
|
|
6
|
+
* Returns defaults when userConfig is null or undefined.
|
|
7
|
+
*/
|
|
8
|
+
export function mergeConfig(userConfig) {
|
|
9
|
+
if (userConfig == null) {
|
|
10
|
+
return { ...DEFAULT_CONFIG, states: [], changelog: [], links: [] };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
...DEFAULT_CONFIG,
|
|
15
|
+
...userConfig,
|
|
16
|
+
states: Array.isArray(userConfig.states) ? [...userConfig.states] : [...DEFAULT_CONFIG.states],
|
|
17
|
+
changelog: Array.isArray(userConfig.changelog) ? [...userConfig.changelog] : [...DEFAULT_CONFIG.changelog],
|
|
18
|
+
links: Array.isArray(userConfig.links) ? [...userConfig.links] : [...DEFAULT_CONFIG.links],
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parses the current window.location.hash into { tab, stateKey }.
|
|
5
|
+
*
|
|
6
|
+
* Supported patterns:
|
|
7
|
+
* #/state/{key} → { tab: 'prototype', stateKey: key }
|
|
8
|
+
* #/screens/{key} → { tab: 'screens', stateKey: key }
|
|
9
|
+
* #/screens → { tab: 'screens', stateKey: null }
|
|
10
|
+
* #/changelog → { tab: 'changelog', stateKey: null }
|
|
11
|
+
* #/documents → { tab: 'documents', stateKey: null }
|
|
12
|
+
* (empty / none) → { tab: 'prototype', stateKey: null }
|
|
13
|
+
*/
|
|
14
|
+
function parseHash(hash) {
|
|
15
|
+
if (!hash || hash === '#' || hash === '#/') {
|
|
16
|
+
return { tab: 'prototype', stateKey: null };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Strip the leading '#'
|
|
20
|
+
const path = hash.startsWith('#') ? hash.slice(1) : hash;
|
|
21
|
+
// Strip optional leading '/'
|
|
22
|
+
const trimmed = path.startsWith('/') ? path.slice(1) : path;
|
|
23
|
+
|
|
24
|
+
const stateMatch = trimmed.match(/^state\/(.+)$/);
|
|
25
|
+
if (stateMatch) {
|
|
26
|
+
return { tab: 'prototype', stateKey: stateMatch[1] };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const screensMatch = trimmed.match(/^screens\/(.+)$/);
|
|
30
|
+
if (screensMatch) {
|
|
31
|
+
return { tab: 'screens', stateKey: screensMatch[1] };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const knownTabs = ['screens', 'changelog', 'documents'];
|
|
35
|
+
if (knownTabs.includes(trimmed)) {
|
|
36
|
+
return { tab: trimmed, stateKey: null };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return { tab: 'prototype', stateKey: null };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Hook that synchronises React state with the URL hash.
|
|
44
|
+
*
|
|
45
|
+
* Returns:
|
|
46
|
+
* tab - current tab name ('prototype' | 'screens' | 'changelog' | 'documents')
|
|
47
|
+
* stateKey - state key (string or null)
|
|
48
|
+
* navigateTo - (key: string) => void — navigate to #/state/{key}
|
|
49
|
+
* navigateToTab - (tab: string) => void — navigate to #/{tab} (empty for 'prototype')
|
|
50
|
+
* navigateToScreen - (key: string) => void — navigate to #/screens/{key}
|
|
51
|
+
*/
|
|
52
|
+
export function useHashRoute() {
|
|
53
|
+
const [route, setRoute] = useState(() => parseHash(window.location.hash));
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
function handleHashChange() {
|
|
57
|
+
setRoute(parseHash(window.location.hash));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
window.addEventListener('hashchange', handleHashChange);
|
|
61
|
+
return () => {
|
|
62
|
+
window.removeEventListener('hashchange', handleHashChange);
|
|
63
|
+
};
|
|
64
|
+
}, []);
|
|
65
|
+
|
|
66
|
+
const navigateTo = useCallback((key) => {
|
|
67
|
+
window.location.hash = `#/state/${key}`;
|
|
68
|
+
setRoute({ tab: 'prototype', stateKey: key });
|
|
69
|
+
}, []);
|
|
70
|
+
|
|
71
|
+
const navigateToTab = useCallback((tab) => {
|
|
72
|
+
if (tab === 'prototype') {
|
|
73
|
+
window.location.hash = '';
|
|
74
|
+
setRoute({ tab: 'prototype', stateKey: null });
|
|
75
|
+
} else {
|
|
76
|
+
window.location.hash = `#/${tab}`;
|
|
77
|
+
setRoute({ tab, stateKey: null });
|
|
78
|
+
}
|
|
79
|
+
}, []);
|
|
80
|
+
|
|
81
|
+
const navigateToScreen = useCallback((key) => {
|
|
82
|
+
window.location.hash = `#/screens/${key}`;
|
|
83
|
+
setRoute({ tab: 'screens', stateKey: key });
|
|
84
|
+
}, []);
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
tab: route.tab,
|
|
88
|
+
stateKey: route.stateKey,
|
|
89
|
+
navigateTo,
|
|
90
|
+
navigateToTab,
|
|
91
|
+
navigateToScreen,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
2
|
+
|
|
3
|
+
const MIN_ZOOM = 0.1;
|
|
4
|
+
const MAX_ZOOM = 2;
|
|
5
|
+
const INITIAL_ZOOM = 0.35;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* usePanZoom – provides pan and zoom state for a canvas container.
|
|
9
|
+
*
|
|
10
|
+
* @param {React.RefObject} containerRef – ref to the scroll/clip container element
|
|
11
|
+
* @returns {{ zoom, offset, fitToView, handlers }}
|
|
12
|
+
*/
|
|
13
|
+
export function usePanZoom(containerRef) {
|
|
14
|
+
const [zoom, setZoom] = useState(INITIAL_ZOOM);
|
|
15
|
+
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
|
16
|
+
|
|
17
|
+
// Pan drag state (stored in a ref to avoid stale closures in mousemove)
|
|
18
|
+
const isPanning = useRef(false);
|
|
19
|
+
const panStart = useRef({ x: 0, y: 0 });
|
|
20
|
+
const offsetAtStart = useRef({ x: 0, y: 0 });
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Wheel: zoom toward cursor (Cmd/Ctrl + wheel only)
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
const onWheel = useCallback(
|
|
26
|
+
(e) => {
|
|
27
|
+
e.preventDefault();
|
|
28
|
+
|
|
29
|
+
const container = containerRef.current;
|
|
30
|
+
if (!container) return;
|
|
31
|
+
|
|
32
|
+
// Ctrl/Cmd + wheel = zoom toward cursor
|
|
33
|
+
if (e.ctrlKey || e.metaKey) {
|
|
34
|
+
const rect = container.getBoundingClientRect();
|
|
35
|
+
const cursorX = e.clientX - rect.left;
|
|
36
|
+
const cursorY = e.clientY - rect.top;
|
|
37
|
+
|
|
38
|
+
setZoom((prevZoom) => {
|
|
39
|
+
const delta = -e.deltaY * 0.001;
|
|
40
|
+
const nextZoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, prevZoom + delta * prevZoom));
|
|
41
|
+
|
|
42
|
+
setOffset((prevOffset) => ({
|
|
43
|
+
x: cursorX - (cursorX - prevOffset.x) * (nextZoom / prevZoom),
|
|
44
|
+
y: cursorY - (cursorY - prevOffset.y) * (nextZoom / prevZoom),
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
return nextZoom;
|
|
48
|
+
});
|
|
49
|
+
} else {
|
|
50
|
+
// Regular scroll/trackpad = pan
|
|
51
|
+
setOffset((prev) => ({
|
|
52
|
+
x: prev.x - e.deltaX,
|
|
53
|
+
y: prev.y - e.deltaY,
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
[containerRef],
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Pan – mouse drag
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
const onMouseDown = useCallback((e) => {
|
|
64
|
+
// Skip if the click target is an interactive element
|
|
65
|
+
if (e.target.closest('a, button')) return;
|
|
66
|
+
|
|
67
|
+
isPanning.current = true;
|
|
68
|
+
panStart.current = { x: e.clientX, y: e.clientY };
|
|
69
|
+
offsetAtStart.current = { x: 0, y: 0 }; // will be set from current offset below
|
|
70
|
+
|
|
71
|
+
// Capture current offset in the ref so mousemove can reference it
|
|
72
|
+
setOffset((prev) => {
|
|
73
|
+
offsetAtStart.current = prev;
|
|
74
|
+
return prev;
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
document.body.style.userSelect = 'none';
|
|
78
|
+
}, []);
|
|
79
|
+
|
|
80
|
+
const onMouseMove = useCallback((e) => {
|
|
81
|
+
if (!isPanning.current) return;
|
|
82
|
+
|
|
83
|
+
const dx = e.clientX - panStart.current.x;
|
|
84
|
+
const dy = e.clientY - panStart.current.y;
|
|
85
|
+
|
|
86
|
+
setOffset({
|
|
87
|
+
x: offsetAtStart.current.x + dx,
|
|
88
|
+
y: offsetAtStart.current.y + dy,
|
|
89
|
+
});
|
|
90
|
+
}, []);
|
|
91
|
+
|
|
92
|
+
const endPan = useCallback(() => {
|
|
93
|
+
if (!isPanning.current) return;
|
|
94
|
+
isPanning.current = false;
|
|
95
|
+
document.body.style.userSelect = '';
|
|
96
|
+
}, []);
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// fitToView – measures content vs container and calculates a zoom to fit
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
const contentRef = useRef(null);
|
|
102
|
+
|
|
103
|
+
const fitToView = useCallback(
|
|
104
|
+
(ref) => {
|
|
105
|
+
// Allow passing an explicit content ref, or use the stored one
|
|
106
|
+
const contentEl = (ref || contentRef).current;
|
|
107
|
+
const containerEl = containerRef.current;
|
|
108
|
+
if (!contentEl || !containerEl) return;
|
|
109
|
+
|
|
110
|
+
const containerRect = containerEl.getBoundingClientRect();
|
|
111
|
+
// Measure content at zoom=1 (natural size)
|
|
112
|
+
const naturalWidth = contentEl.scrollWidth;
|
|
113
|
+
const naturalHeight = contentEl.scrollHeight;
|
|
114
|
+
|
|
115
|
+
const margin = 0.9;
|
|
116
|
+
const zoomX = (containerRect.width / naturalWidth) * margin;
|
|
117
|
+
const zoomY = (containerRect.height / naturalHeight) * margin;
|
|
118
|
+
const nextZoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, Math.min(zoomX, zoomY)));
|
|
119
|
+
|
|
120
|
+
// Center the content
|
|
121
|
+
const scaledWidth = naturalWidth * nextZoom;
|
|
122
|
+
const scaledHeight = naturalHeight * nextZoom;
|
|
123
|
+
const offsetX = (containerRect.width - scaledWidth) / 2;
|
|
124
|
+
const offsetY = (containerRect.height - scaledHeight) / 2;
|
|
125
|
+
|
|
126
|
+
setZoom(nextZoom);
|
|
127
|
+
setOffset({ x: offsetX, y: offsetY });
|
|
128
|
+
},
|
|
129
|
+
[containerRef],
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
// Attach wheel listener with { passive: false } so preventDefault() works
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
const el = containerRef.current;
|
|
135
|
+
if (!el) return;
|
|
136
|
+
el.addEventListener('wheel', onWheel, { passive: false });
|
|
137
|
+
return () => el.removeEventListener('wheel', onWheel);
|
|
138
|
+
}, [onWheel, containerRef]);
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
zoom,
|
|
142
|
+
offset,
|
|
143
|
+
setZoom,
|
|
144
|
+
setOffset,
|
|
145
|
+
fitToView,
|
|
146
|
+
contentRef,
|
|
147
|
+
handlers: {
|
|
148
|
+
onMouseDown,
|
|
149
|
+
onMouseMove,
|
|
150
|
+
onMouseUp: endPan,
|
|
151
|
+
onMouseLeave: endPan,
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export default usePanZoom;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
|
|
3
|
+
export function useTheme() {
|
|
4
|
+
const [theme, setTheme] = useState('light');
|
|
5
|
+
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
if (theme === 'dark') {
|
|
8
|
+
document.documentElement.classList.add('dark');
|
|
9
|
+
} else {
|
|
10
|
+
document.documentElement.classList.remove('dark');
|
|
11
|
+
}
|
|
12
|
+
}, [theme]);
|
|
13
|
+
|
|
14
|
+
const toggleTheme = () => {
|
|
15
|
+
setTheme((prev) => (prev === 'light' ? 'dark' : 'light'));
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const isDark = theme === 'dark';
|
|
19
|
+
|
|
20
|
+
return { theme, isDark, toggleTheme };
|
|
21
|
+
}
|