vplan 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/core/index.ts +140 -0
- package/dist/index.js +474 -0
- package/dist/index.js.map +1 -0
- package/package.json +52 -0
- package/runtime/Layout.tsx +17 -0
- package/runtime/components/Callout.tsx +38 -0
- package/runtime/components/Chart.tsx +112 -0
- package/runtime/components/Checklist.tsx +34 -0
- package/runtime/components/Compare.tsx +51 -0
- package/runtime/components/ExpandButton.tsx +20 -0
- package/runtime/components/FileTree.tsx +101 -0
- package/runtime/components/Mermaid.tsx +52 -0
- package/runtime/components/Phase.tsx +34 -0
- package/runtime/components/Questions.tsx +30 -0
- package/runtime/components/validate.ts +21 -0
- package/runtime/css.d.ts +1 -0
- package/runtime/fullscreen.ts +218 -0
- package/runtime/index.html +12 -0
- package/runtime/index.tsx +42 -0
- package/runtime/main.tsx +4 -0
- package/runtime/theme.css +789 -0
- package/runtime/virtual-plan.d.ts +11 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/** Toggle native fullscreen for an expandable element (code block, diagram, chart). */
|
|
2
|
+
export function toggleFullscreen(el: Element | null) {
|
|
3
|
+
if (!el) return
|
|
4
|
+
if (document.fullscreenElement === el) {
|
|
5
|
+
void document.exitFullscreen()
|
|
6
|
+
} else {
|
|
7
|
+
void (el as HTMLElement).requestFullscreen?.()
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const svg = (paths: string) =>
|
|
12
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`
|
|
13
|
+
|
|
14
|
+
const ICON = {
|
|
15
|
+
minimize: svg(
|
|
16
|
+
'<path d="M15 19v-2a2 2 0 0 1 2 -2h2"/><path d="M15 5v2a2 2 0 0 0 2 2h2"/><path d="M5 15h2a2 2 0 0 1 2 2v2"/><path d="M5 9h2a2 2 0 0 0 2 -2v-2"/>',
|
|
17
|
+
),
|
|
18
|
+
zoomIn: svg(
|
|
19
|
+
'<path d="M3 10a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"/><path d="M7 10l6 0"/><path d="M10 7l0 6"/><path d="M21 21l-6 -6"/>',
|
|
20
|
+
),
|
|
21
|
+
zoomOut: svg(
|
|
22
|
+
'<path d="M3 10a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"/><path d="M7 10l6 0"/><path d="M21 21l-6 -6"/>',
|
|
23
|
+
),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The zoomable inner element for each kind of expandable host (diagrams, charts). */
|
|
27
|
+
function contentOf(host: HTMLElement): HTMLElement | null {
|
|
28
|
+
if (host.classList.contains('vp-mermaid')) return host.querySelector('.vp-mermaid__svg')
|
|
29
|
+
if (host.classList.contains('vp-chart')) return host.querySelector('.vp-chart__canvas')
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Pan/zoom controller for a fullscreen surface. Uses an absolutely-positioned
|
|
35
|
+
* content layer with a `translate() scale()` transform (origin 0,0) so zoom-to-point
|
|
36
|
+
* math is exact. On open it fits the content to fill the viewport and centers it;
|
|
37
|
+
* it supports drag-pan, two-finger touch pinch, and trackpad pinch (ctrl + wheel).
|
|
38
|
+
*/
|
|
39
|
+
class PanZoom {
|
|
40
|
+
private scale = 1
|
|
41
|
+
private x = 0
|
|
42
|
+
private y = 0
|
|
43
|
+
private fitScale = 1
|
|
44
|
+
private readonly pointers = new Map<number, { x: number; y: number }>()
|
|
45
|
+
private pinchDist = 0
|
|
46
|
+
private readonly ac = new AbortController()
|
|
47
|
+
|
|
48
|
+
constructor(
|
|
49
|
+
private readonly host: HTMLElement,
|
|
50
|
+
private readonly content: HTMLElement,
|
|
51
|
+
private readonly toolbar: HTMLElement,
|
|
52
|
+
) {
|
|
53
|
+
content.classList.add('vp-fs-content')
|
|
54
|
+
this.bind()
|
|
55
|
+
requestAnimationFrame(() => this.fit())
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private apply() {
|
|
59
|
+
this.content.style.transform = `translate(${this.x}px, ${this.y}px) scale(${this.scale})`
|
|
60
|
+
const label = this.toolbar.querySelector('.vp-fs-zoom')
|
|
61
|
+
if (label) label.textContent = `${Math.round((this.scale / this.fitScale) * 100)}%`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
fit() {
|
|
65
|
+
this.content.style.transform = 'none'
|
|
66
|
+
const cw = this.content.offsetWidth || 1
|
|
67
|
+
const ch = this.content.offsetHeight || 1
|
|
68
|
+
const hw = this.host.clientWidth
|
|
69
|
+
const hh = this.host.clientHeight
|
|
70
|
+
const pad = 56
|
|
71
|
+
this.fitScale = Math.min((hw - pad) / cw, (hh - pad) / ch)
|
|
72
|
+
this.scale = this.fitScale
|
|
73
|
+
this.x = (hw - cw * this.scale) / 2
|
|
74
|
+
this.y = (hh - ch * this.scale) / 2
|
|
75
|
+
this.apply()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
zoomBy(factor: number) {
|
|
79
|
+
this.zoomAt(factor, this.host.clientWidth / 2, this.host.clientHeight / 2)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
reset() {
|
|
83
|
+
this.fit()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private zoomAt(factor: number, cx: number, cy: number) {
|
|
87
|
+
const next = Math.max(this.fitScale * 0.5, Math.min(this.fitScale * 12, this.scale * factor))
|
|
88
|
+
const px = (cx - this.x) / this.scale
|
|
89
|
+
const py = (cy - this.y) / this.scale
|
|
90
|
+
this.x = cx - px * next
|
|
91
|
+
this.y = cy - py * next
|
|
92
|
+
this.scale = next
|
|
93
|
+
this.apply()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private bind() {
|
|
97
|
+
const { signal } = this.ac
|
|
98
|
+
const host = this.host
|
|
99
|
+
|
|
100
|
+
host.addEventListener(
|
|
101
|
+
'wheel',
|
|
102
|
+
event => {
|
|
103
|
+
event.preventDefault()
|
|
104
|
+
const rect = host.getBoundingClientRect()
|
|
105
|
+
const cx = event.clientX - rect.left
|
|
106
|
+
const cy = event.clientY - rect.top
|
|
107
|
+
if (event.ctrlKey) this.zoomAt(Math.exp(-event.deltaY * 0.01), cx, cy)
|
|
108
|
+
else {
|
|
109
|
+
this.x -= event.deltaX
|
|
110
|
+
this.y -= event.deltaY
|
|
111
|
+
this.apply()
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
{ signal, passive: false },
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
host.addEventListener(
|
|
118
|
+
'pointerdown',
|
|
119
|
+
event => {
|
|
120
|
+
if ((event.target as HTMLElement).closest('.vp-fs-toolbar')) return
|
|
121
|
+
host.setPointerCapture(event.pointerId)
|
|
122
|
+
this.pointers.set(event.pointerId, { x: event.clientX, y: event.clientY })
|
|
123
|
+
host.style.cursor = 'grabbing'
|
|
124
|
+
},
|
|
125
|
+
{ signal },
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
host.addEventListener(
|
|
129
|
+
'pointermove',
|
|
130
|
+
event => {
|
|
131
|
+
const prev = this.pointers.get(event.pointerId)
|
|
132
|
+
if (!prev) return
|
|
133
|
+
this.pointers.set(event.pointerId, { x: event.clientX, y: event.clientY })
|
|
134
|
+
const points = [...this.pointers.values()]
|
|
135
|
+
if (points.length === 1) {
|
|
136
|
+
this.x += event.clientX - prev.x
|
|
137
|
+
this.y += event.clientY - prev.y
|
|
138
|
+
this.apply()
|
|
139
|
+
} else if (points.length === 2) {
|
|
140
|
+
const a = points[0]
|
|
141
|
+
const b = points[1]
|
|
142
|
+
if (!a || !b) return
|
|
143
|
+
const dist = Math.hypot(a.x - b.x, a.y - b.y)
|
|
144
|
+
if (this.pinchDist > 0) {
|
|
145
|
+
const rect = host.getBoundingClientRect()
|
|
146
|
+
this.zoomAt(
|
|
147
|
+
dist / this.pinchDist,
|
|
148
|
+
(a.x + b.x) / 2 - rect.left,
|
|
149
|
+
(a.y + b.y) / 2 - rect.top,
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
this.pinchDist = dist
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
{ signal },
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
const release = (event: PointerEvent) => {
|
|
159
|
+
this.pointers.delete(event.pointerId)
|
|
160
|
+
if (this.pointers.size < 2) this.pinchDist = 0
|
|
161
|
+
if (this.pointers.size === 0) host.style.cursor = 'grab'
|
|
162
|
+
}
|
|
163
|
+
host.addEventListener('pointerup', release, { signal })
|
|
164
|
+
host.addEventListener('pointercancel', release, { signal })
|
|
165
|
+
window.addEventListener('resize', () => this.fit(), { signal })
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
destroy() {
|
|
169
|
+
this.ac.abort()
|
|
170
|
+
this.content.classList.remove('vp-fs-content')
|
|
171
|
+
this.content.style.transform = ''
|
|
172
|
+
this.host.style.cursor = ''
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function buildToolbar(): HTMLElement {
|
|
177
|
+
const toolbar = document.createElement('div')
|
|
178
|
+
toolbar.className = 'vp-fs-toolbar'
|
|
179
|
+
toolbar.innerHTML =
|
|
180
|
+
`<button type="button" class="vp-fs-btn" data-act="out" aria-label="Zoom out">${ICON.zoomOut}</button>` +
|
|
181
|
+
`<button type="button" class="vp-fs-btn vp-fs-zoom" data-act="reset" aria-label="Fit to screen">100%</button>` +
|
|
182
|
+
`<button type="button" class="vp-fs-btn" data-act="in" aria-label="Zoom in">${ICON.zoomIn}</button>` +
|
|
183
|
+
`<button type="button" class="vp-fs-btn vp-fs-close" data-act="close" aria-label="Exit fullscreen">${ICON.minimize}</button>`
|
|
184
|
+
return toolbar
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let viewer: PanZoom | null = null
|
|
188
|
+
let initialized = false
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Install the in-fullscreen pan/zoom viewer + control toolbar (zoom out / level /
|
|
192
|
+
* zoom in / close). Built when any `.vp-expandable` enters fullscreen and torn down
|
|
193
|
+
* on exit, so it works for code blocks, diagrams, and charts uniformly.
|
|
194
|
+
*/
|
|
195
|
+
export function initFullscreenControls() {
|
|
196
|
+
if (initialized) return
|
|
197
|
+
initialized = true
|
|
198
|
+
document.addEventListener('fullscreenchange', () => {
|
|
199
|
+
viewer?.destroy()
|
|
200
|
+
viewer = null
|
|
201
|
+
for (const existing of document.querySelectorAll('.vp-fs-toolbar')) existing.remove()
|
|
202
|
+
const el = document.fullscreenElement as HTMLElement | null
|
|
203
|
+
if (!el?.classList.contains('vp-expandable')) return
|
|
204
|
+
const content = contentOf(el)
|
|
205
|
+
if (!content) return
|
|
206
|
+
const toolbar = buildToolbar()
|
|
207
|
+
el.appendChild(toolbar)
|
|
208
|
+
const panZoom = new PanZoom(el, content, toolbar)
|
|
209
|
+
viewer = panZoom
|
|
210
|
+
toolbar.addEventListener('click', event => {
|
|
211
|
+
const act = (event.target as HTMLElement).closest('button')?.dataset.act
|
|
212
|
+
if (act === 'close') void document.exitFullscreen()
|
|
213
|
+
else if (act === 'in') panZoom.zoomBy(1.25)
|
|
214
|
+
else if (act === 'out') panZoom.zoomBy(0.8)
|
|
215
|
+
else if (act === 'reset') panZoom.reset()
|
|
216
|
+
})
|
|
217
|
+
})
|
|
218
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Plan</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="./main.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { MDXProvider } from '@mdx-js/react'
|
|
2
|
+
import type { ComponentType } from 'react'
|
|
3
|
+
import { createRoot } from 'react-dom/client'
|
|
4
|
+
import { Callout } from './components/Callout.js'
|
|
5
|
+
import { Chart } from './components/Chart.js'
|
|
6
|
+
import { Checklist } from './components/Checklist.js'
|
|
7
|
+
import { Compare } from './components/Compare.js'
|
|
8
|
+
import { FileTree } from './components/FileTree.js'
|
|
9
|
+
import { Mermaid } from './components/Mermaid.js'
|
|
10
|
+
import { Phase } from './components/Phase.js'
|
|
11
|
+
import { Questions } from './components/Questions.js'
|
|
12
|
+
import { Layout } from './Layout.js'
|
|
13
|
+
import './theme.css'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The component scope auto-injected into every plan's MDX (no imports needed).
|
|
17
|
+
* Mermaid is here because a remark plugin rewrites ```mermaid fences to <Mermaid>;
|
|
18
|
+
* fenced code blocks are highlighted at build time by rehype-expressive-code.
|
|
19
|
+
*/
|
|
20
|
+
export const components = {
|
|
21
|
+
Phase,
|
|
22
|
+
FileTree,
|
|
23
|
+
Chart,
|
|
24
|
+
Compare,
|
|
25
|
+
Callout,
|
|
26
|
+
Questions,
|
|
27
|
+
Checklist,
|
|
28
|
+
Mermaid,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Mount a compiled MDX plan into the page shell. */
|
|
32
|
+
export function mount(Plan: ComponentType) {
|
|
33
|
+
const container = document.getElementById('root')
|
|
34
|
+
if (!container) throw new Error('VisualPlan: #root element not found')
|
|
35
|
+
createRoot(container).render(
|
|
36
|
+
<MDXProvider components={components}>
|
|
37
|
+
<Layout>
|
|
38
|
+
<Plan />
|
|
39
|
+
</Layout>
|
|
40
|
+
</MDXProvider>,
|
|
41
|
+
)
|
|
42
|
+
}
|
package/runtime/main.tsx
ADDED