Skip to content

Commit ca832d1

Browse files
authored
feat(playground): support hash sync when embedded in iframe (#298)
1 parent b599aa5 commit ca832d1

File tree

9 files changed

+348
-36
lines changed

9 files changed

+348
-36
lines changed

docs/.vitepress/config.mts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,10 @@
1-
import pkg from '@opentiny/tiny-robot/package.json' with { type: 'json' }
21
import vueJsx from '@vitejs/plugin-vue-jsx'
32
import { fileURLToPath } from 'url'
43
import { defineConfig } from 'vitepress'
54
import { vitepressDemoPlugin } from 'vitepress-demo-plugin'
6-
import { SidebarBadgePlugin, MarkdownBadgePlugin } from './plugins/badge'
5+
import { MarkdownBadgePlugin, SidebarBadgePlugin } from './plugins/badge'
76
import { themeConfig } from './themeConfig'
87

9-
const { version } = pkg
10-
118
const devAlias = {
129
'@opentiny/tiny-robot': fileURLToPath(new URL('../../packages/components/src', import.meta.url)),
1310
'@opentiny/tiny-robot-kit': fileURLToPath(new URL('../../packages/kit/src', import.meta.url)),
@@ -47,9 +44,6 @@ export default defineConfig({
4744
...(process.env.VP_MODE === 'development' ? devAlias : prodAlias),
4845
},
4946
},
50-
define: {
51-
__TINY_ROBOT_VERSION__: JSON.stringify(version),
52-
},
5347
},
5448
markdown: {
5549
config: (md) => {

docs/.vitepress/theme/index.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ declare global {
1010
__SW_REGISTERED__?: boolean
1111
__CODE_PLAYGROUND_LISTENED__?: boolean
1212
}
13-
const __TINY_ROBOT_VERSION__: string
1413
}
1514

1615
export default {
@@ -80,7 +79,7 @@ function listenCodePlaygroundEvent() {
8079
})
8180
}
8281

83-
const tinyRobotVersion = __TINY_ROBOT_VERSION__ || 'latest'
82+
const tinyRobotVersion = 'latest'
8483
const defaultFiles = getDefaultFiles({ tinyRobotVersion })
8584
const cssFile = defaultFiles.find((file) => file.filename === 'src/index.css')
8685
if (cssFile) {

packages/playground/src/App.vue

Lines changed: 100 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,107 @@
11
<script setup lang="ts">
2-
import { File, Repl } from '@vue/repl'
2+
import { Repl } from '@vue/repl'
33
import Monaco from '@vue/repl/monaco-editor'
44
import { nextTick, onMounted, ref, watch, watchEffect } from 'vue'
55
import Header from './Header.vue'
66
import { generateImportMap, generateStore, getDefaultFiles, getVersions } from './utils'
77
8-
declare global {
9-
const __TINY_ROBOT_VERSION__: string
10-
}
11-
12-
const tinyRobotVersion = ref(__TINY_ROBOT_VERSION__ || 'latest')
8+
const tinyRobotVersion = ref('latest')
139
1410
const tinyRobotVersions = ref<string[]>([tinyRobotVersion.value])
11+
const tinyRobotLatestVersion = ref<string | undefined>(undefined)
1512
1613
const { store, builtinImportMap } = generateStore({
1714
tinyRobotVersion: tinyRobotVersion.value,
1815
files: location.hash ? [] : getDefaultFiles({ tinyRobotVersion: tinyRobotVersion.value }),
1916
})
2017
18+
// Extract TinyRobot version from import-map in store (e.g. from hash) and sync to tinyRobotVersion
19+
function syncTinyRobotVersionFromImportMap() {
20+
type ImportMapShape = { imports?: Record<string, string> }
21+
let importMap: ImportMapShape | null = null
22+
23+
const importMapFile = store.files['import-map.json']
24+
if (importMapFile?.code) {
25+
try {
26+
importMap = JSON.parse(importMapFile.code) as ImportMapShape
27+
} catch {
28+
// ignore
29+
}
30+
}
31+
const tinyRobotUrl = importMap?.imports?.['@opentiny/tiny-robot']
32+
if (!tinyRobotUrl) return
33+
34+
// Match version in URL like .../tiny-robot@0.3.2/... or .../tiny-robot@latest/...
35+
const match = tinyRobotUrl.match(/@opentiny\/tiny-robot@([^/]+)/)
36+
const version = match?.[1]?.trim()
37+
if (version) {
38+
tinyRobotVersion.value = version
39+
}
40+
}
41+
42+
/**
43+
* Trigger a full recompile of all files in the store.
44+
* Use this when file contents (e.g. src/index.css) are updated in-place so that the preview
45+
* picks up the changes without needing to activate the corresponding tab.
46+
*/
47+
function triggerFullRecompile() {
48+
store.setFiles(store.getFiles(), store.mainFile)
49+
}
50+
51+
// Listen for messages from the host app (useful when embedded in an iframe).
52+
const ALLOWED_ORIGINS = ['https://playground.opentiny.design'] as const
53+
54+
function isAllowedMessageOrigin(origin: string): boolean {
55+
// Allow local dev (any port) and the official playground domain.
56+
if (!origin) return false
57+
if (ALLOWED_ORIGINS.includes(origin as (typeof ALLOWED_ORIGINS)[number])) return true
58+
59+
try {
60+
const url = new URL(origin)
61+
const host = url.hostname.toLowerCase()
62+
if (host === 'localhost' || host === '127.0.0.1') return true
63+
return false
64+
} catch {
65+
return false
66+
}
67+
}
68+
69+
// 使用 document.referrer 推导父页面的 origin,在跨域场景下无法直接访问 window.parent.location
70+
function getParentOrigin(): string | null {
71+
try {
72+
const referrer = document.referrer
73+
if (!referrer) return null
74+
const url = new URL(referrer)
75+
const origin = url.origin
76+
return isAllowedMessageOrigin(origin) ? origin : null
77+
} catch {
78+
return null
79+
}
80+
}
81+
2182
if (location.hash) {
22-
store.deserialize(location.hash)
83+
try {
84+
store.deserialize(location.hash)
85+
syncTinyRobotVersionFromImportMap()
86+
} catch {
87+
// ignore
88+
}
2389
}
2490
25-
// persist state to URL hash
26-
watchEffect(() => history.replaceState({}, '', store.serialize()))
91+
// Persist state to URL hash; when in an iframe, notify the parent so it can sync the URL.
92+
watchEffect(() => {
93+
const serialized = store.serialize()
94+
history.replaceState({}, '', serialized)
95+
if (window.self !== window.top) {
96+
const targetOrigin = getParentOrigin()
97+
if (targetOrigin) {
98+
window.parent.postMessage(
99+
{ type: 'playground-hash-change', url: window.location.href, hash: serialized },
100+
targetOrigin,
101+
)
102+
}
103+
}
104+
})
27105
28106
// Watch for TinyRobot version changes and update import map
29107
watch(tinyRobotVersion, async (newVersion) => {
@@ -43,28 +121,35 @@ watch(tinyRobotVersion, async (newVersion) => {
43121
`@opentiny/tiny-robot@${newVersion}/dist/style.css`,
44122
)
45123
if (indexCssFile.code !== updatedCss) {
46-
store.addFile(new File('src/index.css', updatedCss))
124+
indexCssFile.code = updatedCss
125+
triggerFullRecompile()
47126
}
48127
}
49128
})
50129
51-
// Load available Vue versions on component mount
130+
// Load available TinyRobot versions on component mount
52131
onMounted(async () => {
53132
try {
54-
tinyRobotVersions.value = await getVersions('@opentiny/tiny-robot', {
133+
const { versions, lastVersion } = await getVersions('@opentiny/tiny-robot', {
55134
includePrerelease: true,
56-
includeLatest: false,
135+
includeLatest: true,
57136
})
137+
tinyRobotVersions.value = versions
138+
tinyRobotLatestVersion.value = lastVersion
58139
} catch (error) {
59-
console.error('Failed to load Vue versions:', error)
140+
console.error('Failed to load TinyRobot versions:', error)
60141
}
61142
})
62143
</script>
63144

64145
<template>
65146
<div class="playground-container">
66147
<!-- Header with Vue version selector -->
67-
<Header v-model:tiny-robot-version="tinyRobotVersion" :tiny-robot-versions="tinyRobotVersions" />
148+
<Header
149+
v-model:tiny-robot-version="tinyRobotVersion"
150+
:tiny-robot-versions="tinyRobotVersions"
151+
:tiny-robot-latest-version="tinyRobotLatestVersion"
152+
/>
68153

69154
<!-- Main playground area -->
70155
<main class="playground-main">

packages/playground/src/Header.vue

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
<script setup lang="ts">
22
import IconGithub from './components/IconGithub.vue'
3+
import IconShare from './components/IconShare.vue'
4+
import { notify } from './utils/notify'
35
46
// Define props interface for version data
57
interface Props {
68
tinyRobotVersions: string[]
9+
tinyRobotLatestVersion?: string
710
}
811
912
// Define props with default values
@@ -12,6 +15,42 @@ withDefaults(defineProps<Props>(), {
1215
})
1316
1417
const tinyRobotVersion = defineModel<string>('tinyRobotVersion', { required: true })
18+
19+
// 获取分享链接
20+
// 1. 如果当前页面是 iframe,则使用 document.referrer 获取父页面 URL
21+
// 2. 如果当前页面不是 iframe,则使用当前页面 URL
22+
const getShareUrl = () => {
23+
if (typeof window === 'undefined') return ''
24+
25+
const { location } = window
26+
const currentHash = location.hash
27+
28+
try {
29+
const referrer = typeof document !== 'undefined' ? document.referrer : ''
30+
if (referrer) {
31+
const url = new URL(referrer)
32+
// 这里写死 pathname。https://playground.opentiny.design/tiny-robot.html
33+
url.pathname = '/tiny-robot.html'
34+
url.hash = currentHash
35+
return url.toString()
36+
}
37+
} catch {}
38+
39+
return location.href
40+
}
41+
42+
// Handle click on the share URL button: copy the URL to clipboard or show it as a fallback.
43+
const handleShareClick = async () => {
44+
const url = getShareUrl()
45+
if (!url) return
46+
47+
try {
48+
if (navigator.clipboard && navigator.clipboard.writeText) {
49+
await navigator.clipboard.writeText(url)
50+
notify('链接已复制到剪贴板')
51+
}
52+
} catch {}
53+
}
1554
</script>
1655

1756
<template>
@@ -26,10 +65,13 @@ const tinyRobotVersion = defineModel<string>('tinyRobotVersion', { required: tru
2665
<label for="tiny-robot-version" class="version-label">TinyRobot 版本:</label>
2766
<select id="tiny-robot-version" v-model="tinyRobotVersion" class="version-select">
2867
<option v-for="version in tinyRobotVersions" :key="version" :value="version">
29-
{{ version }}
68+
{{ version }}{{ version === 'latest' && tinyRobotLatestVersion ? ` (${tinyRobotLatestVersion})` : '' }}
3069
</option>
3170
</select>
3271
</div>
72+
<button type="button" class="share-button" @click="handleShareClick" aria-label="Copy share URL">
73+
<IconShare size="20" />
74+
</button>
3375
<a
3476
class="github-button"
3577
href="https://github.com/opentiny/tiny-robot"
@@ -83,6 +125,27 @@ const tinyRobotVersion = defineModel<string>('tinyRobotVersion', { required: tru
83125
gap: 1rem;
84126
}
85127
128+
.share-button {
129+
background: transparent;
130+
border-radius: 999px;
131+
color: #333;
132+
border: none;
133+
padding: 0;
134+
cursor: pointer;
135+
transition:
136+
background-color 0.15s ease-in-out,
137+
box-shadow 0.15s ease-in-out;
138+
width: 32px;
139+
height: 32px;
140+
display: flex;
141+
align-items: center;
142+
justify-content: center;
143+
}
144+
145+
.share-button:hover {
146+
background-color: rgba(0, 0, 0, 0.04);
147+
}
148+
86149
.version-selector {
87150
display: flex;
88151
align-items: center;
@@ -96,6 +159,7 @@ const tinyRobotVersion = defineModel<string>('tinyRobotVersion', { required: tru
96159
}
97160
98161
.version-select {
162+
min-width: 130px;
99163
padding: 0.375rem 0.75rem;
100164
border: 1px solid #ced4da;
101165
border-radius: 0.375rem;
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<script setup lang="ts">
2+
withDefaults(
3+
defineProps<{
4+
size?: string | number
5+
}>(),
6+
{
7+
size: 24,
8+
},
9+
)
10+
</script>
11+
12+
<template>
13+
<svg
14+
xmlns="http://www.w3.org/2000/svg"
15+
:width="size"
16+
:height="size"
17+
viewBox="0 0 24 24"
18+
fill="none"
19+
stroke="currentColor"
20+
stroke-width="2"
21+
stroke-linecap="round"
22+
stroke-linejoin="round"
23+
>
24+
<path d="M21 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h6" />
25+
<path d="m21 3-9 9" />
26+
<path d="M15 3h6v6" />
27+
</svg>
28+
</template>

0 commit comments

Comments
 (0)