@marver-design/marver 0.19.1 → 0.20.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/CHANGELOG.md +77 -0
- package/README.md +9 -11
- package/dist/{bake-kaf5kGZ7.mjs → bake-tAb0D6Rc.mjs} +1 -1
- package/dist/{build-7ed5H2vT.mjs → build-DwpNPl6Z.mjs} +3 -3
- package/dist/cli.mjs +18 -12
- package/dist/{dev-D3mP2x27.mjs → dev-BjdDP69b.mjs} +3 -3
- package/dist/{init-BQYCS3EU.mjs → init-DKxuRxBr.mjs} +33 -32
- package/dist/{plugin-DI-7NAnx.mjs → plugin-y4Ch7o_A.mjs} +7 -7
- package/dist/{poster-DNh6N27C.mjs → poster-FCv_nXyP.mjs} +1 -1
- package/dist/{publish-bakes-Dp-ZFk3d.mjs → publish-bakes-XH6Bac58.mjs} +8 -2
- package/dist/{shot-DMDvDbeP.mjs → shot-iw3SEcpn.mjs} +7 -7
- package/docs/live-jam.md +1 -1
- package/docs/slides.md +89 -89
- package/docs/sticky-notes.md +14 -4
- package/package.json +1 -1
- package/src/client/const.ts +27 -9
- package/src/client/content/chart.tsx +8 -8
- package/src/client/content/index.tsx +5 -4
- package/src/client/content/slide.tsx +26 -201
- package/src/client/frame-host/main.tsx +10 -0
- package/src/client/shell/Comments.tsx +12 -4
- package/src/client/shell/Play.tsx +26 -14
- package/src/client/shell/Toolbar.tsx +7 -5
- package/src/client/shell/canvas/FrameNode.tsx +16 -2
- package/src/client/shell/canvas/Sticky.tsx +16 -1
- package/src/client/shell/notes.ts +43 -9
- package/src/client/shell/store.ts +17 -8
- package/src/client/shell/tidy.ts +12 -5
- package/src/client/stage/main.tsx +56 -7
- package/templates/AGENTS-embedded.md +3 -2
- package/templates/AGENTS-studio.md +3 -2
- package/templates/instructions/reference/deck-layouts.md +153 -199
- package/templates/instructions/reference/deck-story.md +6 -6
- package/templates/instructions/shape.md +6 -4
- package/templates/instructions/slides.md +275 -383
|
@@ -1,39 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Slide - an OPTIONAL wrapper, nothing more. A slide is any frame with `slide: true` in its
|
|
3
|
+
* meta: it renders at its stage size (its declared viewport, else 1280×720) and slides mode
|
|
4
|
+
* scales the whole stage to the screen, so the frame never scales itself. Everything inside
|
|
5
|
+
* is the author's own code - layout, type, colour, imagery, motion.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* unthemed repo still gets a coherent deck.
|
|
7
|
+
* The wrapper fills the frame and tells the content primitives they sit on a stage (Img,
|
|
8
|
+
* Chart); decks written against the earlier Slide root import it, so it stays.
|
|
10
9
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* arms the entrance presets (`data-animate`, run once after the swap
|
|
15
|
-
* settles - the stage adds `data-sl-entered`).
|
|
10
|
+
* The playback contract lives with the player (src/client/stage/main.tsx): while a deck
|
|
11
|
+
* plays, <html> carries `data-sl-play`, plus `data-sl-entered` once each slide has arrived -
|
|
12
|
+
* authors key their own CSS and JS motion off them. useSlidePlay() is the same flag for React.
|
|
16
13
|
*/
|
|
17
|
-
import { createContext, useContext,
|
|
18
|
-
import {
|
|
14
|
+
import { createContext, useContext, useSyncExternalStore, type CSSProperties, type ReactNode } from 'react'
|
|
15
|
+
import { SLIDE_INTRINSIC } from '../const.ts'
|
|
19
16
|
|
|
20
|
-
|
|
21
|
-
export const
|
|
22
|
-
|
|
23
|
-
const PAD_X = 88
|
|
24
|
-
const PAD_Y = 44
|
|
17
|
+
/** The default stage. A deck may declare another size through its frames' viewport. */
|
|
18
|
+
export const SLIDE_W = SLIDE_INTRINSIC.width
|
|
19
|
+
export const SLIDE_H = SLIDE_INTRINSIC.height
|
|
25
20
|
|
|
26
|
-
/** Img (and anything else that cares) asks: am I inside a
|
|
21
|
+
/** Img (and anything else that cares) asks: am I inside a <Slide>? */
|
|
27
22
|
export const SlideCtx = createContext(false)
|
|
28
23
|
export const useInSlide = () => useContext(SlideCtx)
|
|
29
24
|
|
|
30
25
|
/**
|
|
31
|
-
* Is
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* documentElement attribute, so the contract is one attribute, one owner,
|
|
36
|
-
* observable by anyone.
|
|
26
|
+
* Is a deck playing? The STAGE owns the answer: it stamps `data-sl-play` on <html> (boot:
|
|
27
|
+
* the `slides` URL param). CSS reacts to the attribute natively; React components (Chart's
|
|
28
|
+
* entrance, Video's player mount) subscribe here - a MutationObserver over the
|
|
29
|
+
* documentElement attribute, so the contract is one attribute, one owner, observable by anyone.
|
|
37
30
|
*/
|
|
38
31
|
const subscribePlay = (cb: () => void) => {
|
|
39
32
|
if (typeof document === 'undefined') return () => {}
|
|
@@ -44,195 +37,27 @@ const subscribePlay = (cb: () => void) => {
|
|
|
44
37
|
const readPlay = () => typeof document !== 'undefined' && document.documentElement.hasAttribute('data-sl-play')
|
|
45
38
|
export const useSlidePlay = (): boolean => useSyncExternalStore(subscribePlay, readPlay, () => false)
|
|
46
39
|
|
|
40
|
+
/* the frame document must not pad the stage: a stage-wide root in a margined body overflows */
|
|
47
41
|
const SLIDE_CSS = `
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
also paints the slide ground, so the letterbox around a scaled stage is
|
|
51
|
-
seamless instead of a default-white halo. */
|
|
52
|
-
body:has(.sl-root) { margin: 0; background: var(--marver-slide-ground, #ffffff) }
|
|
53
|
-
.dark body:has(.sl-root), [data-theme="dark"] body:has(.sl-root) { background: var(--marver-slide-ground-dark, #101014) }
|
|
54
|
-
.sl-root, .sl-root * { box-sizing: border-box }
|
|
55
|
-
.sl-root {
|
|
56
|
-
--sl-ink: var(--marver-slide-ink, #18181b);
|
|
57
|
-
--sl-ground: var(--marver-slide-ground, #ffffff);
|
|
58
|
-
--sl-accent: var(--marver-slide-accent, #0088ff);
|
|
59
|
-
--sl-muted: var(--marver-slide-muted, rgba(24, 24, 27, .55));
|
|
60
|
-
--sl-tempo: var(--marver-slide-tempo, 350ms);
|
|
61
|
-
--sl-font: var(--marver-slide-font, ${FONT_STACK});
|
|
62
|
-
/* THE STAGE MARGINS, in px and ASYMMETRIC - the shape every well-made deck
|
|
63
|
-
uses. Generous at the sides, tighter top and bottom, so the title sits
|
|
64
|
-
high, the footnote sits low, and the middle band is the tallest thing on
|
|
65
|
-
the slide. Percentages are wrong here twice over: they would resolve
|
|
66
|
-
against this absolutely positioned box's containing block (the viewport,
|
|
67
|
-
not the stage), and one value for all four sides squeezes the middle. */
|
|
68
|
-
--sl-pad-x: var(--marver-slide-pad-x, ${PAD_X}px);
|
|
69
|
-
--sl-pad-y: var(--marver-slide-pad-y, ${PAD_Y}px);
|
|
70
|
-
--sl-margin: var(--sl-pad-x); /* the side margin, for author math */
|
|
71
|
-
/* THE FIT: authored at exactly ${SLIDE_W}x${SLIDE_H}, then scaled and
|
|
72
|
-
centered to the largest box the viewport gives it - fill window, any
|
|
73
|
-
device, a canvas node resized to a phone, any published viewer's screen.
|
|
74
|
-
One coordinate system, so the author's px, Tailwind classes, and charts
|
|
75
|
-
all scale together. Pure CSS (tan(atan2(a, b)) is the unitless ratio
|
|
76
|
-
a / b), so a lean cover - which runs no JS - reflows to the right scale
|
|
77
|
-
the moment its node is resized. 1 wherever the viewport IS the stage.
|
|
78
|
-
Declared under @supports below: an engine without CSS trig would keep
|
|
79
|
-
the invalid tokens, defeat the var() fallback, and invalidate the whole
|
|
80
|
-
transform - unscaled AND uncentered. */
|
|
81
|
-
--sl-fit: 1;
|
|
82
|
-
/* translate-center, not inset+margin:auto - an overconstrained absolute
|
|
83
|
-
box (1280px stage in a 390px viewport) resolves margins to 0 and the
|
|
84
|
-
scaled slide drifts off-center; translate(-50%,-50%) centers at ANY size */
|
|
85
|
-
position: absolute; left: 50%; top: 50%;
|
|
86
|
-
width: ${SLIDE_W}px; height: ${SLIDE_H}px; overflow: hidden;
|
|
87
|
-
transform: translate(-50%, -50%) scale(var(--sl-fit, 1)); transform-origin: center center;
|
|
88
|
-
background: var(--sl-ground); color: var(--sl-ink);
|
|
89
|
-
font-family: var(--sl-font);
|
|
90
|
-
padding: var(--sl-pad-y) var(--sl-pad-x); box-sizing: border-box;
|
|
91
|
-
display: flex; flex-direction: column; justify-content: center; gap: 28px;
|
|
92
|
-
}
|
|
93
|
-
@supports (width: calc(1px * tan(atan2(1px, 1px)))) {
|
|
94
|
-
.sl-root { --sl-fit: min(tan(atan2(100vw, ${SLIDE_W}px)), tan(atan2(100vh, ${SLIDE_H}px))) }
|
|
95
|
-
}
|
|
96
|
-
.dark .sl-root, [data-theme="dark"] .sl-root {
|
|
97
|
-
--sl-ink: var(--marver-slide-ink-dark, #f5f5f7);
|
|
98
|
-
--sl-ground: var(--marver-slide-ground-dark, #101014);
|
|
99
|
-
--sl-muted: var(--marver-slide-muted-dark, rgba(245, 245, 247, .55));
|
|
100
|
-
}
|
|
101
|
-
/* type roles - fixed values, one coordinate system with the 1280x720 stage.
|
|
102
|
-
sl-display is the ONE sanctioned oversize: the big-number stat, a section
|
|
103
|
-
numeral, the manifesto line - never running text. */
|
|
104
|
-
.sl-display { font-size: 160px; line-height: 1; font-weight: 800; letter-spacing: -.03em; margin: 0; font-variant-numeric: tabular-nums }
|
|
105
|
-
/* sl-stat is the ROW size: three or four figures side by side, where one
|
|
106
|
-
sl-display would not fit and sl-assertion would not read as a number. */
|
|
107
|
-
.sl-stat { font-size: 88px; line-height: 1.02; font-weight: 700; letter-spacing: -.03em; margin: 0; font-variant-numeric: tabular-nums }
|
|
108
|
-
.sl-assertion { font-size: 56px; line-height: 1.08; font-weight: 750; letter-spacing: -.02em; margin: 0 }
|
|
109
|
-
.sl-support { font-size: 30px; line-height: 1.25; font-weight: 500; margin: 0 }
|
|
110
|
-
.sl-body { font-size: 24px; line-height: 1.45; margin: 0 }
|
|
111
|
-
.sl-caption { font-size: 18px; line-height: 1.4; color: var(--sl-muted); margin: 0 }
|
|
112
|
-
|
|
113
|
-
/* THE MOTION RESET - at rest, a slide is still: animation NONE (not paused -
|
|
114
|
-
none is deterministic; paused can freeze mid-keyframe) and no transitions.
|
|
115
|
-
Slides mode (the stage sets data-sl-play on <html>) lifts it. This governs
|
|
116
|
-
EVERY descendant - spinners and loaders included - and that is the Slide
|
|
117
|
-
contract, documented in instructions/slides.md. */
|
|
118
|
-
:root:not([data-sl-play]) .sl-root, :root:not([data-sl-play]) .sl-root *,
|
|
119
|
-
:root:not([data-sl-play]) .sl-root *::before, :root:not([data-sl-play]) .sl-root *::after {
|
|
120
|
-
animation: none !important;
|
|
121
|
-
transition: none !important;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/* Entrance presets: inert until the stage marks the swap settled. An element
|
|
125
|
-
carrying a morph name must not carry data-animate (one transform owner) -
|
|
126
|
-
the doctrine says so; the selector below cannot check it, the review gate does. */
|
|
127
|
-
[data-sl-play] .sl-root [data-animate] { opacity: 0 }
|
|
128
|
-
/* ONE tempo per deck: the token times the entrances AND the view-transition
|
|
129
|
-
morphs between slides (the stage document is this document). */
|
|
130
|
-
::view-transition-group(*), ::view-transition-old(root), ::view-transition-new(root) {
|
|
131
|
-
animation-duration: var(--marver-slide-tempo, 350ms);
|
|
132
|
-
}
|
|
133
|
-
[data-sl-play][data-sl-entered] .sl-root [data-animate] {
|
|
134
|
-
opacity: 1; animation-duration: var(--sl-tempo); animation-timing-function: cubic-bezier(.2, .7, .2, 1);
|
|
135
|
-
animation-fill-mode: both;
|
|
136
|
-
}
|
|
137
|
-
[data-sl-play][data-sl-entered] .sl-root [data-animate="fade-up"] { animation-name: sl-fade-up }
|
|
138
|
-
[data-sl-play][data-sl-entered] .sl-root [data-animate="fade"] { animation-name: sl-fade }
|
|
139
|
-
[data-sl-play][data-sl-entered] .sl-root [data-animate="scale-in"] { animation-name: sl-scale-in }
|
|
140
|
-
[data-sl-play][data-sl-entered] .sl-root [data-animate-delay="1"] { animation-delay: 80ms }
|
|
141
|
-
[data-sl-play][data-sl-entered] .sl-root [data-animate-delay="2"] { animation-delay: 160ms }
|
|
142
|
-
[data-sl-play][data-sl-entered] .sl-root [data-animate-delay="3"] { animation-delay: 240ms }
|
|
143
|
-
@keyframes sl-fade-up { from { opacity: 0; transform: translateY(18px) } to { opacity: 1; transform: none } }
|
|
144
|
-
@keyframes sl-fade { from { opacity: 0 } to { opacity: 1 } }
|
|
145
|
-
@keyframes sl-scale-in { from { opacity: 0; transform: scale(.94) } to { opacity: 1; transform: none } }
|
|
146
|
-
@media (prefers-reduced-motion: reduce) {
|
|
147
|
-
[data-sl-play] .sl-root [data-animate] { opacity: 1; animation: none !important }
|
|
148
|
-
}
|
|
42
|
+
body:has(.sl-root) { margin: 0 }
|
|
43
|
+
.sl-root { position: relative; box-sizing: border-box; width: 100%; min-height: 100vh }
|
|
149
44
|
`
|
|
150
45
|
|
|
151
46
|
function ensureSlideStyles() {
|
|
152
47
|
// keyed by the DOCUMENT, not a module boolean - HMR reloads and multiple
|
|
153
48
|
// roots must not double- or under-inject
|
|
154
|
-
if (typeof document === 'undefined' || document.querySelector('style[data-mv-slide]')) return
|
|
49
|
+
if (typeof document === 'undefined' || document.querySelector('style[data-mv-slide-css]')) return
|
|
155
50
|
const el = document.createElement('style')
|
|
156
|
-
el.setAttribute('data-mv-slide', '')
|
|
51
|
+
el.setAttribute('data-mv-slide-css', '')
|
|
157
52
|
el.textContent = SLIDE_CSS
|
|
158
53
|
document.head.appendChild(el)
|
|
159
54
|
}
|
|
160
55
|
|
|
161
|
-
export function Slide({ children, style }: { children?: ReactNode; style?: CSSProperties }) {
|
|
56
|
+
export function Slide({ children, className, style }: { children?: ReactNode; className?: string; style?: CSSProperties }) {
|
|
162
57
|
ensureSlideStyles()
|
|
163
|
-
const ref = useRef<HTMLDivElement>(null)
|
|
164
|
-
// DEV overflow marker: a slide that outgrows its stage is a VISIBLE defect
|
|
165
|
-
// (outline + console + data-sl-over), never a silent clip decision left to
|
|
166
|
-
// chance. Development only - a published deck paints no diagnostics.
|
|
167
|
-
// Two tests, both in the stage's LAYOUT space (rects are post-transform,
|
|
168
|
-
// the fit scales the stage, so rect offsets are divided by the scale):
|
|
169
|
-
// 1. escape - a box leaving the 1280x720 stage. Measured against the
|
|
170
|
-
// STAGE, not the padded content box: full-bleed images and clipped
|
|
171
|
-
// photos break the margins on purpose.
|
|
172
|
-
// 2. collision - a flex/grid child outgrowing its parent's content box.
|
|
173
|
-
// Inside a flex column a body at flex:1 keeps its own box while ITS
|
|
174
|
-
// children spill over the neighbouring bands, all still inside the
|
|
175
|
-
// stage, which test 1 never sees.
|
|
176
|
-
useEffect(() => {
|
|
177
|
-
if (!import.meta.env.DEV) return
|
|
178
|
-
const el = ref.current
|
|
179
|
-
if (!el || typeof ResizeObserver === 'undefined') return
|
|
180
|
-
let last: boolean | null = null
|
|
181
|
-
let raf = 0
|
|
182
|
-
const check = () => {
|
|
183
|
-
raf = 0
|
|
184
|
-
const r = el.getBoundingClientRect()
|
|
185
|
-
const k = el.offsetWidth ? r.width / el.offsetWidth : 1
|
|
186
|
-
const pad = (v: string) => parseFloat(v) || 0
|
|
187
|
-
const W = el.offsetWidth, H = el.offsetHeight
|
|
188
|
-
let over = el.scrollHeight > el.clientHeight + 1 || el.scrollWidth > el.clientWidth + 1
|
|
189
|
-
let culprit: Element | null = null
|
|
190
|
-
const all = el.querySelectorAll('*')
|
|
191
|
-
const n = Math.min(all.length, 600)
|
|
192
|
-
for (let i = 0; !over && i < n; i++) {
|
|
193
|
-
const c = all[i]
|
|
194
|
-
const b = c.getBoundingClientRect()
|
|
195
|
-
if (!b.width && !b.height) continue
|
|
196
|
-
const y0 = (b.top - r.top) / k, y1 = (b.bottom - r.top) / k
|
|
197
|
-
const x0 = (b.left - r.left) / k, x1 = (b.right - r.left) / k
|
|
198
|
-
if (y1 > H + 1.5 || y0 < -1.5 || x1 > W + 1.5 || x0 < -1.5) { over = true; culprit = c; break }
|
|
199
|
-
const par = c.parentElement
|
|
200
|
-
if (!par || !par.clientHeight) continue
|
|
201
|
-
const ps = getComputedStyle(par)
|
|
202
|
-
if (!/flex|grid/.test(ps.display) || ps.overflowY !== 'visible' || ps.overflowX !== 'visible') continue
|
|
203
|
-
if (/absolute|fixed/.test(getComputedStyle(c).position)) continue
|
|
204
|
-
const pr = par.getBoundingClientRect()
|
|
205
|
-
const lo = (pr.top - b.top) / k + pad(ps.paddingTop)
|
|
206
|
-
const hi = (b.bottom - pr.bottom) / k + pad(ps.paddingBottom)
|
|
207
|
-
if (lo > 2 || hi > 2) { over = true; culprit = c; break }
|
|
208
|
-
}
|
|
209
|
-
if (over === last) return // idempotent: never write the same state twice
|
|
210
|
-
last = over
|
|
211
|
-
el.style.outline = over ? '3px solid #ff4d4f' : ''
|
|
212
|
-
el.dataset.slOver = over ? '1' : ''
|
|
213
|
-
if (over) console.warn('[marver slide] content overflows the 1280×720 stage - split the slide, never shrink the type', culprit ?? el)
|
|
214
|
-
}
|
|
215
|
-
const schedule = () => { if (!raf) raf = requestAnimationFrame(check) }
|
|
216
|
-
// observe every descendant (bounded): a flex child keeps its box while its
|
|
217
|
-
// content grows, and a root-only observer never hears about that. Nodes
|
|
218
|
-
// added later are picked up by the childList observer - childList ONLY,
|
|
219
|
-
// so our own outline/data writes (attribute mutations) cannot re-trigger.
|
|
220
|
-
const ro = new ResizeObserver(schedule)
|
|
221
|
-
const seen = new WeakSet<Element>()
|
|
222
|
-
const observeAll = () => {
|
|
223
|
-
const all = el.querySelectorAll('*')
|
|
224
|
-
for (let i = 0; i < all.length && i < 600; i++) { if (!seen.has(all[i])) { seen.add(all[i]); ro.observe(all[i]) } }
|
|
225
|
-
}
|
|
226
|
-
ro.observe(el)
|
|
227
|
-
observeAll()
|
|
228
|
-
const mo = new MutationObserver(() => { observeAll(); schedule() })
|
|
229
|
-
mo.observe(el, { childList: true, subtree: true })
|
|
230
|
-
schedule()
|
|
231
|
-
return () => { cancelAnimationFrame(raf); ro.disconnect(); mo.disconnect() }
|
|
232
|
-
}, [])
|
|
233
58
|
return (
|
|
234
59
|
<SlideCtx.Provider value={true}>
|
|
235
|
-
<div
|
|
60
|
+
<div className={className ? `sl-root ${className}` : 'sl-root'} style={style}>{children}</div>
|
|
236
61
|
</SlideCtx.Provider>
|
|
237
62
|
)
|
|
238
63
|
}
|
|
@@ -8,6 +8,7 @@ import { createRoot } from 'react-dom/client'
|
|
|
8
8
|
|
|
9
9
|
import './bridge.js'
|
|
10
10
|
import { frameFile, frames, layoutChain, layouts, providers } from './registry.ts'
|
|
11
|
+
import { SLIDE_DOC_CSS } from '../const.ts'
|
|
11
12
|
|
|
12
13
|
const params = new URLSearchParams(location.search)
|
|
13
14
|
const id = params.get('id') ?? ''
|
|
@@ -77,6 +78,15 @@ async function boot() {
|
|
|
77
78
|
// No typeof gate: memo()/forwardRef() components are objects, not functions.
|
|
78
79
|
// React + the ErrorBoundary validate the element type better than we can.
|
|
79
80
|
if (Frame == null) return fail(`${fileKey} has no default export`)
|
|
81
|
+
// a slide frame says so on its document - content primitives size for a stage by it, and
|
|
82
|
+
// the slide baseline (SLIDE_DOC_CSS) applies, the same one the stage installs
|
|
83
|
+
if (frameMod.meta?.slide === true) {
|
|
84
|
+
document.documentElement.setAttribute('data-mv-slide', '')
|
|
85
|
+
const base = document.createElement('style')
|
|
86
|
+
base.setAttribute('data-mv-slide-doc', '')
|
|
87
|
+
base.textContent = SLIDE_DOC_CSS
|
|
88
|
+
document.head.appendChild(base)
|
|
89
|
+
}
|
|
80
90
|
|
|
81
91
|
const wrappers: any[] = []
|
|
82
92
|
const providerKey = Object.keys(providers)[0]
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
8
8
|
import { createPortal } from 'react-dom'
|
|
9
9
|
import { avatarFallback, useComments } from './comments-store.ts'
|
|
10
|
-
import { useStore, type Node } from './store.ts'
|
|
10
|
+
import { CONFIG, useStore, type Node } from './store.ts'
|
|
11
11
|
import { threadHostKey } from './keys.ts'
|
|
12
12
|
import { isNoteAnchor, resolveNoteAnchor, useNotes } from './notes.ts'
|
|
13
13
|
import { canvasCtl } from './canvas/ctl.ts'
|
|
@@ -15,7 +15,7 @@ import { bootHash, buildHash, parseHash, writeHash } from './hash.ts'
|
|
|
15
15
|
import { ArrowUpIcon, CheckIcon, CheckSquareOffsetIcon, LinkIcon, ParallelogramFillIcon, PencilSimpleIcon, PlusIcon, XIcon } from './icons.tsx'
|
|
16
16
|
import { Tip } from './Tip.tsx'
|
|
17
17
|
import { mentionAlerts, mentionPeople, mentionQueryAt, parseBody, type MentionPerson } from './mentions.ts'
|
|
18
|
-
import { ROUTE } from '../const.ts'
|
|
18
|
+
import { ROUTE, slideSize, stageFit } from '../const.ts'
|
|
19
19
|
import type { AgentMeta, Thread } from '../../shared/events.ts'
|
|
20
20
|
|
|
21
21
|
/** Tint the pin / composer / thread card EDGES (border, outline, focus ring) in the anchored
|
|
@@ -132,6 +132,13 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
|
|
|
132
132
|
const selected = useStore((s) => s.selection.includes(node.key))
|
|
133
133
|
const { setActive } = useComments.getState()
|
|
134
134
|
const [rects, setRects] = useState<Record<string, { x: number; y: number; w: number; h: number } | null>>({})
|
|
135
|
+
// a slide renders at its stage and the node scales it (FrameNode): rects the frame reports are
|
|
136
|
+
// in stage px, so they map through the same fit into the node's coordinates
|
|
137
|
+
const frameEntry = useStore((s) => s.frameFor(node))
|
|
138
|
+
const stage = frameEntry ? slideSize(frameEntry, CONFIG.viewports) : null
|
|
139
|
+
const fit = stage ? stageFit(stage, { w: node.w, h: node.h }) : null
|
|
140
|
+
const inNode = <R extends { x: number; y: number; w: number; h: number }>(r: R): R =>
|
|
141
|
+
fit ? { ...r, x: fit.ox + r.x * fit.k, y: fit.oy + r.y * fit.k, w: r.w * fit.k, h: r.h * fit.k } : r
|
|
135
142
|
|
|
136
143
|
const open = threads.filter((t) => !t.resolved)
|
|
137
144
|
const anchored = open.filter((t) => (t.anchor as any)?.el)
|
|
@@ -249,7 +256,8 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
|
|
|
249
256
|
|
|
250
257
|
const pinPos = (t: Thread) => {
|
|
251
258
|
const a = t.anchor as any
|
|
252
|
-
const
|
|
259
|
+
const fr = rects[t.id]
|
|
260
|
+
const r = isNoteAnchor(a) ? noteRects[t.id] : fr && inNode(fr)
|
|
253
261
|
if (a?.el && r) return { x: r.x + (a.pos?.fx ?? 0.5) * r.w, y: r.y + (a.pos?.fy ?? 0.5) * r.h, orphan: false }
|
|
254
262
|
if (a?.el && r === null) return { x: node.w - 16, y: 16, orphan: true } // orphan parks top-right
|
|
255
263
|
const p = a?.pos // frame-level: stored fraction of the frame
|
|
@@ -310,7 +318,7 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
|
|
|
310
318
|
)}
|
|
311
319
|
{draft?.nodeKey === node.key && (() => {
|
|
312
320
|
const a = draft.anchor as any
|
|
313
|
-
const r = (draftOnNote && noteRects.draft) || a?.rect
|
|
321
|
+
const r = (draftOnNote && noteRects.draft) || (a?.rect && inNode(a.rect))
|
|
314
322
|
return <DraftComposer at={{ x: (r?.x ?? 0) + (a?.pos?.fx ?? 0.5) * (r?.w ?? 0), y: (r?.y ?? 0) + (a?.pos?.fy ?? 0.5) * (r?.h ?? 0) }} bounds={{ w: node.w, h: node.h }} hue={anchorHue(draft.anchor)} />
|
|
315
323
|
})()}
|
|
316
324
|
</>
|
|
@@ -15,7 +15,7 @@ import { useStore, BOARD_POLICY, BRANDING, CONFIG, PUBLISHED, SOURCE_REVEALED, b
|
|
|
15
15
|
import { deckOrder } from './play-order.ts'
|
|
16
16
|
import { useComments } from './comments-store.ts'
|
|
17
17
|
import { isNoteAnchor } from './notes.ts'
|
|
18
|
-
import { ROUTE } from '../const.ts'
|
|
18
|
+
import { ROUTE, slideSize } from '../const.ts'
|
|
19
19
|
import { poweredByUrl } from '../../shared/utm.ts'
|
|
20
20
|
import { canvasCtl } from './canvas/ctl.ts'
|
|
21
21
|
import { Tip } from './Tip.tsx'
|
|
@@ -70,9 +70,9 @@ export function enterSlides(over?: { at?: string; device?: string; theme?: strin
|
|
|
70
70
|
const at = over?.at && deck.includes(over.at) ? over.at : deck[0]
|
|
71
71
|
const frame = s.manifest?.frames.find((f) => f.id === at)
|
|
72
72
|
const theme = (over?.theme && CONFIG.themes.includes(over.theme) ? over.theme : undefined) ?? frame?.theme ?? s.viewTheme
|
|
73
|
-
// the deck
|
|
74
|
-
//
|
|
75
|
-
const device = over?.device
|
|
73
|
+
// a slide is a fixed stage: the deck shows it fit to the window ('slide') or edge to edge
|
|
74
|
+
// ('fill'); a viewport a restored link carries is not a slide view, so it lands on the stage
|
|
75
|
+
const device = over?.device === 'fill' ? 'fill' : 'slide'
|
|
76
76
|
s.setPlay({ at, device, theme, slides: true })
|
|
77
77
|
}
|
|
78
78
|
|
|
@@ -358,7 +358,11 @@ function PlayInner() {
|
|
|
358
358
|
|
|
359
359
|
const setDevice = (name: string) => {
|
|
360
360
|
const p = useStore.getState().play
|
|
361
|
-
if (p
|
|
361
|
+
if (!p) return
|
|
362
|
+
// slides: the stage fit to the window or edge to edge - a viewport preset has no meaning
|
|
363
|
+
// for a fixed stage, so it lands on the slide view
|
|
364
|
+
if (p.slides) { useStore.getState().setPlay({ ...p, device: name === 'fill' ? 'fill' : 'slide' }); return }
|
|
365
|
+
if (CONFIG.viewports[name] || name === 'fill') useStore.getState().setPlay({ ...p, device: name })
|
|
362
366
|
}
|
|
363
367
|
const setTheme = (t: string) => {
|
|
364
368
|
const p = useStore.getState().play
|
|
@@ -556,26 +560,34 @@ function PlayInner() {
|
|
|
556
560
|
|
|
557
561
|
// doc preset: reading width, natural document scrolling - the iframe takes the
|
|
558
562
|
// window's height at min(width, 860) CSS pixels, unscaled, and scrolls itself
|
|
559
|
-
const
|
|
560
|
-
|
|
563
|
+
const frameEntry = useStore.getState().manifest?.frames.find((f) => f.id === play.at)
|
|
564
|
+
// a slide - in slides mode, or a slide frame met in present or focus - is a fixed stage:
|
|
565
|
+
// the iframe is always its own stage (its declared viewport, else 1280×720), scaled to the
|
|
566
|
+
// window UP as well as down, so a projector shows it at full size and the frame never scales
|
|
567
|
+
// itself. 'fill' fits it edge to edge; otherwise it leaves room for the chrome. A slide never
|
|
568
|
+
// reflows - a device preset has nothing to change on a fixed stage.
|
|
569
|
+
const stage = (frameEntry && slideSize(frameEntry, CONFIG.viewports)) ?? (slides ? SLIDE_INTRINSIC : null)
|
|
570
|
+
const vp = stage ? { width: stage.width, height: stage.height }
|
|
571
|
+
: docPreset ? { width: Math.min(win.w, 860), height: win.h }
|
|
561
572
|
: fill ? { width: win.w, height: win.h }
|
|
562
|
-
:
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
573
|
+
: CONFIG.viewports[play.device] ?? Object.values(CONFIG.viewports)[0]
|
|
574
|
+
const scale = stage ? Math.max(0.05, fill ? Math.min(win.w / vp.width, win.h / vp.height)
|
|
575
|
+
: slides ? Math.min((win.w - 48) / vp.width, (win.h - (deckChrome === 'none' ? 48 : 88)) / vp.height)
|
|
576
|
+
: Math.min((win.w - 96) / vp.width, (win.h - 128) / vp.height))
|
|
577
|
+
: docPreset || fill ? 1
|
|
566
578
|
: Math.min(1, (win.w - 96) / vp.width, (win.h - 128) / vp.height)
|
|
567
579
|
const names = Object.keys(CONFIG.viewports)
|
|
568
580
|
const list = focus ? [play.at] : slides ? currentDeck() : playList()
|
|
569
581
|
const pos = list.indexOf(play.at)
|
|
570
582
|
const variants = variantList()
|
|
571
|
-
const frameEntry = useStore.getState().manifest?.frames.find((f) => f.id === play.at)
|
|
572
583
|
const title = focus ? (frameEntry?.title ?? (play.at.split('/').pop() ?? play.at).replace(/-/g, ' ')) : boardLabel(board)
|
|
573
584
|
|
|
574
585
|
// whole-pixel wrapper + per-axis scale so the iframe lands exactly on its edges -
|
|
575
586
|
// fractional sizes left subpixel seams glowing at the corners on dark frames
|
|
576
587
|
const dw = Math.round(vp.width * scale)
|
|
577
588
|
const dh = Math.round(vp.height * scale)
|
|
578
|
-
const deviceHint =
|
|
589
|
+
const deviceHint = stage ? `${vp.width} × ${vp.height} stage · ${fill ? 'edge to edge' : 'fit to the window'}`
|
|
590
|
+
: fill ? 'Fill window' : `${vp.width} × ${vp.height} · keys 1-${names.length + 1}`
|
|
579
591
|
|
|
580
592
|
return (
|
|
581
593
|
<div className={`sh-play${fill || docPreset ? ' fill' : ''}${docPreset ? ` doc t-${play.theme}` : ''}`}>
|
|
@@ -650,7 +662,7 @@ function PlayInner() {
|
|
|
650
662
|
{!trimmed && <>
|
|
651
663
|
<LaserButton />
|
|
652
664
|
<i className="sep" />
|
|
653
|
-
{!docPreset && <DevicePicker value={fill ? 'fill' : play.device} onSelect={(n) => n && setDevice(n)} includeFill includeSlide={slides} hint={deviceHint} dark />}
|
|
665
|
+
{!docPreset && <DevicePicker value={fill ? 'fill' : play.device} onSelect={(n) => n && setDevice(n)} includeFill includeSlide={slides} stageOnly={slides} hint={deviceHint} dark />}
|
|
654
666
|
<ThemePicker value={play.theme} onSelect={setTheme} hint="D" dark />
|
|
655
667
|
</>}
|
|
656
668
|
{playUpdateRevision && <>
|
|
@@ -153,18 +153,20 @@ export function HideUIButton() {
|
|
|
153
153
|
/** Controlled device dropdown. `value` is the active device name ('fill' allowed when
|
|
154
154
|
* includeFill), null = default/mixed. The trigger + menu chrome is shared; the adapter
|
|
155
155
|
* owns what select does. */
|
|
156
|
-
export function DevicePicker({ value, onSelect, includeDefault, includeFill, includeSlide, hint, dark }: {
|
|
156
|
+
export function DevicePicker({ value, onSelect, includeDefault, includeFill, includeSlide, stageOnly, hint, dark }: {
|
|
157
157
|
value: string | null
|
|
158
158
|
onSelect: (name: string | null) => void
|
|
159
159
|
includeDefault?: boolean
|
|
160
160
|
includeFill?: boolean
|
|
161
|
-
/** slides mode: the
|
|
161
|
+
/** slides mode: the slide's own stage, fit to the window, as a first-class device */
|
|
162
162
|
includeSlide?: boolean
|
|
163
|
+
/** slides mode: a slide is a fixed stage - only the stage views (slide, fill), no viewports */
|
|
164
|
+
stageOnly?: boolean
|
|
163
165
|
hint?: ReactNode
|
|
164
166
|
dark?: boolean
|
|
165
167
|
}) {
|
|
166
168
|
const pop = usePopover()
|
|
167
|
-
const entries = Object.entries(CONFIG.viewports)
|
|
169
|
+
const entries = stageOnly ? [] : Object.entries(CONFIG.viewports)
|
|
168
170
|
const pick = (name: string | null) => { onSelect(name); pop.setOpen(false) }
|
|
169
171
|
const triggerIcon = value === 'fill' ? <FrameCornersIcon size={16} /> : value === 'slide' ? <SlideFrameIcon size={16} /> : deviceIcon(value, 16)
|
|
170
172
|
return (
|
|
@@ -177,7 +179,7 @@ export function DevicePicker({ value, onSelect, includeDefault, includeFill, inc
|
|
|
177
179
|
</Tip>
|
|
178
180
|
<Popover pop={pop} dark={dark}>
|
|
179
181
|
{includeSlide && <>
|
|
180
|
-
<button onClick={() => pick('slide')} title="
|
|
182
|
+
<button onClick={() => pick('slide')} title="the slide's own size, fit to the window">
|
|
181
183
|
<SlideFrameIcon size={15} /><span>Slide</span>
|
|
182
184
|
{value === 'slide' && <CheckIcon size={13} className="chk" />}
|
|
183
185
|
</button>
|
|
@@ -197,7 +199,7 @@ export function DevicePicker({ value, onSelect, includeDefault, includeFill, inc
|
|
|
197
199
|
</button>
|
|
198
200
|
))}
|
|
199
201
|
{includeFill && <button onClick={() => pick('fill')}>
|
|
200
|
-
<FrameCornersIcon size={15} /><span>Fill window</span><kbd>{
|
|
202
|
+
<FrameCornersIcon size={15} /><span>Fill window</span><kbd>{Object.keys(CONFIG.viewports).length + 1}</kbd>
|
|
201
203
|
{value === 'fill' && <CheckIcon size={13} className="chk" />}
|
|
202
204
|
</button>}
|
|
203
205
|
</Popover>
|
|
@@ -11,10 +11,21 @@ import { sleep, wake } from './sleep.ts'
|
|
|
11
11
|
import { canAutoReload, shouldArmReadyWatch } from './ready-watch.ts'
|
|
12
12
|
import { Stickies, type NoteSpec } from './Sticky.tsx'
|
|
13
13
|
import { noteId, sceneNoteHost } from '../notes.ts'
|
|
14
|
+
import { slideSize, stageFit } from '../../const.ts'
|
|
14
15
|
|
|
15
16
|
export const HEADER = 28
|
|
16
17
|
const SNAP = 12
|
|
17
18
|
|
|
19
|
+
/** The iframe's box in its node: a frame fills the node; a slide renders at its stage and is
|
|
20
|
+
* scaled uniformly into the node and centred - resizing a slide node never reflows it. The
|
|
21
|
+
* identity case carries no transform (no layer, no containing block). */
|
|
22
|
+
function iframeBox(stage: { width: number; height: number } | null, node: { w: number; h: number }) {
|
|
23
|
+
if (!stage) return { width: node.w, height: node.h }
|
|
24
|
+
const { k, ox, oy } = stageFit(stage, node)
|
|
25
|
+
const identity = Math.abs(k - 1) < 1e-6 && Math.abs(ox) < 0.5 && Math.abs(oy) < 0.5
|
|
26
|
+
return { width: stage.width, height: stage.height, ...(identity ? {} : { transform: `translate(${ox}px, ${oy}px) scale(${k})`, transformOrigin: '0 0' }) }
|
|
27
|
+
}
|
|
28
|
+
|
|
18
29
|
/** Live Jam working shimmer: a slim 2x6 strip of tiny marver marks on the frame's left
|
|
19
30
|
* flank, top-aligned - each mark twinkles on its own scattered beat, phased per frame by
|
|
20
31
|
* --mv-w0 (set on the node) so parallel frames never pulse in sync. */
|
|
@@ -172,7 +183,10 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
172
183
|
const resizing = useRef(false)
|
|
173
184
|
const [resizeTick, setResizeTick] = useState(0)
|
|
174
185
|
useEffect(() => { dirty.current = false }, [node.nav]) // a fresh document is pristine again
|
|
175
|
-
|
|
186
|
+
// a slide's document is its stage, whatever the node's size (the node only scales it): the
|
|
187
|
+
// sleep key - the size the compiler renders to certify textures - is the document's size
|
|
188
|
+
const stage = frame ? slideSize(frame, CONFIG.viewports) : null
|
|
189
|
+
const w = Math.round(stage?.width ?? node.w), h = Math.round(stage?.height ?? node.h)
|
|
176
190
|
// a layout effect: the wake lands BEFORE the first paint of the new state (a stretched texture
|
|
177
191
|
// must never be painted at a new size)
|
|
178
192
|
useLayoutEffect(() => {
|
|
@@ -418,7 +432,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
418
432
|
src={src}
|
|
419
433
|
title={frame.id}
|
|
420
434
|
onLoad={registerWin}
|
|
421
|
-
style={{
|
|
435
|
+
style={{ ...iframeBox(stage, node), display: node.missing || node.status === 'error' ? 'none' : 'block' }}
|
|
422
436
|
/>
|
|
423
437
|
{/* the overlay eats mouse events for drag-by-body; laser and comment mode both
|
|
424
438
|
need the mouse INSIDE the frame for hover highlights, so it steps aside
|
|
@@ -19,6 +19,7 @@ import { cleanSource, guardDiagramSource, sanitizeSvg } from '../../content/diag
|
|
|
19
19
|
import { useComments } from '../comments-store.ts'
|
|
20
20
|
import { goTo } from '../goto.ts'
|
|
21
21
|
import { NOTE_W, SCENE_NOTE_W, noteAnchor, noteVisible, useNotes, type NoteKind } from '../notes.ts'
|
|
22
|
+
import { useStore } from '../store.ts'
|
|
22
23
|
|
|
23
24
|
export interface NoteSpec { kind: NoteKind; id: string; text: string }
|
|
24
25
|
|
|
@@ -266,10 +267,24 @@ function StickyBody({ text, kind, nodeKey, frameId }: { text: string; kind: Note
|
|
|
266
267
|
export const Stickies = memo(function Stickies({ nodeKey, frameId, notes, underBadge }: { nodeKey: string; frameId: string; notes: NoteSpec[]; underBadge: boolean }) {
|
|
267
268
|
const ids = notes.map((n) => n.id)
|
|
268
269
|
const on = useNotes((s) => noteVisible(s, ids))
|
|
270
|
+
const col = useRef<HTMLDivElement>(null)
|
|
271
|
+
// the column's extent is measured, never computed: markdown, fonts and diagrams decide it. The
|
|
272
|
+
// layout gets it (and its changes) so a note longer than its frame has room below; a folded
|
|
273
|
+
// column keeps its height (the fold is a transform), so folding never reflows. Unmounting
|
|
274
|
+
// (notes gone, board switched) clears it.
|
|
275
|
+
useEffect(() => {
|
|
276
|
+
const el = col.current
|
|
277
|
+
if (!el) return
|
|
278
|
+
const report = () => useStore.getState().noteMeasured(nodeKey, el.offsetTop + el.offsetHeight)
|
|
279
|
+
const ro = new ResizeObserver(report)
|
|
280
|
+
ro.observe(el)
|
|
281
|
+
report()
|
|
282
|
+
return () => { ro.disconnect(); useStore.getState().noteMeasured(nodeKey, 0) }
|
|
283
|
+
}, [nodeKey, underBadge, notes.length])
|
|
269
284
|
if (!notes.length) return null
|
|
270
285
|
const width = Math.max(...notes.map((n) => (n.kind === 'scene' ? SCENE_NOTE_W : NOTE_W)))
|
|
271
286
|
return (
|
|
272
|
-
<div className={`sh-notes${on ? '' : ' off'}${underBadge ? ' below-vbadge' : ''}`} data-node-notes={nodeKey} style={{ width }}>
|
|
287
|
+
<div ref={col} className={`sh-notes${on ? '' : ' off'}${underBadge ? ' below-vbadge' : ''}`} data-node-notes={nodeKey} style={{ width }}>
|
|
273
288
|
<button className="sh-notes-fold sh-no-pan" type="button" aria-label={on ? 'hide notes' : 'show notes'} title={on ? 'hide notes (N: all)' : 'show notes (N: all)'}
|
|
274
289
|
onPointerDown={(e) => e.stopPropagation()}
|
|
275
290
|
onClick={(e) => { e.stopPropagation(); useNotes.getState().toggle(ids) }} />
|
|
@@ -44,14 +44,36 @@ export function sceneNoteHost(
|
|
|
44
44
|
* `h + NODE_HEADER` tall, and that is the height a note beside it has to clear. */
|
|
45
45
|
export const NODE_HEADER = 28
|
|
46
46
|
|
|
47
|
-
/**
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
|
|
47
|
+
/** The measured extent of a node's note column: world px from the node's top to the bottom of
|
|
48
|
+
* its last sticky (0 = no column, or not rendered yet). A note's height is its content's, known
|
|
49
|
+
* only once it is drawn - so, like a content frame's height, it is measured, transient (never
|
|
50
|
+
* serialized, a reload remeasures), and fed back to the layout when it lands. */
|
|
51
|
+
const noteHeights = new Map<string, number>()
|
|
52
|
+
export const noteHeight = (key: string): number => noteHeights.get(key) ?? 0
|
|
53
|
+
/** Record a column's extent; true when it changed (the caller asks the layout for room then). */
|
|
54
|
+
export function setNoteHeight(key: string, h: number): boolean {
|
|
55
|
+
const H = Number.isFinite(h) && h > 0 ? Math.round(h) : 0
|
|
56
|
+
if ((noteHeights.get(key) ?? 0) === H) return false
|
|
57
|
+
if (H) noteHeights.set(key, H)
|
|
58
|
+
else noteHeights.delete(key)
|
|
59
|
+
return true
|
|
60
|
+
}
|
|
61
|
+
/** A board load starts from no heights: node keys are per board file, and a key another board
|
|
62
|
+
* also uses must not bring its column's height along (the old board unmounts after the load). */
|
|
63
|
+
export const clearNoteHeights = (): void => { noteHeights.clear() }
|
|
64
|
+
|
|
65
|
+
/** True when a note has no room: another node stands inside the space in front of a noted node
|
|
66
|
+
* (its own note, or the scene's note it hosts) - beside its card, or below it where a note taller
|
|
67
|
+
* than its frame runs on. Room is the layout's job, never the author's - a board the shell
|
|
68
|
+
* composes re-applies its layout when this is true, so a note file can land on a saved board and
|
|
69
|
+
* the frames make way. An obstacle is another node's card, or its own note column (two columns
|
|
70
|
+
* running into each other cramp too) - the two rectangles, never their bounding box: the empty
|
|
71
|
+
* canvas under a card beside a long column is free. Missing nodes (a deleted frame's card, still
|
|
72
|
+
* drawn full size) block room but never host a note. `noteH` is the measured column extent. */
|
|
52
73
|
export function notesCramped(
|
|
53
74
|
nodes: readonly { key: string; frame: string; x: number; y: number; w: number; h: number; missing?: boolean }[],
|
|
54
75
|
manifest: { frames: { id: string; scene: string; note?: string }[]; scenes: { name: string; note?: string }[] } | null,
|
|
76
|
+
noteH: (key: string) => number = noteHeight,
|
|
55
77
|
): boolean {
|
|
56
78
|
if (!manifest) return false
|
|
57
79
|
const entry = (id: string) => manifest.frames.find((f) => f.id === id)
|
|
@@ -62,11 +84,23 @@ export function notesCramped(
|
|
|
62
84
|
const h = sceneNoteHost(live, (id) => entry(id)?.scene, s.name)
|
|
63
85
|
if (h) hosts.add(h)
|
|
64
86
|
}
|
|
87
|
+
type N = (typeof nodes)[number]
|
|
88
|
+
type R = { x0: number; x1: number; y0: number; y1: number }
|
|
89
|
+
const hit = (a: R, b: R) => a.x0 < b.x1 && a.x1 > b.x0 && a.y0 < b.y1 && a.y1 > b.y0
|
|
90
|
+
const card = (n: N): R => ({ x0: n.x, x1: n.x + n.w, y0: n.y, y1: n.y + n.h + NODE_HEADER })
|
|
91
|
+
// the room a note takes: the reserve beside the card, as tall as the card or the column, whichever runs further
|
|
92
|
+
const room = (n: N): R | null => {
|
|
93
|
+
const r = n.missing ? 0 : noteReserve(!!entry(n.frame)?.note, hosts.has(n.key))
|
|
94
|
+
return r ? { x0: n.x - r, x1: n.x, y0: n.y, y1: n.y + Math.max(n.h + NODE_HEADER, noteH(n.key)) } : null
|
|
95
|
+
}
|
|
65
96
|
return live.some((n) => {
|
|
66
|
-
const
|
|
67
|
-
if (!
|
|
68
|
-
|
|
69
|
-
|
|
97
|
+
const mine = room(n)
|
|
98
|
+
if (!mine) return false
|
|
99
|
+
return nodes.some((o) => {
|
|
100
|
+
if (o === n) return false
|
|
101
|
+
const theirs = room(o)
|
|
102
|
+
return hit(mine, card(o)) || (!!theirs && hit(mine, theirs))
|
|
103
|
+
})
|
|
70
104
|
})
|
|
71
105
|
}
|
|
72
106
|
|