apptify 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -66
- package/dist/apptify.css +1 -1
- package/dist/apptify.js +2298 -2140
- package/dist/components/AppleCard.vue.d.ts +76 -0
- package/dist/components/button.d.ts +3 -3
- package/dist/components/content.d.ts +0 -90
- package/dist/components/foundation.d.ts +201 -129
- package/dist/components/link.d.ts +31 -0
- package/dist/components/overlays.d.ts +8 -2
- package/dist/core/context.d.ts +11 -0
- package/dist/core/overlay-surface.d.ts +6 -0
- package/dist/core/popup-motion.d.ts +5 -5
- package/dist/core/popup-placement.d.ts +3 -2
- package/dist/index.d.ts +144 -79
- package/docs/COMPONENTS.md +23 -9
- package/docs/INPUT-WORKSHOP.md +59 -0
- package/docs/PLAYGROUND-TEMPLATE.md +9 -9
- package/package.json +5 -1
- package/templates/playground/README.md +4 -4
- package/templates/playground/index.html +1 -1
- package/templates/playground/package.json +6 -2
- package/templates/playground/source-snapshot.json +138 -53
- package/templates/playground/{playground → src}/App.vue +5 -4
- package/templates/playground/{playground → src}/ComponentDemo.vue +9 -10
- package/templates/playground/{playground → src}/catalog.ts +3 -4
- package/templates/playground/src/components/ComponentIndex.vue +43 -0
- package/templates/playground/src/components/DirectoryDialog.vue +20 -0
- package/templates/playground/src/components/PageFooter.vue +7 -0
- package/templates/playground/{playground → src}/components/UserMenu.vue +11 -11
- package/templates/playground/src/editor/AppleInputExample.vue +78 -0
- package/templates/playground/src/editor/AppleInputPreview.vue +82 -0
- package/templates/playground/src/editor/ComponentDocumentLink.vue +19 -0
- package/templates/playground/src/editor/ComponentWorkshop.vue +81 -0
- package/templates/playground/src/editor/ConfigurableEditor.vue +35 -0
- package/templates/playground/src/editor/LiveVueEditor.vue +133 -0
- package/templates/playground/src/editor/apple-input-source.ts +2 -0
- package/templates/playground/src/editor/apple-input.ts +48 -0
- package/templates/playground/src/editor/contract.ts +14 -0
- package/templates/playground/src/editor/documents.ts +20 -0
- package/templates/playground/src/editor/input-playground.ts +59 -0
- package/templates/playground/src/editor/runtime.ts +5 -0
- package/templates/playground/src/editor/sections.ts +10 -0
- package/templates/playground/{playground → src}/style.css +40 -39
- package/templates/playground/{playground → src}/views/[...all].vue +2 -2
- package/templates/playground/src/views/component-docs/[component].vue +162 -0
- package/templates/playground/src/views/components.vue +148 -0
- package/templates/playground/src/views/index.vue +73 -0
- package/templates/playground/src/views/settings.vue +224 -0
- package/templates/playground/tooling/playground-editor.mjs +114 -0
- package/templates/playground/tooling/prepare-input-source.mjs +38 -0
- package/templates/playground/tsconfig.json +2 -2
- package/templates/playground/vite.config.ts +3 -1
- package/tooling/playground-editor.mjs +114 -0
- package/tooling/prepare-input-source.mjs +38 -0
- package/tooling/prepare-playground.mjs +27 -14
- package/tooling/test-create-apptify.mjs +15 -5
- package/tooling/test-playground-package.mjs +25 -11
- package/templates/playground/playground/components/ComponentIndex.vue +0 -62
- package/templates/playground/playground/components/PageFooter.vue +0 -3
- package/templates/playground/playground/views/components.vue +0 -126
- package/templates/playground/playground/views/index.vue +0 -75
- package/templates/playground/playground/views/settings.vue +0 -98
- /package/templates/playground/{playground → src}/components/not-found-paper-scene.vue +0 -0
- /package/templates/playground/{playground → src}/components/not-found-paper-story.ts +0 -0
- /package/templates/playground/{playground → src}/env.d.ts +0 -0
- /package/templates/playground/{playground → src}/main.ts +0 -0
- /package/templates/playground/{playground → src}/router/index.ts +0 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<main class="main-content home-page">
|
|
3
|
+
<section class="home-intro">
|
|
4
|
+
<div class="page-kicker">APPTIFY / 界面与交互</div>
|
|
5
|
+
<h1>让界面自然,<br /><span>让细节动人。</span></h1>
|
|
6
|
+
<p>从一个按钮,到完整体验。<br class="mobile-break" /> 简洁的组件,恰到好处的交互。</p>
|
|
7
|
+
<div class="home-actions">
|
|
8
|
+
<router-link v-slot="{ href, navigate }" to="/components" custom>
|
|
9
|
+
<apple-button :href="href" @click="navigate">浏览全部组件 <ArrowRight :size="17" aria-hidden="true" /></apple-button>
|
|
10
|
+
</router-link>
|
|
11
|
+
<router-link v-slot="{ href, navigate }" to="/settings" custom>
|
|
12
|
+
<apple-link :href="href" class="home-link" @click="navigate">调整外观与动效 <ArrowUpRight :size="16" aria-hidden="true" /></apple-link>
|
|
13
|
+
</router-link>
|
|
14
|
+
</div>
|
|
15
|
+
</section>
|
|
16
|
+
<section aria-label="精选交互示例">
|
|
17
|
+
<div class="section-heading"><h2>细节,即刻体验。</h2><span>精选交互示例 · 直接试一试</span></div>
|
|
18
|
+
<div class="specimen-grid">
|
|
19
|
+
<apple-card class="specimen"><div class="specimen-caption"><span>01 / 导航</span></div><h2>内容,各归其位。</h2><apple-search v-model="settingQuery" placeholder="搜索设置" label="搜索设置" /><apple-list v-model="setting" :items="filteredSettings" selectable label="设置导航示例" /><p v-if="!filteredSettings.length" class="search-empty" role="status">没有匹配的设置,试试其他关键词。</p></apple-card>
|
|
20
|
+
<apple-card class="specimen"><div class="specimen-caption"><span>02 / 控制</span></div><h2>点到,即止。</h2><div class="button-specimens"><apple-button :icon="arrowIcon" @click="$apple.notify('操作已完成',{tone:'success'})">继续探索</apple-button><apple-button variant="secondary" @click="$apple.notify('已加入收藏')">加入收藏</apple-button><apple-button variant="outline" @click="$router.push('/components#apple-button')">了解更多</apple-button><div class="icon-button-row"><apple-button variant="secondary" :icon="plusIcon" icon-only label="添加" @click="$apple.notify('已添加')" /><apple-button :variant="liked?'primary':'secondary'" :icon="heartIcon" icon-only :label="liked ? '取消收藏' : '收藏'" :aria-pressed="liked" @click="liked=!liked" /><apple-button variant="secondary" :icon="downloadIcon" icon-only label="浏览组件示例" @click="$router.push('/components')" /></div></div><div class="specimen-foot"><code>apple-button</code><span>5 种样式</span></div></apple-card>
|
|
21
|
+
<apple-card class="specimen"><div class="specimen-caption"><span>03 / 偏好</span></div><h2>恰好,是你的。</h2><div class="preference-demo"><apple-segmented-control v-model="period" :items="periods" label="统计周期" /><div class="setting-line"><div><strong>消息通知</strong><small>不错过重要的事</small></div><apple-switch v-model="notices" aria-label="消息通知" /></div><div class="setting-line"><div><strong>自动同步</strong><small>在所有设备上保持一致</small></div><apple-switch v-model="sync" aria-label="自动同步" /></div><apple-slider v-model="volume" label="音量" /><div class="check-row"><apple-checkbox v-model="agreed" label="记住我的偏好" /></div></div></apple-card>
|
|
22
|
+
<apple-card class="specimen form-specimen"><div class="specimen-caption"><span>04 / 输入</span></div><h2>开始一段对话。</h2><apple-form @submit="$apple.notify(`你好,${name || '新朋友'}!`,{tone:'success'})"><apple-input v-model="name" label="你的名字" placeholder="怎么称呼你" autocomplete="name" clearable required /><apple-input v-model="email" label="电子邮箱" type="email" placeholder="name@example.com" autocomplete="email" required /><apple-button type="submit">加入我们<ArrowRight :size="15" /></apple-button></apple-form></apple-card>
|
|
23
|
+
<apple-card class="specimen media-specimen"><div class="specimen-caption"><span>05 / 媒体</span></div><h2>靠近一点,再一点。</h2><apple-image :gallery="[{ src: '/images/lake.jpg', alt: '山间湖泊和木屋' }, { src: '/images/airpods-max-orange.jpg', alt: '橙色耳机' }]" aspect-ratio="4/3" /><div class="specimen-foot"><code>apple-image</code><span>山间的一刻</span></div></apple-card>
|
|
24
|
+
<apple-card class="specimen feedback-specimen"><div class="specimen-caption"><span>06 / 反馈</span></div><h2>每一步,都有回应。</h2><apple-alert title="所有更改已保存" tone="success" /><div class="progress-demo"><div><span>正在同步</span><span>{{progress}}%</span></div><apple-progress :model-value="progress" label="正在同步" /></div><apple-avatar-group :items="[{name:'林初'},{name:'Alex'},{name:'Taylor'},{name:'Quinn'}]" /><div class="feedback-actions"><apple-button variant="secondary" size="small" @click="openDemoDialog">打开对话框</apple-button><apple-link as="button" @click="$apple.notify('所有更改已保存',{tone:'success'});progress=Math.min(100,progress+10)">显示提示</apple-link></div></apple-card>
|
|
25
|
+
</div>
|
|
26
|
+
</section>
|
|
27
|
+
<section class="more-components">
|
|
28
|
+
<div><h2>更多组件,同样顺手。</h2><span>{{ catalog.length }} 个组件,预览与交互都在一页之中。</span></div>
|
|
29
|
+
<router-link v-slot="{ href, navigate }" to="/components" custom>
|
|
30
|
+
<apple-link :href="href" class="home-link" @click="navigate">查看全部组件 <ArrowRight :size="16" aria-hidden="true" /></apple-link>
|
|
31
|
+
</router-link>
|
|
32
|
+
</section>
|
|
33
|
+
</main>
|
|
34
|
+
</template>
|
|
35
|
+
|
|
36
|
+
<script lang="ts">
|
|
37
|
+
import { defineComponent, markRaw } from 'vue'
|
|
38
|
+
import { ArrowRight, ArrowUpRight, Download, Heart, Plus } from 'lucide-vue-next'
|
|
39
|
+
import { catalog } from '../catalog'
|
|
40
|
+
|
|
41
|
+
export default defineComponent({
|
|
42
|
+
components: { ArrowRight, ArrowUpRight },
|
|
43
|
+
data() { return {
|
|
44
|
+
catalog, arrowIcon: markRaw(ArrowRight), plusIcon: markRaw(Plus), heartIcon: markRaw(Heart), downloadIcon: markRaw(Download),
|
|
45
|
+
period: 'month', periods: [{label:'日',value:'day'},{label:'周',value:'week'},{label:'月',value:'month'}],
|
|
46
|
+
notices: true, sync: false, agreed: true, volume: 64, progress: 68, name: '', email: '', liked: false,
|
|
47
|
+
settingQuery: '', setting: 'profile', overviewSettings: [
|
|
48
|
+
{label:'个人资料',value:'profile',description:'姓名、头像和联系方式'},
|
|
49
|
+
{label:'通知',value:'notices',description:'推送和邮件偏好'},
|
|
50
|
+
{label:'隐私与安全',value:'privacy',description:'管理你的账户安全设置'},
|
|
51
|
+
],
|
|
52
|
+
} },
|
|
53
|
+
computed: {
|
|
54
|
+
filteredSettings() {
|
|
55
|
+
const query = this.settingQuery.trim()
|
|
56
|
+
return this.overviewSettings.filter(item => item.label.includes(query) || item.description.includes(query))
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
methods: {
|
|
60
|
+
openDemoDialog() { this.$apple.dialog({title:'一切,就从这里开始。',message:'试试这些组件,找到适合你的交互。',confirmText:'好的',cancelText:'稍后'}) },
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
</script>
|
|
64
|
+
|
|
65
|
+
<style scoped>
|
|
66
|
+
.home-link { min-height: 44px; gap: 9px; font-size: 13px; font-weight: 500; }
|
|
67
|
+
.search-empty { margin: 12px 0 0; color: var(--apple-secondary); font-size: 12px; line-height: 1.7; }
|
|
68
|
+
/* Keep the library's control sizes in the compact specimens. */
|
|
69
|
+
.button-specimens > .apple-button { min-height: 44px; font-size: inherit; }
|
|
70
|
+
.icon-button-row .apple-button { width: 44px; height: 44px; min-height: 44px; }
|
|
71
|
+
.form-specimen .apple-form .apple-button { min-height: 44px; font-size: inherit; }
|
|
72
|
+
.feedback-actions .apple-button { font-size: 13px; }
|
|
73
|
+
</style>
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<main>
|
|
3
|
+
<apple-container class="main-content settings-page" :data-preview-motion="previewMotion" :data-preview-ripple="rippleAllowed && apple.ripple.value.enabled">
|
|
4
|
+
<aside class="settings-sidebar">
|
|
5
|
+
<h1>设置</h1>
|
|
6
|
+
<nav aria-label="设置导航"><apple-tree v-model="selectedSetting" v-model:expanded="expandedSettings" :items="settingsTree" label="设置目录" @select="navigateSetting" /></nav>
|
|
7
|
+
<p>让界面跟随你的偏好。</p>
|
|
8
|
+
</aside>
|
|
9
|
+
<div class="settings-content">
|
|
10
|
+
<header class="page-heading settings-heading">
|
|
11
|
+
<div><div class="page-kicker">APPTIFY / 外观与交互</div><h2>外观设置</h2><p>调整主题、动效与材质,即刻查看效果。</p></div>
|
|
12
|
+
<apple-button variant="secondary" size="small" @click="reset">恢复默认设置</apple-button>
|
|
13
|
+
</header>
|
|
14
|
+
<div class="settings-grid">
|
|
15
|
+
<apple-card id="setting-theme" class="settings-section" tabindex="-1" aria-labelledby="theme-heading">
|
|
16
|
+
<div class="specimen-caption"><span>01 / 外观</span><Palette :size="18" aria-hidden="true" /></div>
|
|
17
|
+
<div class="settings-section-heading"><div><h2 id="theme-heading">主题</h2><p>选择喜欢的色彩,应用于所有页面。</p></div></div>
|
|
18
|
+
<div class="theme-picker" role="group" aria-label="选择主题">
|
|
19
|
+
<apple-link as="button" v-for="theme in themes" class="theme-choice" :key="theme.value" type="button" :aria-pressed="apple.theme.value.name === theme.value" :class="{ selected: apple.theme.value.name === theme.value }" @click="apple.theme.value.set(theme.value)">
|
|
20
|
+
<span aria-hidden="true" class="theme-swatch" :style="{ background: theme.background, color: theme.accent }"><i /><i /><i /></span>
|
|
21
|
+
<span class="theme-label">{{ theme.label }}<Check v-if="apple.theme.value.name === theme.value" :size="15" aria-hidden="true" /></span>
|
|
22
|
+
</apple-link>
|
|
23
|
+
</div>
|
|
24
|
+
<div class="settings-card-foot">外观偏好保存在这台设备上。</div>
|
|
25
|
+
</apple-card>
|
|
26
|
+
<apple-card id="setting-motion" class="settings-section" tabindex="-1" aria-labelledby="motion-heading">
|
|
27
|
+
<div class="specimen-caption"><span>02 / 动效</span><Waves :size="18" aria-hidden="true" /></div>
|
|
28
|
+
<div class="settings-section-heading"><div><h2 id="motion-heading">动效</h2><p>为页面切换和组件交互选择动效强度。</p></div></div>
|
|
29
|
+
<apple-segmented-control :model-value="apple.motion.value.mode" :items="motionOptions" label="全局动效" @update:model-value="setMotion" />
|
|
30
|
+
<p class="settings-note">{{ motionDescriptions[apple.motion.value.mode] }}<template v-if="apple.motion.value.reduced"> 系统已开启减少动态效果。</template></p>
|
|
31
|
+
<div ref="motionDemo" class="effect-demo motion-sample" aria-label="动效自动演示" @pointerenter="motionPaused = true" @pointerleave="motionPaused = false" @focusin="motionPaused = true" @focusout="motionPaused = false">
|
|
32
|
+
<apple-accordion v-model="motionDemoOpened" multiple :items="motionDemoItems" />
|
|
33
|
+
</div>
|
|
34
|
+
<div class="settings-card-foot">{{ previewMotion === 'none' ? '动效已关闭 · 静态展示' : '自动演示 · 跟随当前动效强度' }}</div>
|
|
35
|
+
</apple-card>
|
|
36
|
+
<apple-card id="setting-ripple" class="settings-section" tabindex="-1" aria-labelledby="ripple-heading">
|
|
37
|
+
<div class="specimen-caption"><span>03 / 点击反馈</span><MousePointer2 :size="18" aria-hidden="true" /></div>
|
|
38
|
+
<div class="settings-section-heading"><div><h2 id="ripple-heading">点击波纹</h2><p>让每一次轻点,都有细微的回应。</p></div></div>
|
|
39
|
+
<apple-switch :model-value="rippleAllowed && apple.ripple.value.enabled" :disabled="!rippleAllowed" label="点击波纹" :hint="rippleAllowed ? '减弱动效时自动关闭,可手动开启;偏好保存在这台设备上。' : '关闭动效时,无法开启点击波纹。'" @update:model-value="apple.ripple.value.set" />
|
|
40
|
+
<div ref="rippleDemo" class="effect-demo ripple-sample" aria-label="波纹自动演示" @pointerenter="ripplePaused = true" @pointerleave="ripplePaused = false" @focusin="ripplePaused = true" @focusout="ripplePaused = false">
|
|
41
|
+
<apple-button variant="outline" @click="rippleDemoClicks++">试试点击波纹</apple-button>
|
|
42
|
+
<span class="ripple-click-count">已点击 {{ rippleDemoClicks }} 次</span>
|
|
43
|
+
</div>
|
|
44
|
+
<div class="settings-card-foot">{{ rippleAllowed && apple.ripple.value.enabled ? '自动点击 · 也可以手动试一试' : '点击波纹已关闭 · 静态展示' }}</div>
|
|
45
|
+
</apple-card>
|
|
46
|
+
<apple-card id="setting-glass" class="settings-section" tabindex="-1" aria-labelledby="glass-heading">
|
|
47
|
+
<div class="specimen-caption"><span>04 / 材质</span><SlidersHorizontal :size="18" aria-hidden="true" /></div>
|
|
48
|
+
<div class="settings-section-heading"><div><h2 id="glass-heading">玻璃效果</h2><p>调整下拉浮层的底色与模糊程度。</p></div></div>
|
|
49
|
+
<div class="glass-controls">
|
|
50
|
+
<apple-slider :model-value="apple.glass.value.opacity" label="玻璃不透明度" :min="0" :max="100" :step="0.1" :format-value="formatOpacity" @update:model-value="setGlassOpacity" />
|
|
51
|
+
<apple-slider :model-value="apple.glass.value.blur" label="玻璃模糊" :min="2" :max="22" :step="1" :format-value="formatBlur" @update:model-value="setGlassBlur" />
|
|
52
|
+
</div>
|
|
53
|
+
<div style="margin-bottom: 16px;">
|
|
54
|
+
<apple-select v-model="glassPreviewChoice" label="点击查看效果" :items="glassPreviewItems"/>
|
|
55
|
+
</div>
|
|
56
|
+
<div class="effect-demo glass-sample" aria-label="玻璃效果自动演示">
|
|
57
|
+
<div class="glass-sample-colors" aria-hidden="true"><span /><span /><span /></div>
|
|
58
|
+
</div>
|
|
59
|
+
<apple-link as="button" class="glass-reset" @click="resetGlass">恢复默认玻璃效果</apple-link>
|
|
60
|
+
</apple-card>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
</apple-container>
|
|
64
|
+
</main>
|
|
65
|
+
</template>
|
|
66
|
+
|
|
67
|
+
<script setup lang="ts">
|
|
68
|
+
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
|
69
|
+
import { Check, Layers, MousePointer2, Palette, SlidersHorizontal, Waves } from 'lucide-vue-next'
|
|
70
|
+
import { builtInThemes, resolveMotion, useApple, type Motion } from 'apptify'
|
|
71
|
+
|
|
72
|
+
const apple = useApple()
|
|
73
|
+
const selectedSetting = ref('theme')
|
|
74
|
+
const expandedSettings = ref(['settings', 'appearance'])
|
|
75
|
+
const settingsTree = [{ label: '设置', value: 'settings', children: [{
|
|
76
|
+
label: '外观设置', value: 'appearance', children: [
|
|
77
|
+
{ label: '主题', value: 'theme' },
|
|
78
|
+
{ label: '动效', value: 'motion' },
|
|
79
|
+
{ label: '点击波纹', value: 'ripple' },
|
|
80
|
+
{ label: '玻璃效果', value: 'glass' },
|
|
81
|
+
],
|
|
82
|
+
}] }]
|
|
83
|
+
async function navigateSetting(item: { value: string | number }) {
|
|
84
|
+
await nextTick()
|
|
85
|
+
const card = document.getElementById(`setting-${item.value}`)
|
|
86
|
+
card?.scrollIntoView({ behavior: previewMotion.value === 'full' ? 'smooth' : 'instant', block: 'start' })
|
|
87
|
+
card?.focus({ preventScroll: true })
|
|
88
|
+
}
|
|
89
|
+
const previewMotion = computed(() => resolveMotion('inherit', apple.motion.value.mode, apple.motion.value.reduced))
|
|
90
|
+
const rippleAllowed = computed(() => resolveMotion('inherit', apple.motion.value.mode, apple.motion.value.reduced) !== 'none')
|
|
91
|
+
const motionDemo = ref<HTMLElement>()
|
|
92
|
+
const rippleDemo = ref<HTMLElement>()
|
|
93
|
+
const motionDemoOpened = ref<string[]>([])
|
|
94
|
+
const motionDemoItems = [{ label: '预览效果', value: 'preview', content: '折叠面板与所有组件的动画速度、数量都会跟随你选择的动效强度。' }]
|
|
95
|
+
const rippleDemoClicks = ref(0)
|
|
96
|
+
const motionPaused = ref(false)
|
|
97
|
+
const ripplePaused = ref(false)
|
|
98
|
+
let demoTimer: number | undefined
|
|
99
|
+
let releaseTimer: number | undefined
|
|
100
|
+
let pressedButton: HTMLButtonElement | undefined
|
|
101
|
+
function releaseDemoButton() {
|
|
102
|
+
window.clearTimeout(releaseTimer)
|
|
103
|
+
pressedButton?.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, button: 0 }))
|
|
104
|
+
pressedButton = undefined
|
|
105
|
+
}
|
|
106
|
+
function playDemos() {
|
|
107
|
+
if (document.hidden || previewMotion.value === 'none') return
|
|
108
|
+
if (!motionPaused.value) motionDemo.value?.querySelector<HTMLButtonElement>('button')?.click()
|
|
109
|
+
if (ripplePaused.value || !apple.ripple.value.enabled) return
|
|
110
|
+
const button = rippleDemo.value?.querySelector<HTMLButtonElement>('button')
|
|
111
|
+
if (!button) return
|
|
112
|
+
releaseDemoButton()
|
|
113
|
+
const rect = button.getBoundingClientRect()
|
|
114
|
+
pressedButton = button
|
|
115
|
+
button.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0, buttons: 1, clientX: rect.x + rect.width / 2, clientY: rect.y + rect.height / 2 }))
|
|
116
|
+
releaseTimer = window.setTimeout(() => {
|
|
117
|
+
releaseDemoButton()
|
|
118
|
+
button.click()
|
|
119
|
+
}, 160)
|
|
120
|
+
}
|
|
121
|
+
onMounted(() => { demoTimer = window.setInterval(playDemos, 2600) })
|
|
122
|
+
onBeforeUnmount(() => {
|
|
123
|
+
window.clearInterval(demoTimer)
|
|
124
|
+
releaseDemoButton()
|
|
125
|
+
})
|
|
126
|
+
const glassPreviewChoice = ref('1')
|
|
127
|
+
const glassPreviewItems = [
|
|
128
|
+
{ label: 'Item 1', value: '1', description: 'Item 1' },
|
|
129
|
+
{ label: 'Item 2', value: '2', description: 'Item 2' },
|
|
130
|
+
]
|
|
131
|
+
function formatOpacity(value: number) { return `${Number(value.toFixed(1))}%` }
|
|
132
|
+
function formatBlur(value: number) { return `${value}px` }
|
|
133
|
+
function setGlassOpacity(opacity: number) { apple.glass.value.set({ opacity }) }
|
|
134
|
+
function setGlassBlur(blur: number) { apple.glass.value.set({ blur }) }
|
|
135
|
+
const themes = [
|
|
136
|
+
{ label: '跟随系统', value: 'system', background: 'linear-gradient(125deg, #f5f5f7 50%, #242426 50%)', accent: '#8a8a90' },
|
|
137
|
+
{ label: '浅色', value: 'light', background: '#f5f5f7', accent: builtInThemes.light.tokens.accent },
|
|
138
|
+
{ label: '深色', value: 'dark', background: '#242426', accent: builtInThemes.dark.tokens.accent },
|
|
139
|
+
{ label: '石墨', value: 'graphite', background: '#253334', accent: '#a6ceca' },
|
|
140
|
+
{ label: '玫瑰', value: 'rose', background: '#fff1f5', accent: '#a83b65' },
|
|
141
|
+
]
|
|
142
|
+
const motionOptions = [{ label: '自动', value: 'auto' }, { label: '完整', value: 'full' }, { label: '减弱', value: 'reduced' }, { label: '关闭', value: 'none' }]
|
|
143
|
+
const motionDescriptions: Record<string, string> = {
|
|
144
|
+
auto: '跟随系统的动态效果偏好,自动调整。',
|
|
145
|
+
full: '呈现完整的过渡与交互动效,同时尊重系统的减少动态效果设置。',
|
|
146
|
+
reduced: '缩短过渡、减少位移,保留必要的反馈。',
|
|
147
|
+
none: '立即呈现每次变化,关闭过渡动画。',
|
|
148
|
+
}
|
|
149
|
+
function setMotion(value: Motion) { apple.motion.value.set(value) }
|
|
150
|
+
function resetGlass() {
|
|
151
|
+
apple.glass.value.reset()
|
|
152
|
+
apple.notify('已恢复默认玻璃效果', { tone: 'success' })
|
|
153
|
+
}
|
|
154
|
+
function reset() {
|
|
155
|
+
apple.theme.value.set('light')
|
|
156
|
+
apple.motion.value.set('auto')
|
|
157
|
+
apple.ripple.value.set(true)
|
|
158
|
+
apple.glass.value.reset()
|
|
159
|
+
apple.notify('已恢复默认设置', { tone: 'success' })
|
|
160
|
+
}
|
|
161
|
+
</script>
|
|
162
|
+
|
|
163
|
+
<style scoped>
|
|
164
|
+
.settings-page { padding-bottom: 40px; display: grid; grid-template-columns: 176px minmax(0, 1fr); gap: 32px; align-items: start; }
|
|
165
|
+
.settings-sidebar { position: sticky; top: 96px; min-width: 0; }
|
|
166
|
+
.settings-sidebar h1 { font-size: 22px; margin: 0 0 24px 8px; }
|
|
167
|
+
.settings-sidebar p { color: var(--apple-secondary); font-size: 11px; line-height: 1.7; margin: 24px 8px 0; }
|
|
168
|
+
.settings-content { min-width: 0; padding-inline: 0; }
|
|
169
|
+
.settings-heading h2 { font-size: 30px; line-height: 1.2; margin: 0; font-weight: 650; }
|
|
170
|
+
.settings-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; }
|
|
171
|
+
.settings-heading .page-kicker { margin-bottom: 14px; }
|
|
172
|
+
.settings-heading > .apple-button { flex-shrink: 0; }
|
|
173
|
+
.settings-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr)); gap: 18px; align-items: stretch; }
|
|
174
|
+
.settings-section { min-width: 0; margin: 0; scroll-margin-top: 88px; }
|
|
175
|
+
.settings-section:focus-visible { outline: 2px solid var(--apple-accent); outline-offset: 4px; }
|
|
176
|
+
.settings-section :deep(> .apple-card__body) { height: 100%; display: flex; flex-direction: column; padding: 12px; }
|
|
177
|
+
.specimen-caption { margin-bottom: 20px; font-size: 10px; }
|
|
178
|
+
.settings-section-heading { margin-bottom: 24px; }
|
|
179
|
+
.settings-section h2 { font-size: 21px; line-height: 1.3; }
|
|
180
|
+
.settings-section-heading p { margin-top: 9px; }
|
|
181
|
+
.settings-card-foot { margin-top: auto; padding-top: 24px; color: var(--apple-secondary); font-size: 11px; line-height: 1.7; }
|
|
182
|
+
.theme-picker { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
|
|
183
|
+
.theme-choice { min-width: 0; padding: 6px; border: 1px solid var(--apple-border); border-radius: 10px; }
|
|
184
|
+
.theme-choice.selected { border-color: var(--apple-accent); outline: 1px solid var(--apple-accent); }
|
|
185
|
+
.theme-choice:focus-visible { outline: 3px solid color-mix(in srgb, var(--apple-accent) 65%, transparent); outline-offset: 3px; }
|
|
186
|
+
.theme-choice :deep(.apple-link__content) { flex-direction: column; width: 100%; gap: 9px; }
|
|
187
|
+
.theme-swatch { height: 62px; padding: 12px; }
|
|
188
|
+
.theme-label { color: var(--apple-text); font-size: 11px; }
|
|
189
|
+
.settings-note { margin: 16px 0 0; }
|
|
190
|
+
.effect-demo { position: relative; min-height: 144px; border-radius: 12px; overflow: hidden; background: transparent; margin-top: 20px; }
|
|
191
|
+
.motion-sample { display: flex; align-items: flex-start; flex: 1; min-height: 220px; padding: 16px; }
|
|
192
|
+
.motion-sample :deep(.apple-accordion) { width: 100%; }
|
|
193
|
+
.motion-sample :deep(.apple-accordion h3) { font-size: 14px; }
|
|
194
|
+
.motion-sample :deep(.apple-accordion__content) { font-size: 12px; }
|
|
195
|
+
.ripple-sample { flex: 1; max-height: 180px; margin-block: auto; display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 16px; padding: 20px; }
|
|
196
|
+
.ripple-click-count { color: var(--apple-secondary); font-size: 11px; }
|
|
197
|
+
.glass-sample { min-height: 156px; display: grid; place-items: center; margin: 0 0 20px; isolation: isolate; }
|
|
198
|
+
.glass-sample-colors { position: absolute; inset: -40px; display: flex; align-items: center; justify-content: center; gap: 20px; animation: sample-glass 6s ease-in-out infinite; }
|
|
199
|
+
.glass-sample-colors span { width: 95px; height: 95px; flex-shrink: 0; border-radius: 28px; background: #82b6ef; transform: rotate(-20deg); }
|
|
200
|
+
.glass-sample-colors span:nth-child(2) { background: #e9aa99; width: 72px; height: 120px; transform: rotate(25deg); }
|
|
201
|
+
.glass-sample-colors span:nth-child(3) { background: #ada1e2; border-radius: 50%; }
|
|
202
|
+
.glass-sample-panel { position: relative; display: grid; grid-template-columns: 20px 1fr; gap: 12px; width: 168px; padding: 18px; border: 1px solid var(--apple-border); border-radius: 12px; background: rgb(var(--apple-glass-rgb, 255 255 255) / var(--apple-glass-opacity, .3)); backdrop-filter: blur(var(--apple-glass-blur, 12px)) saturate(2); -webkit-backdrop-filter: blur(var(--apple-glass-blur, 12px)) saturate(2); font-size: 12px; }
|
|
203
|
+
.glass-sample-panel i { grid-column: 1 / -1; height: 5px; width: 100%; border-radius: 4px; background: currentColor; opacity: .15; }
|
|
204
|
+
.glass-sample-panel i:last-child { width: 65%; }
|
|
205
|
+
@keyframes sample-glass { 0%, 100% { transform: translateX(-32px) rotate(-8deg); } 50% { transform: translateX(32px) rotate(8deg); } }
|
|
206
|
+
@keyframes sample-reduced { 0%, 100% { opacity: .6; } 50% { opacity: 1; } }
|
|
207
|
+
.settings-page[data-preview-motion=reduced] .glass-sample-colors { animation-name: sample-reduced; }
|
|
208
|
+
.settings-page[data-preview-motion=none] .glass-sample-colors { animation: none; }
|
|
209
|
+
.glass-reset { align-self: flex-start; margin-top: 12px; }
|
|
210
|
+
.glass-controls { display: grid; gap: 24px; margin-bottom: 24px; }
|
|
211
|
+
.glass-controls :deep(.apple-slider__value) { min-width: 58px; }
|
|
212
|
+
|
|
213
|
+
@media (max-width: 900px) { .settings-page { grid-template-columns: 144px minmax(0, 1fr); gap: 24px; } }
|
|
214
|
+
@media (max-width: 640px) {
|
|
215
|
+
.settings-page { grid-template-columns: minmax(0, 1fr); gap: 24px; }
|
|
216
|
+
.settings-sidebar { position: static; }
|
|
217
|
+
.settings-sidebar h1 { margin-bottom: 12px; }
|
|
218
|
+
.settings-sidebar p { display: none; }
|
|
219
|
+
.settings-heading h2 { font-size: 26px; }
|
|
220
|
+
.settings-grid { grid-template-columns: minmax(0, 1fr); gap: 16px; }
|
|
221
|
+
.settings-heading { align-items: flex-start; flex-direction: column; gap: 16px; }
|
|
222
|
+
.settings-section :deep(> .apple-card__body) { padding: 10px 6px; }
|
|
223
|
+
}
|
|
224
|
+
</style>
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { build } from 'esbuild'
|
|
2
|
+
import { readFile } from 'node:fs/promises'
|
|
3
|
+
import { createRequire } from 'node:module'
|
|
4
|
+
import { resolve } from 'node:path'
|
|
5
|
+
import { existsSync } from 'node:fs'
|
|
6
|
+
import { createHash } from 'node:crypto'
|
|
7
|
+
import { parse, compileScript, compileStyle } from 'vue/compiler-sfc'
|
|
8
|
+
|
|
9
|
+
// The editor runtime uses esbuild, so compile library SFCs just as Vite does.
|
|
10
|
+
const vueComponents = {
|
|
11
|
+
name: 'vue-components',
|
|
12
|
+
setup(builder) {
|
|
13
|
+
builder.onResolve({ filter: /\.vue\?style$/ }, args => ({ path: args.path.slice(0, -6), namespace: 'vue-style' }))
|
|
14
|
+
builder.onLoad({ filter: /\.vue$/ }, async args => {
|
|
15
|
+
const { descriptor, errors } = parse(await readFile(args.path, 'utf8'), { filename: args.path })
|
|
16
|
+
if (errors.length) throw errors[0]
|
|
17
|
+
const id = createHash('sha256').update(args.path).digest('hex').slice(0, 8)
|
|
18
|
+
const scope = `data-v-${id}`
|
|
19
|
+
if (args.namespace === 'vue-style') {
|
|
20
|
+
const styles = descriptor.styles.map(style => {
|
|
21
|
+
const result = compileStyle({ source: style.content, filename: args.path, id: scope, scoped: style.scoped })
|
|
22
|
+
if (result.errors.length) throw result.errors[0]
|
|
23
|
+
return result.code
|
|
24
|
+
})
|
|
25
|
+
return { contents: styles.join('\n'), loader: 'css' }
|
|
26
|
+
}
|
|
27
|
+
const script = compileScript(descriptor, { id, inlineTemplate: true, genDefaultAs: '__component' })
|
|
28
|
+
return {
|
|
29
|
+
contents: `${script.content}\n__component.__scopeId = ${JSON.stringify(scope)};\nexport default __component;\nimport ${JSON.stringify(args.path + '?style')};`,
|
|
30
|
+
loader: 'ts', resolveDir: resolve(args.path, '..'),
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Local-only resources for the official Vue REPL. No CDN or compile service. */
|
|
37
|
+
export default function playgroundEditor({ appDir = 'playground', libraryDir = 'src' } = {}) {
|
|
38
|
+
let config
|
|
39
|
+
let resources
|
|
40
|
+
let rebuilding = Promise.resolve()
|
|
41
|
+
function rebuild() { rebuilding = rebuilding.catch(() => {}).then(prepare); return rebuilding }
|
|
42
|
+
async function prepare() {
|
|
43
|
+
if (libraryDir && existsSync(resolve(config.root, libraryDir, 'components/forms.ts'))) {
|
|
44
|
+
const { prepareInputSource } = await import('./prepare-input-source.mjs')
|
|
45
|
+
await prepareInputSource()
|
|
46
|
+
}
|
|
47
|
+
const require = createRequire(resolve(config.root, 'package.json'))
|
|
48
|
+
const bundle = await build({
|
|
49
|
+
absWorkingDir: config.root, entryPoints: [resolve(config.root, appDir, 'editor/runtime.ts')],
|
|
50
|
+
bundle: true, format: 'esm', platform: 'browser', external: ['vue'],
|
|
51
|
+
outfile: 'runtime.js', write: false, minify: true,
|
|
52
|
+
define: { 'process.env.NODE_ENV': '"production"' },
|
|
53
|
+
plugins: [vueComponents],
|
|
54
|
+
})
|
|
55
|
+
resources = new Map(bundle.outputFiles.map(file => [file.path.endsWith('.css') ? 'runtime.css' : 'runtime.js', file.contents]))
|
|
56
|
+
resources.set('vue.js', await readFile(require.resolve('vue/dist/vue.runtime.esm-browser.prod.js')))
|
|
57
|
+
resources.set('shims.js', await readFile(require.resolve('es-module-shims')))
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
name: 'apptify-local-editor', enforce: 'pre',
|
|
61
|
+
configResolved(value) { config = value },
|
|
62
|
+
// Harden the official sandbox without replacing its compiler or module processing.
|
|
63
|
+
// Fail closed when upstream changes the reviewed implementation.
|
|
64
|
+
transform(code, id) {
|
|
65
|
+
if (!id.split('?')[0].replaceAll('\\', '/').endsWith('/@vue/repl/dist/vue-repl.js')) return
|
|
66
|
+
for (const permission of ['allow-forms', 'allow-modals', 'allow-pointer-lock', 'allow-popups', 'allow-same-origin', 'allow-top-navigation-by-user-activation']) {
|
|
67
|
+
const token = `"${permission}",`
|
|
68
|
+
if (!code.includes(token) && !code.includes(`"${permission}"\n`)) throw new Error('Vue REPL sandbox changed: review permissions before updating')
|
|
69
|
+
code = code.replace(token, '').replace(`"${permission}"\n`, '')
|
|
70
|
+
}
|
|
71
|
+
code = code.replace('const html = sandbox.contentDocument?.documentElement;', 'const html = null; // Opaque origin: rebuild on theme changes.')
|
|
72
|
+
code = code.replace('sandbox.contentWindow?.location.reload();', 'createSandbox();')
|
|
73
|
+
code = code.replace('\n\t\t\t\tswitchPreviewTheme();', '') // srcdoc already has the initial theme; avoid rebuilding on load.
|
|
74
|
+
return { code, map: null }
|
|
75
|
+
},
|
|
76
|
+
async handleHotUpdate(ctx) {
|
|
77
|
+
const source = libraryDir ? resolve(config.root, libraryDir) + '/' : undefined
|
|
78
|
+
if (!(source && ctx.file.startsWith(source)) && ctx.file !== resolve(config.root, appDir, 'editor/runtime.ts')) return
|
|
79
|
+
// Preview resources are an in-memory bundle, separate from Vite's normal CSS HMR.
|
|
80
|
+
// Rebuild it when library files change, then recreate the preview realm.
|
|
81
|
+
await rebuild()
|
|
82
|
+
ctx.server.ws.send({ type: 'full-reload', path: '*' })
|
|
83
|
+
return []
|
|
84
|
+
},
|
|
85
|
+
async configureServer(server) {
|
|
86
|
+
await rebuild()
|
|
87
|
+
server.middlewares.use((req, res, next) => {
|
|
88
|
+
const path = (req.url || '').split('?')[0]
|
|
89
|
+
const prefix = config.base + 'assets/editor/'
|
|
90
|
+
if (!path.startsWith(prefix)) return next()
|
|
91
|
+
const name = path.slice(prefix.length), content = resources.get(name)
|
|
92
|
+
if (!content) return next()
|
|
93
|
+
res.setHeader('Access-Control-Allow-Origin', '*')
|
|
94
|
+
res.setHeader('Cache-Control', 'no-store')
|
|
95
|
+
res.setHeader('Content-Type', name.endsWith('.css') ? 'text/css' : 'text/javascript')
|
|
96
|
+
res.end(content)
|
|
97
|
+
})
|
|
98
|
+
},
|
|
99
|
+
configurePreviewServer(server) {
|
|
100
|
+
server.middlewares.use((req, res, next) => {
|
|
101
|
+
if ((req.url || '').startsWith(config.base + 'assets/editor/')) {
|
|
102
|
+
res.setHeader('Access-Control-Allow-Origin', '*')
|
|
103
|
+
res.setHeader('Cache-Control', 'no-cache')
|
|
104
|
+
}
|
|
105
|
+
next()
|
|
106
|
+
})
|
|
107
|
+
},
|
|
108
|
+
async buildStart() {
|
|
109
|
+
if (config.command !== 'build') return
|
|
110
|
+
await prepare()
|
|
111
|
+
for (const [name, source] of resources) this.emitFile({ type: 'asset', fileName: 'assets/editor/' + name, source })
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import ts from 'typescript'
|
|
4
|
+
import postcss from 'postcss'
|
|
5
|
+
|
|
6
|
+
// Extract the actual AppleInput declarations with TypeScript's AST. Preserve
|
|
7
|
+
// implementation logic; only erase TS types and route dependencies to public ESM.
|
|
8
|
+
export async function prepareInputSource() {
|
|
9
|
+
const root = new URL('../', import.meta.url)
|
|
10
|
+
const source = await readFile(new URL('src/components/forms.ts', root), 'utf8')
|
|
11
|
+
const ast = ts.createSourceFile('forms.ts', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
|
|
12
|
+
const names = ['fieldProps', 'FieldVm', 'fieldId', 'controlAttrs', 'field', 'spinner', 'stringValue', 'AppleInput']
|
|
13
|
+
const declarations = new Map()
|
|
14
|
+
for (const statement of ast.statements) {
|
|
15
|
+
const name = ts.isVariableStatement(statement) ? statement.declarationList.declarations[0]?.name.getText(ast) : statement.name?.getText(ast)
|
|
16
|
+
if (names.includes(name)) declarations.set(name, statement.getText(ast))
|
|
17
|
+
}
|
|
18
|
+
for (const name of names) if (!declarations.has(name)) throw new Error(`AppleInput source changed: missing ${name}`)
|
|
19
|
+
const script = [
|
|
20
|
+
"import { defineComponent, h, useId, type PropType, type Slots, type VNodeChild } from 'vue'",
|
|
21
|
+
"import { ripple } from 'apptify'",
|
|
22
|
+
"import { Eye, EyeOff, LoaderCircle, X } from 'lucide-vue-next'",
|
|
23
|
+
...names.map(name => declarations.get(name)),
|
|
24
|
+
'export default AppleInput',
|
|
25
|
+
].join('\n\n')
|
|
26
|
+
const js = ts.transpileModule(script, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText
|
|
27
|
+
const cssAst = postcss.parse(await readFile(new URL('src/styles/forms.css', root), 'utf8'))
|
|
28
|
+
const boundary = cssAst.nodes.findIndex(node => node.type === 'rule' && node.selector === '.apple-textarea')
|
|
29
|
+
if (boundary < 0) throw new Error('Input CSS section changed: review source extraction')
|
|
30
|
+
const css = cssAst.nodes.slice(0, boundary).map(node => node.toString()).join('\n')
|
|
31
|
+
const code = `<!-- AppleInput 同源实现:src/components/forms.ts + src/styles/forms.css。\n只移除 TypeScript 类型并转接浏览器公开依赖,保留 Options API/render 实现。 -->\n<script>\n${js}</script>\n\n<style>\n${css}\n</style>\n`
|
|
32
|
+
const destination = new URL('playground/editor/apple-input-source.ts', root)
|
|
33
|
+
const snapshot = '// Generated by tooling/prepare-input-source.mjs; do not edit the snapshot by hand.\nexport default ' + JSON.stringify(code) + '\n'
|
|
34
|
+
// Do not trigger a hot update during dev-server startup for unchanged data.
|
|
35
|
+
const previous = await readFile(destination, 'utf8').catch(() => undefined)
|
|
36
|
+
if (previous !== snapshot) await writeFile(destination, snapshot)
|
|
37
|
+
}
|
|
38
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) await prepareInputSource()
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { defineConfig } from 'vite'
|
|
2
2
|
import vue from '@vitejs/plugin-vue'
|
|
3
3
|
import apptifyRoutes from 'apptify/vite'
|
|
4
|
+
import playgroundEditor from './tooling/playground-editor.mjs'
|
|
4
5
|
|
|
5
6
|
export default defineConfig({
|
|
6
|
-
|
|
7
|
+
optimizeDeps: { exclude: ['@vue/repl'] },
|
|
8
|
+
plugins: [playgroundEditor({ appDir: 'src', libraryDir: null }), apptifyRoutes(), vue()],
|
|
7
9
|
server: { host: '127.0.0.1' },
|
|
8
10
|
build: { outDir: 'dist' },
|
|
9
11
|
})
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { build } from 'esbuild'
|
|
2
|
+
import { readFile } from 'node:fs/promises'
|
|
3
|
+
import { createRequire } from 'node:module'
|
|
4
|
+
import { resolve } from 'node:path'
|
|
5
|
+
import { existsSync } from 'node:fs'
|
|
6
|
+
import { createHash } from 'node:crypto'
|
|
7
|
+
import { parse, compileScript, compileStyle } from 'vue/compiler-sfc'
|
|
8
|
+
|
|
9
|
+
// The editor runtime uses esbuild, so compile library SFCs just as Vite does.
|
|
10
|
+
const vueComponents = {
|
|
11
|
+
name: 'vue-components',
|
|
12
|
+
setup(builder) {
|
|
13
|
+
builder.onResolve({ filter: /\.vue\?style$/ }, args => ({ path: args.path.slice(0, -6), namespace: 'vue-style' }))
|
|
14
|
+
builder.onLoad({ filter: /\.vue$/ }, async args => {
|
|
15
|
+
const { descriptor, errors } = parse(await readFile(args.path, 'utf8'), { filename: args.path })
|
|
16
|
+
if (errors.length) throw errors[0]
|
|
17
|
+
const id = createHash('sha256').update(args.path).digest('hex').slice(0, 8)
|
|
18
|
+
const scope = `data-v-${id}`
|
|
19
|
+
if (args.namespace === 'vue-style') {
|
|
20
|
+
const styles = descriptor.styles.map(style => {
|
|
21
|
+
const result = compileStyle({ source: style.content, filename: args.path, id: scope, scoped: style.scoped })
|
|
22
|
+
if (result.errors.length) throw result.errors[0]
|
|
23
|
+
return result.code
|
|
24
|
+
})
|
|
25
|
+
return { contents: styles.join('\n'), loader: 'css' }
|
|
26
|
+
}
|
|
27
|
+
const script = compileScript(descriptor, { id, inlineTemplate: true, genDefaultAs: '__component' })
|
|
28
|
+
return {
|
|
29
|
+
contents: `${script.content}\n__component.__scopeId = ${JSON.stringify(scope)};\nexport default __component;\nimport ${JSON.stringify(args.path + '?style')};`,
|
|
30
|
+
loader: 'ts', resolveDir: resolve(args.path, '..'),
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Local-only resources for the official Vue REPL. No CDN or compile service. */
|
|
37
|
+
export default function playgroundEditor({ appDir = 'playground', libraryDir = 'src' } = {}) {
|
|
38
|
+
let config
|
|
39
|
+
let resources
|
|
40
|
+
let rebuilding = Promise.resolve()
|
|
41
|
+
function rebuild() { rebuilding = rebuilding.catch(() => {}).then(prepare); return rebuilding }
|
|
42
|
+
async function prepare() {
|
|
43
|
+
if (libraryDir && existsSync(resolve(config.root, libraryDir, 'components/forms.ts'))) {
|
|
44
|
+
const { prepareInputSource } = await import('./prepare-input-source.mjs')
|
|
45
|
+
await prepareInputSource()
|
|
46
|
+
}
|
|
47
|
+
const require = createRequire(resolve(config.root, 'package.json'))
|
|
48
|
+
const bundle = await build({
|
|
49
|
+
absWorkingDir: config.root, entryPoints: [resolve(config.root, appDir, 'editor/runtime.ts')],
|
|
50
|
+
bundle: true, format: 'esm', platform: 'browser', external: ['vue'],
|
|
51
|
+
outfile: 'runtime.js', write: false, minify: true,
|
|
52
|
+
define: { 'process.env.NODE_ENV': '"production"' },
|
|
53
|
+
plugins: [vueComponents],
|
|
54
|
+
})
|
|
55
|
+
resources = new Map(bundle.outputFiles.map(file => [file.path.endsWith('.css') ? 'runtime.css' : 'runtime.js', file.contents]))
|
|
56
|
+
resources.set('vue.js', await readFile(require.resolve('vue/dist/vue.runtime.esm-browser.prod.js')))
|
|
57
|
+
resources.set('shims.js', await readFile(require.resolve('es-module-shims')))
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
name: 'apptify-local-editor', enforce: 'pre',
|
|
61
|
+
configResolved(value) { config = value },
|
|
62
|
+
// Harden the official sandbox without replacing its compiler or module processing.
|
|
63
|
+
// Fail closed when upstream changes the reviewed implementation.
|
|
64
|
+
transform(code, id) {
|
|
65
|
+
if (!id.split('?')[0].replaceAll('\\', '/').endsWith('/@vue/repl/dist/vue-repl.js')) return
|
|
66
|
+
for (const permission of ['allow-forms', 'allow-modals', 'allow-pointer-lock', 'allow-popups', 'allow-same-origin', 'allow-top-navigation-by-user-activation']) {
|
|
67
|
+
const token = `"${permission}",`
|
|
68
|
+
if (!code.includes(token) && !code.includes(`"${permission}"\n`)) throw new Error('Vue REPL sandbox changed: review permissions before updating')
|
|
69
|
+
code = code.replace(token, '').replace(`"${permission}"\n`, '')
|
|
70
|
+
}
|
|
71
|
+
code = code.replace('const html = sandbox.contentDocument?.documentElement;', 'const html = null; // Opaque origin: rebuild on theme changes.')
|
|
72
|
+
code = code.replace('sandbox.contentWindow?.location.reload();', 'createSandbox();')
|
|
73
|
+
code = code.replace('\n\t\t\t\tswitchPreviewTheme();', '') // srcdoc already has the initial theme; avoid rebuilding on load.
|
|
74
|
+
return { code, map: null }
|
|
75
|
+
},
|
|
76
|
+
async handleHotUpdate(ctx) {
|
|
77
|
+
const source = libraryDir ? resolve(config.root, libraryDir) + '/' : undefined
|
|
78
|
+
if (!(source && ctx.file.startsWith(source)) && ctx.file !== resolve(config.root, appDir, 'editor/runtime.ts')) return
|
|
79
|
+
// Preview resources are an in-memory bundle, separate from Vite's normal CSS HMR.
|
|
80
|
+
// Rebuild it when library files change, then recreate the preview realm.
|
|
81
|
+
await rebuild()
|
|
82
|
+
ctx.server.ws.send({ type: 'full-reload', path: '*' })
|
|
83
|
+
return []
|
|
84
|
+
},
|
|
85
|
+
async configureServer(server) {
|
|
86
|
+
await rebuild()
|
|
87
|
+
server.middlewares.use((req, res, next) => {
|
|
88
|
+
const path = (req.url || '').split('?')[0]
|
|
89
|
+
const prefix = config.base + 'assets/editor/'
|
|
90
|
+
if (!path.startsWith(prefix)) return next()
|
|
91
|
+
const name = path.slice(prefix.length), content = resources.get(name)
|
|
92
|
+
if (!content) return next()
|
|
93
|
+
res.setHeader('Access-Control-Allow-Origin', '*')
|
|
94
|
+
res.setHeader('Cache-Control', 'no-store')
|
|
95
|
+
res.setHeader('Content-Type', name.endsWith('.css') ? 'text/css' : 'text/javascript')
|
|
96
|
+
res.end(content)
|
|
97
|
+
})
|
|
98
|
+
},
|
|
99
|
+
configurePreviewServer(server) {
|
|
100
|
+
server.middlewares.use((req, res, next) => {
|
|
101
|
+
if ((req.url || '').startsWith(config.base + 'assets/editor/')) {
|
|
102
|
+
res.setHeader('Access-Control-Allow-Origin', '*')
|
|
103
|
+
res.setHeader('Cache-Control', 'no-cache')
|
|
104
|
+
}
|
|
105
|
+
next()
|
|
106
|
+
})
|
|
107
|
+
},
|
|
108
|
+
async buildStart() {
|
|
109
|
+
if (config.command !== 'build') return
|
|
110
|
+
await prepare()
|
|
111
|
+
for (const [name, source] of resources) this.emitFile({ type: 'asset', fileName: 'assets/editor/' + name, source })
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
}
|