fix:修导出时钟与切页卡顿
导出与播放共用模拟时钟;换循环、重排播放头、<grok-bot> 重挂和构建产物对齐。 自定义/动画面板留下不重画,角色高度对齐,表情走弹簧。
This commit is contained in:
@@ -26,7 +26,7 @@ Do not commit extracted geometry, icons, or a third-party app bundle.
|
||||
|
||||
## What's on the site
|
||||
|
||||
The rail switches three views. **Customise** is 18 body shapes, 11 inks and the rest-face playlist. **Animations** is bloub’s timeline editor on our 39 states: drag to reorder, pull a card to set duration, save named cycles in `localStorage`. The right-hand tiles append a block. **Settings** is language (French, English, Chinese) and credits. GIF/MP4 export is not in this tree.
|
||||
The rail switches three views. **Customise** is 8 body shapes, 11 inks and the rest-face playlist; stills export as PNG / SVG / GIF. **Animations** is bloub’s timeline editor on our 39 states: drag to reorder, pull a card to set duration, save named cycles in `localStorage`, export the sequence as MP4 or GIF. The right-hand tiles append a block. **Settings** is language (French, English, Chinese) and credits.
|
||||
|
||||
The centre avatar is always `GrokCharacter` in `hold` mode. Tiles paint one frame and stop. Nothing from bloub’s `src/bot/` is in this tree.
|
||||
|
||||
|
||||
@@ -72,6 +72,17 @@
|
||||
place-items: center;
|
||||
box-shadow: inset 0 -8px 18px rgba(40, 30, 10, 0.08), 0 12px 28px rgba(0,0,0,.3);
|
||||
}
|
||||
.missing {
|
||||
width: min(100%, 280px);
|
||||
margin: 12px auto 0;
|
||||
padding: 22px 18px;
|
||||
border-radius: 16px;
|
||||
background: #2a2823;
|
||||
color: #d8d0c0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.missing strong { display: block; margin-bottom: 8px; color: var(--accent); font-weight: 560; }
|
||||
.missing a { color: var(--accent); }
|
||||
.disk-login svg { width: 64px; height: 64px; overflow: visible; color-scheme: light; --bg: var(--disk); --fg: #111; }
|
||||
.disk-login span {
|
||||
display: block;
|
||||
@@ -181,6 +192,12 @@
|
||||
<span style="display:block;margin-top:8px;font-size:10px;color:#8d877b;text-align:center">登录 sizePx: 64</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="missing" id="missing-geo" hidden>
|
||||
<strong>几何未加载</strong>
|
||||
本仓库不包含 <code>geometry-data.js</code>。把符合
|
||||
<a href="geometry.schema.md">geometry.schema.md</a>
|
||||
的数据放到同目录后刷新,即可本地预览。不要提交或再分发从商业应用抽出的造型。
|
||||
</div>
|
||||
<div class="modes">
|
||||
<button id="mode-onboard" aria-pressed="true">登录轮换</button>
|
||||
<button id="mode-hold" aria-pressed="false">锁定状态</button>
|
||||
@@ -296,6 +313,10 @@
|
||||
document.getElementById("mode-hold").setAttribute("aria-pressed", mode === "hold");
|
||||
}
|
||||
|
||||
if (!window.GROK_GEO?.shapes || typeof window.GROK?.create !== "function") {
|
||||
document.getElementById("missing-geo").hidden = false;
|
||||
} else {
|
||||
|
||||
GROK_META.groups.forEach((group) => {
|
||||
const h = document.createElement("h2");
|
||||
h.textContent = group.label;
|
||||
@@ -387,6 +408,7 @@
|
||||
document.getElementById("act-spin").onclick = () => bots.forEach((el) => el.spinOnce(1));
|
||||
document.getElementById("act-bounce").onclick = () => bots.forEach((el) => el.bounceOnce());
|
||||
document.getElementById("act-burst").onclick = () => bots.forEach((el) => el.burstOnce());
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+2
-1
@@ -56,7 +56,8 @@
|
||||
|
||||
connectedCallback() {
|
||||
if (this._handle) return;
|
||||
const root = this.attachShadow({ mode: "open" });
|
||||
const root = this.shadowRoot || this.attachShadow({ mode: "open" });
|
||||
root.replaceChildren();
|
||||
const box = document.createElement("div");
|
||||
box.setAttribute("part", "disk");
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
|
||||
@@ -276,10 +276,20 @@
|
||||
this.svg.style.setProperty("--bg", this.eyeColor || EYE_BG);
|
||||
}
|
||||
|
||||
_clock() {
|
||||
return this.driven ? this.last : performance.now();
|
||||
}
|
||||
|
||||
setState(name, { resetEyes = false } = {}) {
|
||||
if (!this.playlists[name]) return;
|
||||
this.state = name;
|
||||
this.stateAt = performance.now();
|
||||
this.stateAt = this._clock();
|
||||
if (this.driven) {
|
||||
this.trick = null;
|
||||
this.spinTurn = null;
|
||||
this.hopAt = -1;
|
||||
this.wildWide = false;
|
||||
}
|
||||
const list = this.playlists[name];
|
||||
this.eyeIdx = 0;
|
||||
if (resetEyes) {
|
||||
@@ -407,6 +417,11 @@
|
||||
return this.snapshot();
|
||||
}
|
||||
|
||||
setSize(px) {
|
||||
this.sizePx = px > 0 ? px : null;
|
||||
this._applyPoseScale();
|
||||
}
|
||||
|
||||
_applyPoseScale() {
|
||||
const sc = this.loginWrap ? poseScale(this.shapeName) : (this.pose.scale || 1);
|
||||
this.pose.scale = sc;
|
||||
@@ -529,7 +544,7 @@
|
||||
}
|
||||
|
||||
_cycleShapeTrick() {
|
||||
if (this.reduceMotion || this.paused) return;
|
||||
if (this.reduceMotion || this.paused || this.driven) return;
|
||||
this.trickCycle = (this.trickCycle + 1) % 5;
|
||||
this.wildWide = false;
|
||||
if (this.trickCycle === 0) this._pn(1);
|
||||
@@ -686,7 +701,7 @@
|
||||
this.celebrateAt = now + 6200;
|
||||
}
|
||||
|
||||
if (now >= this.trickAt) {
|
||||
if (!this.driven && now >= this.trickAt) {
|
||||
if ((V_T.has(this.state) || B_T.has(this.state)) && !this.spinTurn && this.hopAt < 0 && !this.trick) {
|
||||
const z = Math.random();
|
||||
if (V_T.has(this.state)) {
|
||||
|
||||
+19
-3
@@ -112,6 +112,7 @@ watch(activeId, (v) => {
|
||||
ecris('cycle', v)
|
||||
block.value = 0
|
||||
elapsed.value = 0
|
||||
state.value = cycle.value.blocks[0]?.state ?? 'idle'
|
||||
})
|
||||
|
||||
watch(block, (i) => {
|
||||
@@ -144,9 +145,12 @@ function onSeek(t: number) {
|
||||
state.value = cycle.value.blocks[index]?.state ?? 'idle'
|
||||
}
|
||||
|
||||
const gardeAnim = ref(false)
|
||||
|
||||
watch(view, (now) => {
|
||||
intro.value = false
|
||||
if (now === 'animations') {
|
||||
gardeAnim.value = true
|
||||
playing.value = true
|
||||
state.value = cycle.value.blocks[block.value]?.state ?? 'idle'
|
||||
return
|
||||
@@ -248,6 +252,11 @@ function annuleCycle() {
|
||||
|
||||
watch(dialogueCycle, (ouverte) => {
|
||||
if (ouverte) erreurCycle.value = false
|
||||
else annuleCycle()
|
||||
})
|
||||
|
||||
watch([view, preview], () => {
|
||||
if (view.value !== 'animations' || preview.value) annuleCycle()
|
||||
})
|
||||
|
||||
const CONFIRMATION_MS = 1800
|
||||
@@ -330,6 +339,7 @@ onUnmounted(() => {
|
||||
clearTimeout(pending)
|
||||
clearTimeout(minuteurBarre)
|
||||
clearTimeout(confirmation)
|
||||
annuleCycle()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -350,7 +360,8 @@ onUnmounted(() => {
|
||||
<div
|
||||
class="scene min-h-full items-stretch justify-center p-8 max-lg:flex max-lg:flex-col max-lg:gap-10 max-lg:px-5"
|
||||
:class="[
|
||||
!preview && view === 'animations' && 'scene--timeline pb-[calc(var(--timeline)_+_1rem)]',
|
||||
!preview && 'scene--timeline lg:pb-[calc(var(--timeline)_+_1rem)]',
|
||||
!preview && view === 'animations' && 'pb-[calc(var(--timeline)_+_1rem)]',
|
||||
!preview && 'max-lg:pt-20',
|
||||
nue || preview ? 'scene--seule' : view === 'reglages' && 'scene--gauche'
|
||||
]"
|
||||
@@ -427,23 +438,27 @@ onUnmounted(() => {
|
||||
:class="droite ? 'panneau--ouvert max-lg:order-2' : 'max-lg:hidden'"
|
||||
>
|
||||
<Customizer
|
||||
v-if="view === 'personnaliser'"
|
||||
v-show="view === 'personnaliser'"
|
||||
v-model:shape="shape"
|
||||
v-model:color="color"
|
||||
v-model:expression="expression"
|
||||
:ready="ready"
|
||||
:active="view === 'personnaliser'"
|
||||
/>
|
||||
<AnimPanel
|
||||
v-else-if="view === 'animations'"
|
||||
v-if="gardeAnim"
|
||||
v-show="view === 'animations'"
|
||||
v-model="state"
|
||||
:shape="shape"
|
||||
:color="color"
|
||||
:ready="ready"
|
||||
:active="view === 'animations'"
|
||||
@pick="addBlock"
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<Transition name="fondu">
|
||||
<Timeline
|
||||
v-if="view === 'animations' && !preview"
|
||||
v-model:cycles="cycles"
|
||||
@@ -458,6 +473,7 @@ onUnmounted(() => {
|
||||
@preview="preview = true"
|
||||
@exporter="dialogueCycle = true"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<p v-if="view === 'reglages' && !preview" class="wordmark" aria-hidden="true">STUDY</p>
|
||||
</template>
|
||||
|
||||
@@ -11,6 +11,7 @@ const props = defineProps<{
|
||||
shape: string
|
||||
color: string
|
||||
ready?: boolean
|
||||
active?: boolean
|
||||
}>()
|
||||
|
||||
const groups = computed(() => {
|
||||
@@ -43,6 +44,7 @@ const groups = computed(() => {
|
||||
:state="id"
|
||||
:shape="shape"
|
||||
:color="color"
|
||||
:active="active"
|
||||
@click="emit('pick', id)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -12,8 +12,9 @@ const props = withDefaults(
|
||||
size?: number
|
||||
interactive?: boolean
|
||||
showLabel?: boolean
|
||||
active?: boolean
|
||||
}>(),
|
||||
{ interactive: true, showLabel: true }
|
||||
{ interactive: true, showLabel: true, active: true }
|
||||
)
|
||||
|
||||
const svg = ref<SVGSVGElement | null>(null)
|
||||
@@ -22,7 +23,6 @@ const painted = ref(false)
|
||||
async function paint() {
|
||||
const node = svg.value
|
||||
if (!node) return
|
||||
node.innerHTML = ''
|
||||
const mount = await paintTile(node, {
|
||||
state: props.state ?? 'idle',
|
||||
shape: props.shape ?? 'blob',
|
||||
@@ -34,12 +34,27 @@ async function paint() {
|
||||
painted.value = !!mount
|
||||
}
|
||||
|
||||
const dirty = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
void paint()
|
||||
})
|
||||
watch(() => [props.state, props.shape, props.color], () => {
|
||||
if (!props.active) {
|
||||
dirty.value = true
|
||||
return
|
||||
}
|
||||
void paint()
|
||||
})
|
||||
watch(
|
||||
() => props.active,
|
||||
(on) => {
|
||||
if (on && dirty.value) {
|
||||
dirty.value = false
|
||||
void paint()
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -4,7 +4,7 @@ import BotTile from '@/components/BotTile.vue'
|
||||
import { shapeName, stateName, t } from '@/i18n'
|
||||
import { COLOR_IDS, EXPRESSION_IDS, colorFill, liveShapes } from '@/replica/catalog'
|
||||
|
||||
const props = defineProps<{ ready?: boolean }>()
|
||||
const props = defineProps<{ ready?: boolean; active?: boolean }>()
|
||||
const shape = defineModel<string>('shape', { required: true })
|
||||
const color = defineModel<string>('color', { required: true })
|
||||
const expression = defineModel<string>('expression', { required: true })
|
||||
@@ -26,6 +26,7 @@ const shapes = computed(() => {
|
||||
:shape="id"
|
||||
:color="color"
|
||||
:state="expression"
|
||||
:active="active"
|
||||
@click="shape = id"
|
||||
/>
|
||||
</div>
|
||||
@@ -40,6 +41,7 @@ const shapes = computed(() => {
|
||||
:shape="shape"
|
||||
:color="color"
|
||||
:state="id"
|
||||
:active="active"
|
||||
@click="expression = id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
import type { Cycle } from '@/editor/cycles'
|
||||
import { MAX_CYCLES, type Cycle } from '@/editor/cycles'
|
||||
import { nomDeCycle, t } from '@/i18n'
|
||||
|
||||
/**
|
||||
@@ -8,7 +8,7 @@ import { nomDeCycle, t } from '@/i18n'
|
||||
* ni la suppression d'une entree, d'ou ce menu — et il s'ouvre vers le haut,
|
||||
* la barre etant en bas de l'ecran.
|
||||
*/
|
||||
defineProps<{ cycles: Cycle[]; current: Cycle }>()
|
||||
const props = defineProps<{ cycles: Cycle[]; current: Cycle }>()
|
||||
const activeId = defineModel<string>('activeId', { required: true })
|
||||
const emit = defineEmits<{ create: []; remove: [id: string]; rename: [id: string] }>()
|
||||
|
||||
@@ -21,6 +21,7 @@ function choose(id: string) {
|
||||
}
|
||||
|
||||
function create() {
|
||||
if (props.cycles.length >= MAX_CYCLES) return
|
||||
open.value = false
|
||||
emit('create')
|
||||
}
|
||||
@@ -135,7 +136,8 @@ onBeforeUnmount(() => window.removeEventListener('pointerdown', onOutside))
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full cursor-pointer items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-xs transition hover:bg-black/5"
|
||||
class="flex w-full cursor-pointer items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-xs transition hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
:disabled="cycles.length >= MAX_CYCLES"
|
||||
@click="create"
|
||||
>
|
||||
<span class="w-3 shrink-0 text-[var(--muted)]">+</span>
|
||||
|
||||
@@ -8,6 +8,7 @@ import ZoomSlider from '@/components/ZoomSlider.vue'
|
||||
import {
|
||||
blocksWith,
|
||||
makeBlock,
|
||||
MAX_CYCLES,
|
||||
nextCycleId,
|
||||
offsetOf,
|
||||
totalDuration,
|
||||
@@ -77,6 +78,7 @@ function select(id: string) {
|
||||
}
|
||||
|
||||
function askCreate() {
|
||||
if (cycles.value.length >= MAX_CYCLES) return
|
||||
naming.value = { mode: 'create' }
|
||||
nameDraft.value = uniqueName(t('cycles.newName'), cycles.value)
|
||||
nameOpen.value = true
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
defineProps<{
|
||||
playing: boolean
|
||||
state: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggle: []
|
||||
preview: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="fixed right-0 bottom-0 left-0 z-20 border-t border-[var(--line)] bg-[var(--paper)]/90 px-4 py-3 backdrop-blur max-lg:px-5 lg:left-[4.5rem] lg:right-[24.5rem]"
|
||||
>
|
||||
<div class="mx-auto flex max-w-[460px] items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-xl border border-[var(--line)] px-3 py-1.5 text-sm transition hover:border-[var(--muted)]"
|
||||
:aria-label="playing ? t('timeline.pause') : t('timeline.play')"
|
||||
@click="emit('toggle')"
|
||||
>
|
||||
{{ playing ? t('timeline.pause') : t('timeline.play') }}
|
||||
</button>
|
||||
<span class="tronque text-sm font-medium">{{ state }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-xl border border-[var(--line)] px-3 py-1.5 text-sm transition hover:border-[var(--muted)]"
|
||||
:aria-label="t('preview.open')"
|
||||
@click="emit('preview')"
|
||||
>
|
||||
{{ t('preview.open') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -4,6 +4,7 @@ import BlockPicker from '@/components/BlockPicker.vue'
|
||||
import BotTile from '@/components/BotTile.vue'
|
||||
import {
|
||||
clampDuration,
|
||||
followMove,
|
||||
moveBlock,
|
||||
offsetOf,
|
||||
STEP,
|
||||
@@ -11,8 +12,8 @@ import {
|
||||
type Block,
|
||||
type StateId
|
||||
} from '@/editor/cycles'
|
||||
import { BASE_SCALE, clampZoom, ticksFor } from '@/ui/timeline'
|
||||
import { secondes, secondesCourtes, stateName, t } from '@/i18n'
|
||||
import { BASE_SCALE, clampZoom, ticksFor } from '@/ui/timeline'
|
||||
|
||||
/**
|
||||
* La piste : une regle graduee, les cartes du montage, et les gestes qui vont
|
||||
@@ -240,10 +241,8 @@ function onBlockUp(index: number) {
|
||||
return
|
||||
}
|
||||
if (d.to === d.from) return
|
||||
// le curseur suit la carte qu'on deplace, sinon la lecture sauterait ailleurs
|
||||
const suivi = block.value === d.from ? d.to : block.value
|
||||
emit('update:blocks', moveBlock(props.blocks, d.from, d.to))
|
||||
block.value = suivi
|
||||
block.value = followMove(block.value, d.from, d.to)
|
||||
}
|
||||
|
||||
function onResizeDown(index: number, e: PointerEvent) {
|
||||
@@ -298,7 +297,7 @@ async function onCardKey(index: number, e: KeyboardEvent) {
|
||||
const cible = index + sens
|
||||
if (cible < 0 || cible >= props.blocks.length) return
|
||||
emit('update:blocks', moveBlock(props.blocks, index, cible))
|
||||
block.value = cible
|
||||
block.value = followMove(block.value, index, cible)
|
||||
await nextTick()
|
||||
const liste = track.value?.querySelectorAll<HTMLButtonElement>('[data-carte]')
|
||||
liste?.[cible]?.focus()
|
||||
|
||||
@@ -93,6 +93,14 @@ export function moveBlock(blocks: Block[], from: number, to: number): Block[] {
|
||||
return next
|
||||
}
|
||||
|
||||
/** Index of the clip that was current, after `moveBlock(from, to)`. */
|
||||
export function followMove(current: number, from: number, to: number): number {
|
||||
if (current === from) return to
|
||||
if (from < to && current > from && current <= to) return current - 1
|
||||
if (from > to && current >= to && current < from) return current + 1
|
||||
return current
|
||||
}
|
||||
|
||||
export function uniqueName(base: string, cycles: Cycle[]): string {
|
||||
const taken = new Set(cycles.map((c) => c.name))
|
||||
if (!taken.has(base)) return base
|
||||
|
||||
@@ -13,6 +13,7 @@ export type ReplicaBot = {
|
||||
orbitGaze: (ms?: number) => void
|
||||
holdFrame: (at?: number) => void
|
||||
step: (dt: number) => void
|
||||
setSize: (px: number) => void
|
||||
destroy: () => void
|
||||
}
|
||||
|
||||
|
||||
+34
-10
@@ -18,16 +18,39 @@ export type ReplicaInput = {
|
||||
size: number
|
||||
}
|
||||
|
||||
let tileQueue: Promise<unknown> = Promise.resolve()
|
||||
const TILE_MAX = 6
|
||||
let tileActive = 0
|
||||
const tileWait: Array<() => void> = []
|
||||
|
||||
/** One-frame tiles share a queue so first paint does not spawn 30 engines at once. */
|
||||
export function paintTile(svg: SVGSVGElement, input: ReplicaInput): Promise<ReplicaMount | null> {
|
||||
const job = tileQueue.then(() => mountReplica(svg, input, false))
|
||||
tileQueue = job.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
)
|
||||
return job
|
||||
function lockTile(): Promise<void> {
|
||||
if (tileActive < TILE_MAX) {
|
||||
tileActive++
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
tileWait.push(() => {
|
||||
tileActive++
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function unlockTile() {
|
||||
tileActive--
|
||||
tileWait.shift()?.()
|
||||
}
|
||||
|
||||
export async function paintTile(
|
||||
svg: SVGSVGElement,
|
||||
input: ReplicaInput
|
||||
): Promise<ReplicaMount | null> {
|
||||
await lockTile()
|
||||
try {
|
||||
svg.innerHTML = ''
|
||||
return await mountReplica(svg, input, false)
|
||||
} finally {
|
||||
unlockTile()
|
||||
}
|
||||
}
|
||||
|
||||
export async function mountReplica(
|
||||
@@ -70,11 +93,12 @@ export async function mountReplica(
|
||||
let last = { ...input }
|
||||
return {
|
||||
apply(next) {
|
||||
if (next.state !== last.state) bot.setState(next.state, { resetEyes: true })
|
||||
if (next.state !== last.state) bot.setState(next.state, { resetEyes: false })
|
||||
if (next.shape !== last.shape) bot.setShape(next.shape)
|
||||
if (next.color !== last.color) bot.setColor(next.color)
|
||||
if (next.follow !== last.follow) bot.setFollowPointer(next.follow)
|
||||
if (next.paper !== last.paper) bot.setEyeColor(next.paper)
|
||||
if (next.size !== last.size) bot.setSize(next.size)
|
||||
last = { ...next }
|
||||
},
|
||||
spin: () => bot.spinOnce(1),
|
||||
|
||||
+13
-1
@@ -593,11 +593,23 @@ html::-webkit-scrollbar {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.fondu-enter-active,
|
||||
.fondu-leave-active {
|
||||
transition: opacity 0.35s ease-out;
|
||||
}
|
||||
|
||||
.fondu-enter-from,
|
||||
.fondu-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.scene,
|
||||
.panneau,
|
||||
.panneau--ouvert,
|
||||
.avatar {
|
||||
.avatar,
|
||||
.fondu-enter-active,
|
||||
.fondu-leave-active {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
|
||||
+28
-15
@@ -13,24 +13,37 @@ function serveReplica(): Plugin {
|
||||
'.svg': 'image/svg+xml',
|
||||
'.md': 'text/markdown; charset=utf-8'
|
||||
}
|
||||
function middleware(
|
||||
req: { url?: string },
|
||||
res: { statusCode: number; setHeader: (k: string, v: string) => void; end: (b?: Buffer) => void },
|
||||
next: () => void
|
||||
) {
|
||||
if (!req.url?.startsWith('/replica/')) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
const rel = decodeURIComponent(req.url.slice('/replica/'.length).split('?')[0] || '')
|
||||
const file = path.resolve(root, rel)
|
||||
if (!file.startsWith(root) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) {
|
||||
res.statusCode = 404
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
res.setHeader('Content-Type', types[path.extname(file)] || 'application/octet-stream')
|
||||
res.end(fs.readFileSync(file))
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'serve-replica',
|
||||
configureServer(server) {
|
||||
server.middlewares.use((req, res, next) => {
|
||||
if (!req.url?.startsWith('/replica/')) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
const rel = decodeURIComponent(req.url.slice('/replica/'.length).split('?')[0] || '')
|
||||
const file = path.resolve(root, rel)
|
||||
if (!file.startsWith(root) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) {
|
||||
res.statusCode = 404
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
res.setHeader('Content-Type', types[path.extname(file)] || 'application/octet-stream')
|
||||
res.end(fs.readFileSync(file))
|
||||
})
|
||||
server.middlewares.use(middleware)
|
||||
},
|
||||
configurePreviewServer(server) {
|
||||
server.middlewares.use(middleware)
|
||||
},
|
||||
writeBundle(options) {
|
||||
const dest = path.resolve(options.dir ?? 'dist', 'replica')
|
||||
fs.cpSync(root, dest, { recursive: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user