egaki 0.7.2 → 0.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/CHANGELOG.md +52 -0
- package/dist/cli/bpm-detect.d.ts +7 -0
- package/dist/cli/bpm-detect.d.ts.map +1 -0
- package/dist/cli/bpm-detect.js +123 -0
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +115 -0
- package/dist/cli/loudness-detect.d.ts +13 -0
- package/dist/cli/loudness-detect.d.ts.map +1 -0
- package/dist/cli/loudness-detect.js +96 -0
- package/dist/vite/angled-screen-shader.d.ts +45 -0
- package/dist/vite/angled-screen-shader.d.ts.map +1 -0
- package/dist/vite/angled-screen-shader.js +573 -0
- package/dist/vite/angled-screen.d.ts +12 -3
- package/dist/vite/angled-screen.d.ts.map +1 -1
- package/dist/vite/angled-screen.js +12 -3
- package/dist/vite/code-block.d.ts.map +1 -1
- package/dist/vite/code-block.js +26 -5
- package/dist/vite/layout-editor.js +1 -1
- package/dist/vite/mdx-video.d.ts +4 -2
- package/dist/vite/mdx-video.d.ts.map +1 -1
- package/dist/vite/mdx-video.js +4 -2
- package/dist/vite/mdx-video.test.js +1 -79
- package/dist/vite/player-page.d.ts.map +1 -1
- package/dist/vite/player-page.js +6 -94
- package/dist/vite/render-client.d.ts +27 -0
- package/dist/vite/render-client.d.ts.map +1 -1
- package/dist/vite/render-client.js +47 -51
- package/dist/vite/sdk.d.ts.map +1 -1
- package/dist/vite/sdk.js +4 -3
- package/dist/vite/shader-renderer.d.ts +4 -0
- package/dist/vite/shader-renderer.d.ts.map +1 -1
- package/dist/vite/shader-renderer.js +3 -3
- package/dist/vite/store-hooks.d.ts +2 -0
- package/dist/vite/store-hooks.d.ts.map +1 -1
- package/dist/vite/store-hooks.js +8 -0
- package/dist/vite/store.d.ts +5 -0
- package/dist/vite/store.d.ts.map +1 -1
- package/dist/vite/store.js +5 -0
- package/dist/vite/tweakpane-hook.d.ts +12 -3
- package/dist/vite/tweakpane-hook.d.ts.map +1 -1
- package/dist/vite/tweakpane-hook.js +128 -20
- package/dist/vite/vite-plugin.d.ts +0 -6
- package/dist/vite/vite-plugin.d.ts.map +1 -1
- package/dist/vite/vite-plugin.js +1 -92
- package/package.json +9 -6
- package/skills/egaki/SKILL.md +26 -224
- package/src/cli/bpm-detect.ts +165 -0
- package/src/cli/cli.ts +149 -0
- package/src/cli/loudness-detect.ts +129 -0
- package/src/vite/angled-screen-shader.tsx +706 -0
- package/src/vite/angled-screen.tsx +13 -4
- package/src/vite/code-block.tsx +28 -6
- package/src/vite/layout-editor.tsx +1 -1
- package/src/vite/mdx-video.test.tsx +1 -94
- package/src/vite/mdx-video.tsx +4 -1
- package/src/vite/player-page.tsx +28 -117
- package/src/vite/render-client.ts +52 -51
- package/src/vite/sdk.ts +4 -3
- package/src/vite/shader-renderer.tsx +3 -3
- package/src/vite/store-hooks.ts +11 -0
- package/src/vite/store.ts +10 -0
- package/src/vite/tweakpane-hook.tsx +173 -28
- package/src/vite/vite-plugin.ts +1 -91
package/src/vite/store-hooks.ts
CHANGED
|
@@ -85,6 +85,12 @@ const getGenerationErrors = () => egakiStore.getState().serverGenerationErrors
|
|
|
85
85
|
const EMPTY_ERRORS: GenerationError[] = []
|
|
86
86
|
const getGenerationErrorsServer = () => EMPTY_ERRORS
|
|
87
87
|
|
|
88
|
+
// Sidebar open state
|
|
89
|
+
const subscribeSidebarOpen = (callback: () => void) =>
|
|
90
|
+
egakiStore.subscribe((state) => state.sidebarOpen, callback)
|
|
91
|
+
const getSidebarOpen = () => egakiStore.getState().sidebarOpen
|
|
92
|
+
const getSidebarOpenServer = () => false
|
|
93
|
+
|
|
88
94
|
// Modules
|
|
89
95
|
const subscribeModules = (callback: () => void) =>
|
|
90
96
|
egakiStore.subscribe((state) => state.modules, callback)
|
|
@@ -109,6 +115,11 @@ export function useGenerationErrors(): GenerationError[] {
|
|
|
109
115
|
return useSyncExternalStore(subscribeGenerationErrors, getGenerationErrors, getGenerationErrorsServer)
|
|
110
116
|
}
|
|
111
117
|
|
|
118
|
+
/** Hook: whether the right tweakpane sidebar is open. */
|
|
119
|
+
export function useSidebarOpen(): boolean {
|
|
120
|
+
return useSyncExternalStore(subscribeSidebarOpen, getSidebarOpen, getSidebarOpenServer)
|
|
121
|
+
}
|
|
122
|
+
|
|
112
123
|
/** Hook: user modules from virtual:egaki-modules. */
|
|
113
124
|
export function useModules(): EagerModules {
|
|
114
125
|
return useSyncExternalStore(subscribeModules, getModules, getModules)
|
package/src/vite/store.ts
CHANGED
|
@@ -70,6 +70,10 @@ export interface EgakiClientState {
|
|
|
70
70
|
/** Recent generation errors from the server progress stream.
|
|
71
71
|
* Errors auto-clear after ERROR_DISPLAY_DURATION_MS. */
|
|
72
72
|
serverGenerationErrors: GenerationError[]
|
|
73
|
+
|
|
74
|
+
/** Whether the right tweakpane sidebar is open. Closed by default so the
|
|
75
|
+
* video takes the full width. */
|
|
76
|
+
sidebarOpen: boolean
|
|
73
77
|
}
|
|
74
78
|
|
|
75
79
|
// ---------------------------------------------------------------------------
|
|
@@ -83,9 +87,15 @@ export const egakiStore = createStore<EgakiClientState>()(
|
|
|
83
87
|
serverGenerationStatus: null as GenerationStatus | null,
|
|
84
88
|
serverGenerationEntries: [] as GenerationProgressEntry[],
|
|
85
89
|
serverGenerationErrors: [] as GenerationError[],
|
|
90
|
+
sidebarOpen: false as boolean,
|
|
86
91
|
})),
|
|
87
92
|
)
|
|
88
93
|
|
|
94
|
+
/** Open or close the right tweakpane sidebar. */
|
|
95
|
+
export function setSidebarOpen(open: boolean) {
|
|
96
|
+
egakiStore.setState({ sidebarOpen: open })
|
|
97
|
+
}
|
|
98
|
+
|
|
89
99
|
// ---------------------------------------------------------------------------
|
|
90
100
|
// Selectors — derive values from state
|
|
91
101
|
// ---------------------------------------------------------------------------
|
|
@@ -18,11 +18,21 @@
|
|
|
18
18
|
* Tweakpane is pure DOM (no React dependency), so there are no dual-React
|
|
19
19
|
* or SSR issues. During SSR (typeof window === 'undefined') the hook
|
|
20
20
|
* returns the raw default values and skips all DOM work.
|
|
21
|
+
*
|
|
22
|
+
* Live props vs user overrides: each param follows the LIVE schema value
|
|
23
|
+
* passed on every render (so animated props like translateX={interpolate(...)}
|
|
24
|
+
* work) until the user touches that control in the pane. Once touched, the
|
|
25
|
+
* user's value wins for that key until the component remounts. Untouched
|
|
26
|
+
* controls are refreshed in the pane every render so animated values are
|
|
27
|
+
* visible while playing. Programmatic refreshes are guarded so tweakpane's
|
|
28
|
+
* change events (which fire on refresh() too) don't mark keys as touched.
|
|
21
29
|
*/
|
|
22
30
|
|
|
23
31
|
import { useCallback, useEffect, useRef, useState, useSyncExternalStore, createContext, useContext } from 'react'
|
|
24
32
|
import type { PlayerRef } from '@remotion/player'
|
|
25
33
|
import { LayoutContainerContext, useIsPremounting } from './mdx-video.tsx'
|
|
34
|
+
import { setSidebarOpen } from './store.ts'
|
|
35
|
+
import { useSidebarOpen } from './store-hooks.ts'
|
|
26
36
|
|
|
27
37
|
// ---------------------------------------------------------------------------
|
|
28
38
|
// Types
|
|
@@ -127,6 +137,10 @@ interface FolderEntry {
|
|
|
127
137
|
params: Record<string, unknown>
|
|
128
138
|
/** The original default values for diffing. */
|
|
129
139
|
defaults: Record<string, unknown>
|
|
140
|
+
/** Keys the user changed via the pane UI. Only these count as "changed"
|
|
141
|
+
* in the copy prompt — untouched keys track live (possibly animated)
|
|
142
|
+
* props, so diffing them against mount-time defaults would be noise. */
|
|
143
|
+
touched: Set<string>
|
|
130
144
|
}
|
|
131
145
|
const activeFolders = new Map<string, FolderEntry>()
|
|
132
146
|
|
|
@@ -144,6 +158,22 @@ function cloneValue<T>(value: T): T {
|
|
|
144
158
|
return value
|
|
145
159
|
}
|
|
146
160
|
|
|
161
|
+
/** Deep-equal for param values: primitives, small arrays (bezier tuples),
|
|
162
|
+
* and flat objects (range {min, max}). */
|
|
163
|
+
function valuesEqual(a: unknown, b: unknown): boolean {
|
|
164
|
+
if (a === b) return true
|
|
165
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
166
|
+
return a.length === b.length && a.every((x, i) => x === b[i])
|
|
167
|
+
}
|
|
168
|
+
if (typeof a === 'object' && a !== null && typeof b === 'object' && b !== null) {
|
|
169
|
+
const ra = a as Record<string, unknown>
|
|
170
|
+
const rb = b as Record<string, unknown>
|
|
171
|
+
const ka = Object.keys(ra)
|
|
172
|
+
return ka.length === Object.keys(rb).length && ka.every((k) => ra[k] === rb[k])
|
|
173
|
+
}
|
|
174
|
+
return false
|
|
175
|
+
}
|
|
176
|
+
|
|
147
177
|
function resolveDefault(v: ParamValue): number | boolean | string | [number, number, number, number] | { min: number; max: number } {
|
|
148
178
|
if (isCubicBezierParam(v)) return [...v.value]
|
|
149
179
|
if (isRangeParam(v)) return { ...v.value }
|
|
@@ -277,7 +307,10 @@ function serializeTweakpanePrompt(
|
|
|
277
307
|
const changed: string[] = []
|
|
278
308
|
for (const [key, current] of Object.entries(entry.params)) {
|
|
279
309
|
const def = entry.defaults[key]
|
|
280
|
-
|
|
310
|
+
// Only user-touched keys count as changed. Untouched keys mirror live
|
|
311
|
+
// (possibly animated) props, so comparing them to mount-time defaults
|
|
312
|
+
// would produce false positives on every playing frame.
|
|
313
|
+
if (entry.touched.has(key) && !valuesEqual(current, def)) {
|
|
281
314
|
changed.push(` ${key}: ${formatValue(current)}`)
|
|
282
315
|
}
|
|
283
316
|
}
|
|
@@ -378,6 +411,12 @@ function CopyChangesButton() {
|
|
|
378
411
|
* Returns a reactive object with current values. Updating a control in the
|
|
379
412
|
* pane immediately triggers a React re-render with fresh values.
|
|
380
413
|
*
|
|
414
|
+
* Values follow live props: each key returns the schema value from the
|
|
415
|
+
* CURRENT render until the user touches that control in the pane, so
|
|
416
|
+
* animated props (e.g. `translateX={interpolate(frame, ...)}`) flow through
|
|
417
|
+
* every frame. Once touched, the user's pane value wins for that key until
|
|
418
|
+
* the component remounts.
|
|
419
|
+
*
|
|
381
420
|
* On unmount the folder is disposed and removed from the pane.
|
|
382
421
|
*/
|
|
383
422
|
export function useTweakpane<T extends ParamSchema>(
|
|
@@ -406,6 +445,22 @@ export function useTweakpane<T extends ParamSchema>(
|
|
|
406
445
|
const schemaRef = useRef(schema)
|
|
407
446
|
schemaRef.current = schema
|
|
408
447
|
|
|
448
|
+
// Keys the user changed via the pane UI. Untouched keys follow the live
|
|
449
|
+
// schema value on every render (so animated props work); touched keys keep
|
|
450
|
+
// the user's pane value until remount.
|
|
451
|
+
const touchedRef = useRef<Set<string>>(new Set())
|
|
452
|
+
// Latest user override values, mirrored from state so the mount effect can
|
|
453
|
+
// seed rebuilt folders without re-running on every pane change.
|
|
454
|
+
const valuesRef = useRef(values)
|
|
455
|
+
valuesRef.current = values
|
|
456
|
+
// Live handles to the mounted folder + bound params, used by the per-render
|
|
457
|
+
// sync effect below to push animated prop values into the pane.
|
|
458
|
+
const liveRef = useRef<{ folder: import('tweakpane').FolderApi; params: Record<string, unknown> } | null>(null)
|
|
459
|
+
// True while we call folder.refresh() programmatically. tweakpane emits
|
|
460
|
+
// change events on refresh() too, so handlers must ignore those or every
|
|
461
|
+
// animated frame would mark keys as user-touched.
|
|
462
|
+
const refreshingRef = useRef(false)
|
|
463
|
+
|
|
409
464
|
// Stable ID for this hook instance
|
|
410
465
|
const idRef = useRef(`${label}-${Math.random().toString(36).slice(2, 8)}`)
|
|
411
466
|
|
|
@@ -423,7 +478,10 @@ export function useTweakpane<T extends ParamSchema>(
|
|
|
423
478
|
const def = resolveDefault(param)
|
|
424
479
|
// Clone object/array values so tweakpane's in-place mutations on params
|
|
425
480
|
// don't corrupt the defaults (used by "Copy changes" diffing).
|
|
426
|
-
|
|
481
|
+
// Touched keys keep the user's override when the folder is rebuilt
|
|
482
|
+
// (e.g. premount → active transition re-runs this effect).
|
|
483
|
+
const touched = touchedRef.current.has(key)
|
|
484
|
+
params[key] = cloneValue(touched ? valuesRef.current[key] : def)
|
|
427
485
|
defaults[key] = cloneValue(def)
|
|
428
486
|
}
|
|
429
487
|
|
|
@@ -447,6 +505,7 @@ export function useTweakpane<T extends ParamSchema>(
|
|
|
447
505
|
picker: 'inline',
|
|
448
506
|
} as any)
|
|
449
507
|
;(blade as any).on('change', (ev: any) => {
|
|
508
|
+
if (refreshingRef.current) return
|
|
450
509
|
const val = ev.value
|
|
451
510
|
const tuple: [number, number, number, number] = [
|
|
452
511
|
Math.round(val.x1 * 1000) / 1000,
|
|
@@ -455,6 +514,7 @@ export function useTweakpane<T extends ParamSchema>(
|
|
|
455
514
|
Math.round(val.y2 * 1000) / 1000,
|
|
456
515
|
]
|
|
457
516
|
params[key] = tuple
|
|
517
|
+
touchedRef.current.add(key)
|
|
458
518
|
setValues((prev) => ({ ...prev, [key]: tuple }))
|
|
459
519
|
})
|
|
460
520
|
continue
|
|
@@ -466,7 +526,9 @@ export function useTweakpane<T extends ParamSchema>(
|
|
|
466
526
|
max: param.max,
|
|
467
527
|
step: param.step,
|
|
468
528
|
}).on('change', (ev) => {
|
|
529
|
+
if (refreshingRef.current) return
|
|
469
530
|
const val = ev.value as { min: number; max: number }
|
|
531
|
+
touchedRef.current.add(key)
|
|
470
532
|
setValues((prev) => ({ ...prev, [key]: { min: val.min, max: val.max } }))
|
|
471
533
|
})
|
|
472
534
|
continue
|
|
@@ -495,17 +557,22 @@ export function useTweakpane<T extends ParamSchema>(
|
|
|
495
557
|
}
|
|
496
558
|
|
|
497
559
|
folder.addBinding(params, key, opts).on('change', (ev) => {
|
|
560
|
+
if (refreshingRef.current) return
|
|
561
|
+
touchedRef.current.add(key)
|
|
498
562
|
setValues((prev) => ({ ...prev, [key]: ev.value }))
|
|
499
563
|
})
|
|
500
564
|
}
|
|
501
565
|
|
|
566
|
+
liveRef.current = { folder, params }
|
|
567
|
+
|
|
502
568
|
// Register for copy prompt
|
|
503
|
-
activeFolders.set(id, { label, params, defaults })
|
|
569
|
+
activeFolders.set(id, { label, params, defaults, touched: touchedRef.current })
|
|
504
570
|
notifyFolderListeners()
|
|
505
571
|
})()
|
|
506
572
|
|
|
507
573
|
return () => {
|
|
508
574
|
disposed = true
|
|
575
|
+
liveRef.current = null
|
|
509
576
|
if (folder) {
|
|
510
577
|
folder.dispose()
|
|
511
578
|
folder = null
|
|
@@ -520,7 +587,40 @@ export function useTweakpane<T extends ParamSchema>(
|
|
|
520
587
|
}
|
|
521
588
|
}, [label, skip])
|
|
522
589
|
|
|
523
|
-
|
|
590
|
+
// Push live (possibly animated) prop values into the pane for untouched
|
|
591
|
+
// keys. Runs after every render — during playback the component re-renders
|
|
592
|
+
// each frame, so animated values are visible moving in the pane UI.
|
|
593
|
+
// folder.refresh() emits change events; refreshingRef makes handlers
|
|
594
|
+
// ignore them so refreshes never mark keys as touched.
|
|
595
|
+
useEffect(() => {
|
|
596
|
+
const live = liveRef.current
|
|
597
|
+
if (!live) return
|
|
598
|
+
let dirty = false
|
|
599
|
+
for (const [key, param] of Object.entries(schemaRef.current)) {
|
|
600
|
+
if (touchedRef.current.has(key)) continue
|
|
601
|
+
// Bezier blades are not bindings — refresh() won't read params for
|
|
602
|
+
// them. The returned value still follows live props below.
|
|
603
|
+
if (isCubicBezierParam(param)) continue
|
|
604
|
+
const liveVal = resolveDefault(param)
|
|
605
|
+
if (!valuesEqual(live.params[key], liveVal)) {
|
|
606
|
+
live.params[key] = cloneValue(liveVal)
|
|
607
|
+
dirty = true
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
if (dirty) {
|
|
611
|
+
refreshingRef.current = true
|
|
612
|
+
live.folder.refresh()
|
|
613
|
+
refreshingRef.current = false
|
|
614
|
+
}
|
|
615
|
+
})
|
|
616
|
+
|
|
617
|
+
// Merge: user-touched keys return the pane value, untouched keys follow
|
|
618
|
+
// the live schema value so animated props work without any extra wiring.
|
|
619
|
+
const merged: Record<string, unknown> = {}
|
|
620
|
+
for (const [key, param] of Object.entries(schema)) {
|
|
621
|
+
merged[key] = touchedRef.current.has(key) ? values[key] : resolveDefault(param)
|
|
622
|
+
}
|
|
623
|
+
return merged as ResolvedValues<T>
|
|
524
624
|
}
|
|
525
625
|
|
|
526
626
|
// ---------------------------------------------------------------------------
|
|
@@ -530,10 +630,32 @@ export function useTweakpane<T extends ParamSchema>(
|
|
|
530
630
|
/** Sidebar width constant shared with player-page layout. */
|
|
531
631
|
export const SIDEBAR_WIDTH = 280
|
|
532
632
|
|
|
633
|
+
/** Lucide-style panel-right icon used for both opening and closing the sidebar. */
|
|
634
|
+
function PanelRightIcon() {
|
|
635
|
+
return (
|
|
636
|
+
<svg
|
|
637
|
+
width='16'
|
|
638
|
+
height='16'
|
|
639
|
+
viewBox='0 0 24 24'
|
|
640
|
+
fill='none'
|
|
641
|
+
stroke='currentColor'
|
|
642
|
+
strokeWidth='1.5'
|
|
643
|
+
strokeLinecap='round'
|
|
644
|
+
strokeLinejoin='round'
|
|
645
|
+
>
|
|
646
|
+
<rect x='3' y='3' width='18' height='18' rx='2' />
|
|
647
|
+
<line x1='15' y1='3' x2='15' y2='21' />
|
|
648
|
+
</svg>
|
|
649
|
+
)
|
|
650
|
+
}
|
|
651
|
+
|
|
533
652
|
/**
|
|
534
|
-
* Right sidebar that hosts the tweakpane Pane.
|
|
535
|
-
*
|
|
536
|
-
*
|
|
653
|
+
* Right sidebar that hosts the tweakpane Pane. Collapsed by default so the
|
|
654
|
+
* video takes the full width; a fixed top-right button opens it. The sidebar
|
|
655
|
+
* container stays mounted while collapsed (hidden via display: none) because
|
|
656
|
+
* useTweakpane registers controls into it — unmounting would lose all
|
|
657
|
+
* registrations until components remount. Also provides player metadata
|
|
658
|
+
* (frame, fps, sections) to the copy prompt button.
|
|
537
659
|
*/
|
|
538
660
|
export function TweakpaneRoot({
|
|
539
661
|
playerRef,
|
|
@@ -566,28 +688,51 @@ export function TweakpaneRoot({
|
|
|
566
688
|
}
|
|
567
689
|
}, [playerRef, fps, sections, entryPath])
|
|
568
690
|
|
|
691
|
+
const sidebarOpen = useSidebarOpen()
|
|
692
|
+
|
|
569
693
|
return (
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
background: '#111',
|
|
581
|
-
borderLeft: '1px solid rgba(255,255,255,0.08)',
|
|
582
|
-
flexShrink: 0,
|
|
583
|
-
}}
|
|
584
|
-
>
|
|
585
|
-
<CopyChangesButton />
|
|
694
|
+
<>
|
|
695
|
+
{!sidebarOpen && (
|
|
696
|
+
<button
|
|
697
|
+
onClick={() => setSidebarOpen(true)}
|
|
698
|
+
title='Open controls sidebar'
|
|
699
|
+
className='fixed top-4 right-4 z-10 flex items-center justify-center rounded-lg w-8 h-8 text-zinc-400 hover:text-zinc-200 hover:bg-white/5 transition-colors cursor-pointer'
|
|
700
|
+
>
|
|
701
|
+
<PanelRightIcon />
|
|
702
|
+
</button>
|
|
703
|
+
)}
|
|
586
704
|
<div
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
705
|
+
className='egaki-sidebar'
|
|
706
|
+
style={{
|
|
707
|
+
width: SIDEBAR_WIDTH,
|
|
708
|
+
minWidth: SIDEBAR_WIDTH,
|
|
709
|
+
height: '100vh',
|
|
710
|
+
position: 'sticky',
|
|
711
|
+
top: 0,
|
|
712
|
+
overflowY: 'auto',
|
|
713
|
+
overflowX: 'hidden',
|
|
714
|
+
background: '#111',
|
|
715
|
+
borderLeft: '1px solid rgba(255,255,255,0.08)',
|
|
716
|
+
flexShrink: 0,
|
|
717
|
+
display: sidebarOpen ? undefined : 'none',
|
|
718
|
+
}}
|
|
719
|
+
>
|
|
720
|
+
<div className='flex items-center justify-end px-2 pt-2'>
|
|
721
|
+
<button
|
|
722
|
+
onClick={() => setSidebarOpen(false)}
|
|
723
|
+
title='Close controls sidebar'
|
|
724
|
+
className='flex items-center justify-center rounded-lg w-8 h-8 text-zinc-400 hover:text-zinc-200 hover:bg-white/5 transition-colors cursor-pointer'
|
|
725
|
+
>
|
|
726
|
+
<PanelRightIcon />
|
|
727
|
+
</button>
|
|
728
|
+
</div>
|
|
729
|
+
<CopyChangesButton />
|
|
730
|
+
<div
|
|
731
|
+
ref={containerRefCallback}
|
|
732
|
+
id='egaki-tweakpane'
|
|
733
|
+
style={{ width: '100%' }}
|
|
734
|
+
/>
|
|
735
|
+
</div>
|
|
736
|
+
</>
|
|
592
737
|
)
|
|
593
738
|
}
|
package/src/vite/vite-plugin.ts
CHANGED
|
@@ -64,60 +64,6 @@ function resolveSourceToFile(root: string, source: string): string | undefined {
|
|
|
64
64
|
return undefined
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
/**
|
|
68
|
-
* Extract raw source text ranges per section directly from the mdast.
|
|
69
|
-
* Works with heading nodes' positions to capture the FULL raw heading
|
|
70
|
-
* text including props like `duration=3s` and `transition=20` that
|
|
71
|
-
* `splitIntoSections` strips during parsing. Each section spans from
|
|
72
|
-
* its heading's start offset to the next heading's start (or EOF).
|
|
73
|
-
*/
|
|
74
|
-
function sectionRawTexts(source: string, mdast: any): string[] {
|
|
75
|
-
// Collect the start offsets of each heading node
|
|
76
|
-
const headingStarts: number[] = []
|
|
77
|
-
for (const node of mdast.children) {
|
|
78
|
-
if (node.type === 'heading' && node.position?.start?.offset != null) {
|
|
79
|
-
headingStarts.push(node.position.start.offset)
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
// Slice source between consecutive headings, trimming trailing whitespace
|
|
83
|
-
// so boundary changes (adding/removing a following section) don't cause
|
|
84
|
-
// false positives on the preceding section.
|
|
85
|
-
return headingStarts.map((start, i) => {
|
|
86
|
-
const end = i + 1 < headingStarts.length ? headingStarts[i + 1]! : source.length
|
|
87
|
-
return source.slice(start, end).trimEnd()
|
|
88
|
-
})
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Compare old and new MDX sources section-by-section. Returns the index
|
|
93
|
-
* of the first added or edited section, or null if nothing changed (or
|
|
94
|
-
* only deletions occurred, which we skip per design).
|
|
95
|
-
*/
|
|
96
|
-
export function findChangedSectionIndex(oldSource: string, newSource: string): number | null {
|
|
97
|
-
try {
|
|
98
|
-
const oldTexts = sectionRawTexts(oldSource, mdxParse(oldSource))
|
|
99
|
-
const newTexts = sectionRawTexts(newSource, mdxParse(newSource))
|
|
100
|
-
|
|
101
|
-
const maxLen = Math.max(oldTexts.length, newTexts.length)
|
|
102
|
-
for (let i = 0; i < maxLen; i++) {
|
|
103
|
-
if (i >= oldTexts.length) return i // new section added
|
|
104
|
-
if (i >= newTexts.length) return null // section deleted, skip
|
|
105
|
-
if (oldTexts[i] !== newTexts[i]) {
|
|
106
|
-
// Distinguish edit from deletion: if the next old section matches
|
|
107
|
-
// the current new section, a section was deleted (shifted down).
|
|
108
|
-
// Look ahead to detect deletions at any position, not just the tail.
|
|
109
|
-
if (newTexts.length < oldTexts.length && oldTexts[i + 1] === newTexts[i]) {
|
|
110
|
-
return null // deletion, skip
|
|
111
|
-
}
|
|
112
|
-
return i // content changed
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
return null // no scene-level change (frontmatter/preamble only)
|
|
116
|
-
} catch {
|
|
117
|
-
return null // parse error, don't seek
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
67
|
/** Discover .mdx files in the project root directory (non-recursive).
|
|
122
68
|
* Only root-level files become entries to avoid import resolution issues
|
|
123
69
|
* with nested paths. Returns a map of routePath → absolutePath. */
|
|
@@ -160,8 +106,6 @@ export function video(options?: VideoPluginOptions): PluginOption[] {
|
|
|
160
106
|
let defaultRoute: string = ''
|
|
161
107
|
/** Whether the user's project has `motion` (framer-motion) installed. */
|
|
162
108
|
let hasMotion = false
|
|
163
|
-
/** Cached previous MDX sources for section-level diff detection, keyed by abs path. */
|
|
164
|
-
const previousMdxSources: Map<string, string> = new Map()
|
|
165
109
|
/** Set of absolute paths of all entry MDX files for quick lookup. */
|
|
166
110
|
let entryPathSet: Set<string> = new Set()
|
|
167
111
|
|
|
@@ -217,13 +161,6 @@ export function video(options?: VideoPluginOptions): PluginOption[] {
|
|
|
217
161
|
defaultRoute = resolveDefaultRoute(mdxEntries, options?.entry, root)
|
|
218
162
|
entryPathSet = new Set(mdxEntries.values())
|
|
219
163
|
|
|
220
|
-
// Seed previous sources for section-level diff detection.
|
|
221
|
-
for (const [, absPath] of mdxEntries) {
|
|
222
|
-
try {
|
|
223
|
-
previousMdxSources.set(absPath, fs.readFileSync(absPath, 'utf-8'))
|
|
224
|
-
} catch { /* ignore read errors */ }
|
|
225
|
-
}
|
|
226
|
-
|
|
227
164
|
// Auto-generate egaki-env.d.ts so MDX LSP knows about built-in
|
|
228
165
|
// components via the global MDXProvidedComponents type. Same
|
|
229
166
|
// pattern Vite uses for vite-env.d.ts.
|
|
@@ -432,19 +369,7 @@ export function video(options?: VideoPluginOptions): PluginOption[] {
|
|
|
432
369
|
mdxEntries.set(relPath.replace(/\.mdx$/, ''), absEntry)
|
|
433
370
|
}
|
|
434
371
|
defaultRoute = resolveDefaultRoute(mdxEntries, options?.entry, root)
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
// Sync previousMdxSources: seed new entries, prune removed ones.
|
|
438
|
-
for (const file of previousMdxSources.keys()) {
|
|
439
|
-
if (!nextEntryPathSet.has(file)) previousMdxSources.delete(file)
|
|
440
|
-
}
|
|
441
|
-
for (const file of nextEntryPathSet) {
|
|
442
|
-
if (!previousMdxSources.has(file)) {
|
|
443
|
-
try { previousMdxSources.set(file, fs.readFileSync(file, 'utf-8')) }
|
|
444
|
-
catch { /* file may be mid-write */ }
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
entryPathSet = nextEntryPathSet
|
|
372
|
+
entryPathSet = new Set(mdxEntries.values())
|
|
448
373
|
|
|
449
374
|
invalidateVirtual([RESOLVED_APP, RESOLVED_MDX, RESOLVED_MODULES])
|
|
450
375
|
if (this.environment.name === 'client') {
|
|
@@ -457,26 +382,11 @@ export function video(options?: VideoPluginOptions): PluginOption[] {
|
|
|
457
382
|
invalidateVirtual([RESOLVED_APP, RESOLVED_MDX])
|
|
458
383
|
|
|
459
384
|
if (this.environment.name === 'client') {
|
|
460
|
-
// Section-level diff detection for the changed entry.
|
|
461
|
-
const newMdxSource = fs.readFileSync(normalizedFile, 'utf-8')
|
|
462
|
-
const prevSource = previousMdxSources.get(normalizedFile)
|
|
463
|
-
const changedSection = prevSource != null
|
|
464
|
-
? findChangedSectionIndex(prevSource, newMdxSource)
|
|
465
|
-
: null
|
|
466
|
-
previousMdxSources.set(normalizedFile, newMdxSource)
|
|
467
|
-
|
|
468
385
|
ctx.server.environments.client?.hot.send({
|
|
469
386
|
type: 'custom',
|
|
470
387
|
event: 'rsc:update',
|
|
471
388
|
data: { file: ctx.file },
|
|
472
389
|
})
|
|
473
|
-
if (changedSection != null) {
|
|
474
|
-
ctx.server.environments.client?.hot.send({
|
|
475
|
-
type: 'custom',
|
|
476
|
-
event: 'egaki:scene-changed',
|
|
477
|
-
data: { sectionIndex: changedSection, file: normalizedFile },
|
|
478
|
-
})
|
|
479
|
-
}
|
|
480
390
|
}
|
|
481
391
|
return []
|
|
482
392
|
}
|