518 lines
28 KiB
Vue
518 lines
28 KiB
Vue
<!--
|
|
plugin-studio.vue — the Category-5 (nested-mfe-spa / app-shell) DEVELOPER STUDIO.
|
|
|
|
ONE element, <plugin-studio>, renders a VERTICAL-TAB layout whose sidebar is the composability
|
|
ladder grouped by CAPABILITY (5 → 1). Under each capability heading it lists every element that
|
|
tier holds; clicking a row nests that element in the main slot. This is the DX viewing surface:
|
|
the spot to SEE every plugin element work, in one place, ordered by capability, with the embed
|
|
snippet for each — so a developer (or the Driver) can review each element and give feedback.
|
|
|
|
★ WHY AN ELEMENT, NOT A PAGE. Every plugin in this system is a compiled Vue custom element, so
|
|
it supports all five deployment methods (iframe · script-tag/custom-element · direct-JS
|
|
props/events · nested-in-another-element · nested-SPA) and can compose. The studio is itself a
|
|
plugin, so it too is a real element (<plugin-studio>), NOT an index.html page. A page could
|
|
only be iframed (one method) and could never be nested as a tab of another cap-5 shell.
|
|
|
|
HOW IT NESTS EACH ELEMENT. Same two-mode strategy proven in doxa-maps-spa:
|
|
mode 'element' → inject the bundle's built .js once (guarded), then create its real tag in an
|
|
empty Vue-unmanaged slot. Used for token-free composites/elements (no Mapbox peer + no
|
|
one-shot-define collision risk when isolated).
|
|
mode 'iframe' → point a lazy iframe at that element's standalone leaf page (its own full SPA
|
|
with its own peer-engine + token ladder). Used for map elements to sidestep the one-shot
|
|
customElements.define hazard (each map owns its own document).
|
|
|
|
The tab manifest is fetched at runtime from the built tree's manifest.json (so the studio stays
|
|
correct as bundles change), grouped by capability via the manifest's baked tier; a compact baked
|
|
fallback keeps it working under file:// or if the fetch fails. treeBase is derived by index.js
|
|
from the running script URL (two levels up), overridable via tree-base="" or profile-config.
|
|
|
|
★ SHARED SHELL. The top bar + capability-grouped vertical rail + content frame are the ONE shared
|
|
beautiful layout, VerticalTabsLayout (library/layouts) — the same shell doxa-maps-spa uses. The
|
|
studio supplies the grouped rail model + owns the content area (the embed toolbar + the panes
|
|
with their delicate direct-JS / lazy-iframe mount machinery).
|
|
|
|
NO re-import of any nested plugin's components and NO re-registration of a tag another bundle
|
|
owns — the only tag this bundle defines is <plugin-studio> (see index.js).
|
|
-->
|
|
<template>
|
|
<!-- The SHELL (top bar + capability-grouped vertical rail + content frame) is the ONE shared
|
|
beautiful layout, VerticalTabsLayout (library/layouts). The studio supplies the grouped rail
|
|
model + owns everything in the content area: the embed bar (as #toolbar) and the per-element
|
|
panes with their delicate direct-JS / lazy-iframe mount machinery (UNCHANGED). -->
|
|
<VerticalTabsLayout
|
|
:title="title"
|
|
nav-label="Plugin elements · by capability 5 → 1"
|
|
:header="true"
|
|
:groups="navGroups"
|
|
:active="activeKey"
|
|
@update:active="select">
|
|
|
|
<!-- #brand: the studio's logo mark (a plug/puzzle glyph — "plugin studio"), left of the title.
|
|
Self-drawn SVG, green accent to match the admin shell; no external asset (offline safe). -->
|
|
<template #brand>
|
|
<span class="ps-logo" aria-hidden="true">
|
|
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="#92b195" stroke-width="2"
|
|
stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M10 3v3M14 3v3M7 6h10v5a5 5 0 0 1-10 0V6Z" />
|
|
<path d="M12 16v5" />
|
|
</svg>
|
|
</span>
|
|
</template>
|
|
|
|
<!-- #meta: the bundle/element count + an i18n locale toggle. The studio previews LOCALISED
|
|
elements, so a locale switch lets a developer see each element in another language. Reuses
|
|
app-wrapper-material/languages.js (persisted, offline, no dep) — same switcher as the SPA. -->
|
|
<template #meta>
|
|
<span class="ps-count">{{ countLabel }}</span>
|
|
<span class="ps-locale">
|
|
<button type="button" class="ps-locale-btn" :aria-expanded="String(localeOpen)"
|
|
aria-label="Change language" @click="localeOpen = !localeOpen">
|
|
<span class="ps-flag">{{ activeLang.flag }}</span>
|
|
<span class="ps-locale-name">{{ activeLang.nativeName }}</span>
|
|
<span class="ps-dots" aria-hidden="true">⋮</span>
|
|
</button>
|
|
<ul class="ps-locale-menu" v-if="localeOpen" role="listbox">
|
|
<li v-for="l in switcherLangs" :key="l.code" role="option" :aria-selected="String(l.code === locale)">
|
|
<button type="button" class="ps-locale-opt" :class="{ active: l.code === locale }" @click="setLocale(l.code)">
|
|
<span class="ps-flag">{{ l.flag }}</span>
|
|
<span class="ps-locale-name">{{ l.nativeName }}</span>
|
|
<span class="ps-locale-en">{{ l.name }}</span>
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
</span>
|
|
</template>
|
|
|
|
<!-- Toolbar strip: the active element's identity + its copy-embed control. -->
|
|
<template #toolbar>
|
|
<div class="ps-toolbar" v-if="active">
|
|
<div class="ps-bar">
|
|
<div class="ps-bar-t">
|
|
<b>{{ active.label }}</b>
|
|
<span class="ps-bar-sub">{{ active.bundle }} · <{{ active.tag }}><span v-if="active.description"> · {{ active.description }}</span></span>
|
|
</div>
|
|
<button type="button" class="ps-btn" :aria-expanded="String(embedOpen)"
|
|
@click="embedOpen = !embedOpen" v-if="active.embed">Copy embed</button>
|
|
</div>
|
|
|
|
<div class="ps-embed" v-if="embedOpen && active.embed">
|
|
<label>script embed
|
|
<button type="button" class="ps-btn" @click="copyText(active.embed, $event.target)">Copy</button>
|
|
</label>
|
|
<textarea readonly :value="active.embed"></textarea>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- All panes stay mounted once loaded so switching back is instant + state survives. -->
|
|
<div class="ps-stage">
|
|
<section
|
|
v-for="row in flatRows" :key="row.key" class="ps-pane"
|
|
v-show="row.key === activeKey" :hidden="row.key !== activeKey">
|
|
|
|
<template v-if="row.mode === 'element'">
|
|
<div class="ps-slot" :ref="el => setSlot(row.key, el)"></div>
|
|
<div v-if="row.state === 'loading'" class="ps-hint">Loading {{ row.label }}…</div>
|
|
<div v-else-if="row.state === 'error'" class="ps-hint err">
|
|
Could not load {{ row.label }}.<br /><small>{{ row.error }}</small>
|
|
</div>
|
|
</template>
|
|
|
|
<template v-else>
|
|
<!-- iframe panes: a broken leaf page (404 / script error inside the frame) used to show a
|
|
silent blank frame. @load flips a per-row flag; a short watchdog after the src is set
|
|
marks the pane errored if it never fired — so ONE bad nested plugin shows an error
|
|
overlay in ITS pane, never a blank studio. The @error alone is unreliable for cross-
|
|
document frames, hence the watchdog. -->
|
|
<iframe
|
|
class="ps-frame"
|
|
:src="row.loaded ? row.src : 'about:blank'"
|
|
:title="row.label"
|
|
loading="lazy"
|
|
allowfullscreen
|
|
referrerpolicy="no-referrer-when-downgrade"
|
|
@load="onFrameLoad(row)"
|
|
@error="onFrameError(row)"></iframe>
|
|
<div v-if="row.state === 'error'" class="ps-hint err">
|
|
Could not load {{ row.label }}.<br /><small>{{ row.error }}</small>
|
|
</div>
|
|
</template>
|
|
</section>
|
|
<div class="ps-hint" v-if="!activeKey">Choose an element from the list.</div>
|
|
</div>
|
|
</VerticalTabsLayout>
|
|
</template>
|
|
|
|
<script>
|
|
import { defineComponent, inject } from 'vue'
|
|
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
|
import VerticalTabsLayout from '@map/layouts/VerticalTabsLayout.vue'
|
|
import { SWITCHER_LANGUAGES, loadLocale, saveLocale, getLanguage } from '@map/layouts/app-wrapper-material/languages.js'
|
|
|
|
// Only the studio's CONTENT-AREA styles remain here; the shell (top bar + rail + body) is now the
|
|
// shared VerticalTabsLayout (which self-injects its own CSS). Kept as an injected string so the studio
|
|
// content is styled even when this component is nested as a child element (see useShadowStyles).
|
|
const PS_SHADOW_CSS = `
|
|
.ps-logo { display:inline-flex; align-items:center; }
|
|
.ps-logo svg { display:block; }
|
|
.ps-count { color:#6d766e; }
|
|
.ps-locale { position:relative; display:inline-flex; }
|
|
.ps-locale-btn { display:inline-flex; align-items:center; gap:6px; font:inherit; font-size:12px; padding:4px 9px; border:1px solid #d1d4cd; border-radius:8px; background:#d1d4cd; color:#3b463d; cursor:pointer; }
|
|
.ps-locale-btn:hover { background:#d1d4cd; }
|
|
.ps-flag { font-size:13px; line-height:1; }
|
|
.ps-locale-name { max-width:11ch; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
.ps-dots { color:#6d766e; }
|
|
.ps-locale-menu { position:absolute; top:100%; right:0; margin-top:4px; z-index:30; min-width:180px; list-style:none; padding:6px; border:1px solid #d1d4cd; border-radius:10px; background:#ffffff; box-shadow:0 12px 32px rgba(0,0,0,.5); max-height:320px; overflow:auto; }
|
|
.ps-locale-opt { display:flex; align-items:center; gap:8px; width:100%; text-align:left; font:inherit; font-size:13px; padding:7px 9px; border:0; border-radius:7px; background:none; color:#3b463d; cursor:pointer; }
|
|
.ps-locale-opt:hover { background:#d1d4cd; color:#3b463d; }
|
|
.ps-locale-opt.active { background:#d1d4cd; color:#3b463d; }
|
|
.ps-locale-opt.active .ps-locale-name { color:#92b195; }
|
|
.ps-locale-en { margin-left:auto; font-size:11px; color:#6d766e; }
|
|
.ps-toolbar { display:flex; flex-direction:column; gap:8px; padding:10px 12px 0; }
|
|
.ps-bar { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
|
.ps-bar-t { flex:1 1 auto; min-width:0; }
|
|
.ps-bar-t b { font-size:15px; color:#3b463d; }
|
|
.ps-bar-sub { display:block; font-size:12px; color:#6d766e; }
|
|
.ps-btn { font:inherit; font-size:12px; padding:5px 10px; border:1px solid #d1d4cd; border-radius:8px; background:#d1d4cd; color:#3b463d; cursor:pointer; white-space:nowrap; }
|
|
.ps-btn:hover { background:#d1d4cd; }
|
|
.ps-embed { display:flex; flex-direction:column; gap:6px; padding:8px; border:1px solid #d1d4cd; border-radius:8px; background:#f3f3f1; }
|
|
.ps-embed label { font-size:12px; color:#6d766e; display:flex; justify-content:space-between; align-items:center; }
|
|
.ps-embed textarea { width:100%; height:58px; font:11px/1.35 ui-monospace,Menlo,monospace; border:1px solid #d1d4cd; border-radius:6px; background:#ffffff; color:#3b463d; padding:6px; resize:vertical; }
|
|
.ps-stage { position:absolute; inset:10px 12px 12px; border:1px solid #d1d4cd; border-radius:10px; overflow:hidden; background:#f3f3f1; }
|
|
.ps-pane { position:absolute; inset:0; }
|
|
.ps-slot { position:absolute; inset:0; }
|
|
.ps-frame { position:absolute; inset:0; width:100%; height:100%; border:0; display:block; background:#f3f3f1; }
|
|
.ps-hint { position:absolute; inset:0; display:grid; place-items:center; text-align:center; color:#6d766e; font-size:13px; padding:20px; }
|
|
.ps-hint.err { color:#f87171; }
|
|
.ps-hint small { color:#6d766e; }
|
|
`
|
|
|
|
// Reader-facing capability names, most-composed first (5 → 1). Mirrors generate-gallery's
|
|
// CAP_LABEL so the studio speaks the same vocabulary as the built gallery + the docs.
|
|
const CAP_LABEL = {
|
|
5: 'App shell · nested-mfe-spa',
|
|
4: 'Composite element',
|
|
3: 'Multi-view element',
|
|
2: 'Preset element',
|
|
1: 'Single element'
|
|
}
|
|
// Composition tier (machine key) → capability number. 5 = app-shell … 1 = single.
|
|
const PT = { 'nested-mfe-spa': 0, 'nested-elements-mfe': 1, 'monolithic': 2, 'parameterized': 3, 'single': 4 }
|
|
const capFor = (t) => (PT[t] != null ? 5 - PT[t] : 1)
|
|
const CAP_EMOJI = { 5: '🧩', 4: '🔗', 3: '🗂️', 2: '🎛️', 1: '🔹' }
|
|
|
|
// Map elements need the Mapbox peer + do their own one-shot customElements.define, so nest them
|
|
// as their own SPA in a lazy iframe (their leaf page). Everything else is token-free and safe to
|
|
// mount directly by its real tag. A profile whose element name matches one of these → iframe.
|
|
const IFRAME_ELEMENTS = new Set([
|
|
'doxa-research-map', 'doxa-simple-map', 'cities-internet-map', 'gospel-resources-map',
|
|
'my-upg-100-list-map', 'doxa-map', 'doxa-maps-spa'
|
|
])
|
|
|
|
// Load a script exactly once per URL; resolves after the IIFE has executed (its define() ran).
|
|
const _loaded = {}
|
|
function loadScriptOnce(url, attrs) {
|
|
if (_loaded[url]) return _loaded[url]
|
|
_loaded[url] = new Promise((ok, fail) => {
|
|
const s = document.createElement('script')
|
|
s.src = url
|
|
if (attrs) for (const k in attrs) if (attrs[k]) s.setAttribute(k, attrs[k])
|
|
s.onload = () => ok()
|
|
s.onerror = () => { delete _loaded[url]; fail(new Error('failed to load ' + url)) }
|
|
document.head.appendChild(s)
|
|
})
|
|
return _loaded[url]
|
|
}
|
|
|
|
export default defineComponent({
|
|
name: 'PluginStudio',
|
|
components: { VerticalTabsLayout },
|
|
props: {
|
|
treeBase: { type: String, default: '' },
|
|
profileConfig: { type: [String, Object], default: '' },
|
|
title: { type: String, default: 'Plugin Studio' },
|
|
tokenUrl: { type: String, default: '' },
|
|
tk: { type: String, default: '' }
|
|
},
|
|
setup() {
|
|
// Inject the studio's content-area CSS into the shadow root (the shell CSS now belongs to the
|
|
// shared VerticalTabsLayout, which self-injects). Idempotent-by-key; the shell + this content
|
|
// together render the full studio inside its shadow DOM.
|
|
useShadowStyles(PS_SHADOW_CSS, 'plugin-studio')
|
|
const injectedBase = inject('psTreeBase', '')
|
|
return { injectedBase }
|
|
},
|
|
data() {
|
|
return {
|
|
activeKey: '',
|
|
embedOpen: false,
|
|
// i18n locale switcher (reuses app-wrapper-material/languages.js — persisted, offline, no dep).
|
|
locale: loadLocale(),
|
|
localeOpen: false,
|
|
switcherLangs: SWITCHER_LANGUAGES,
|
|
// rows keyed by "<bundle>/<profile>"; each carries its resolved nesting state.
|
|
rows: [] // flat list built from the manifest (grouped for display via `groups`)
|
|
}
|
|
},
|
|
computed: {
|
|
cfg() {
|
|
let pc = this.profileConfig
|
|
if (typeof pc === 'string') { try { pc = pc ? JSON.parse(pc) : {} } catch { pc = {} } }
|
|
return pc && typeof pc === 'object' ? pc : {}
|
|
},
|
|
base() {
|
|
const raw = (this.treeBase || this.cfg.treeBase || this.injectedBase || '').trim()
|
|
if (!raw) return ''
|
|
return raw.replace(/\/+$/, '') + '/'
|
|
},
|
|
flatRows() { return this.rows },
|
|
// Group the flat rows by capability, ordered 5 → 1, dropping empty tiers.
|
|
groups() {
|
|
const byCap = {}
|
|
for (const r of this.rows) (byCap[r.cap] = byCap[r.cap] || []).push(r)
|
|
return [5, 4, 3, 2, 1]
|
|
.filter(c => byCap[c] && byCap[c].length)
|
|
.map(c => ({ cap: c, label: CAP_LABEL[c] || ('capability ' + c), rows: byCap[c] }))
|
|
},
|
|
// The GROUPED rail model the shared VerticalTabsLayout paints. Each tier → a group with its CAP
|
|
// badge + count; each row keeps its `key` (so select(key) is unchanged) and shows the element's
|
|
// <tag> as its sub-label (v-html, so the monospace <code> renders — the tag text is our own,
|
|
// not user input). Emoji per capability tier.
|
|
navGroups() {
|
|
const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
return this.groups.map(g => ({
|
|
key: 'cap-' + g.cap,
|
|
badge: 'CAP ' + g.cap,
|
|
label: g.label,
|
|
count: g.rows.length,
|
|
rows: g.rows.map(r => ({
|
|
key: r.key,
|
|
label: r.label,
|
|
emoji: r.emoji,
|
|
sub: '<code><' + esc(r.tag) + '></code>'
|
|
}))
|
|
}))
|
|
},
|
|
active() { return this.rows.find(r => r.key === this.activeKey) || null },
|
|
countLabel() {
|
|
const nb = new Set(this.rows.map(r => r.bundle)).size
|
|
return nb + ' bundles · ' + this.rows.length + ' elements'
|
|
},
|
|
activeLang() { return getLanguage(this.locale) || { code: this.locale, flag: '🌐', nativeName: this.locale } }
|
|
},
|
|
mounted() {
|
|
this.loadManifest()
|
|
},
|
|
methods: {
|
|
setSlot(key, el) { if (el) this._slots = Object.assign(this._slots || {}, { [key]: el }) },
|
|
// Switch preview language. languages.js's contract: switching = re-setting profile-config.locale
|
|
// on the mounted element. Persist + best-effort re-set the active element (direct-JS panes carry a
|
|
// real element; iframe panes carry their own locale ladder). No router, no dep.
|
|
setLocale(code) {
|
|
this.locale = code
|
|
this.localeOpen = false
|
|
saveLocale(code)
|
|
try {
|
|
const row = this.active
|
|
if (row && row.mode === 'iframe') {
|
|
// iframe panes carry locale as a URL param; re-point the src so the nested SPA re-mounts in
|
|
// the new language. Re-arm the load watchdog (via loaded=false → select) so a broken
|
|
// re-load still shows this pane's error overlay, not a blank frame.
|
|
row.loaded = false
|
|
this.select(this.activeKey)
|
|
} else {
|
|
const slot = this._slots && this._slots[this.activeKey]
|
|
const el = slot && slot.firstElementChild
|
|
if (el && el.setAttribute && row) {
|
|
const pc = { profile: row.label, instanceId: 'ps-' + this.activeKey.replace(/\//g, '-'), locale: code }
|
|
if (this.tk || this.cfg.tk) pc.tk = this.tk || this.cfg.tk
|
|
el.setAttribute('profile-config', JSON.stringify(pc))
|
|
}
|
|
}
|
|
} catch { /* non-fatal: the switcher still persists + updates the UI */ }
|
|
},
|
|
resolve(ref) {
|
|
const r = String(ref).replace(/^\.\//, '')
|
|
if (this.base) return this.base + r
|
|
return '../' + r // sibling bundle folders are one level up from this bundle's folder
|
|
},
|
|
// Build the studio rows from the built tree's manifest.json. Each profile becomes one row,
|
|
// its capability read from the baked tier (manifest profile.type) → capFor. Falls back to the
|
|
// baked compact registry (index.js provides window.__psBaked) under file:// or on fetch fail.
|
|
loadManifest() {
|
|
const useModel = (m) => { this.rows = this.rowsFromManifest(m); this.route() }
|
|
const baked = (typeof window !== 'undefined' && window.__psBaked) || null
|
|
if (typeof fetch === 'function' && typeof location !== 'undefined' && location.protocol !== 'file:') {
|
|
const url = this.base ? this.base + 'manifest.json' : '../manifest.json'
|
|
fetch(url, { cache: 'no-cache' })
|
|
.then(r => (r.ok ? r.json() : null))
|
|
.then(m => useModel(m || baked || { bundles: [] }))
|
|
.catch(() => useModel(baked || { bundles: [] }))
|
|
} else {
|
|
useModel(baked || { bundles: [] })
|
|
}
|
|
},
|
|
rowsFromManifest(m) {
|
|
const out = []
|
|
for (const b of (m.bundles || [])) {
|
|
// never list the studio itself as one of its own tabs
|
|
if (b.bundle === 'plugin-studio') continue
|
|
for (const p of (b.profiles || [])) {
|
|
const tag = p.element || (b.elements && b.elements[0]) || b.bundle
|
|
const cap = capFor(p.type || 'single')
|
|
const iframe = IFRAME_ELEMENTS.has(tag) || IFRAME_ELEMENTS.has(b.bundle)
|
|
out.push({
|
|
key: b.bundle + '/' + p.profile,
|
|
bundle: b.bundle,
|
|
label: p.profile,
|
|
tag,
|
|
cap,
|
|
emoji: CAP_EMOJI[cap] || '🔹',
|
|
description: p.description || '',
|
|
mode: iframe ? 'iframe' : 'element',
|
|
bundleJs: b.scriptUrl || (b.bundle + '/' + b.bundle + '.js'),
|
|
leaf: p.iframeUrl || (b.bundle + '/' + p.profile + '/index.html'),
|
|
embed: (p.embed && p.embed.script) || '',
|
|
// runtime nesting state
|
|
loaded: false, mounted: false, state: 'idle', error: '', src: ''
|
|
})
|
|
}
|
|
}
|
|
// stable: capability desc, then bundle, then label
|
|
out.sort((a, b) => b.cap - a.cap || a.bundle.localeCompare(b.bundle) || a.label.localeCompare(b.label))
|
|
return out
|
|
},
|
|
route() {
|
|
const h = (typeof location !== 'undefined' && location.hash || '').replace(/^#\/?/, '')
|
|
if (h && this.rows.some(r => r.key === h)) this.select(h)
|
|
},
|
|
select(key) {
|
|
this.activeKey = key
|
|
this.embedOpen = false
|
|
const row = this.rows.find(r => r.key === key)
|
|
if (!row) return
|
|
if (row.mode === 'iframe') {
|
|
if (!row.loaded) {
|
|
row.src = this.withToken(this.resolve(row.leaf))
|
|
row.loaded = true
|
|
row.state = 'loading'
|
|
// Watchdog: if no load event arrives in 20s, show this pane's error overlay rather than a
|
|
// silent blank frame. onFrameLoad clears it (and un-errors a late load).
|
|
if (row._frameTimer) clearTimeout(row._frameTimer)
|
|
row._frameTimer = setTimeout(() => {
|
|
if (row.state !== 'ready') { row.state = 'error'; row.error = 'nested plugin did not load in time' }
|
|
}, 20000)
|
|
}
|
|
} else if (!row.mounted) {
|
|
this.mountElement(row)
|
|
}
|
|
},
|
|
withToken(url) {
|
|
const tokenUrl = (this.tokenUrl || this.cfg.tokenUrl || '').trim()
|
|
const tk = (this.tk || this.cfg.tk || '').trim()
|
|
const q = []
|
|
if (tk) q.push('tk=' + encodeURIComponent(tk))
|
|
else if (tokenUrl) q.push('tokenUrl=' + encodeURIComponent(tokenUrl))
|
|
// Push the studio's current preview language down to the nested SPA as a URL param — the
|
|
// leaf pages read `?locale=` and inject it into their element's profile-config.locale. The
|
|
// studio is the "simulate DX-life" surface, so it always carries a concrete locale.
|
|
if (this.locale) q.push('locale=' + encodeURIComponent(this.locale))
|
|
if (!q.length) return url
|
|
return url + (url.indexOf('?') === -1 ? '?' : '&') + q.join('&')
|
|
},
|
|
mountElement(row) {
|
|
row.mounted = true
|
|
row.state = 'loading'
|
|
const jsUrl = this.resolve(row.bundleJs)
|
|
loadScriptOnce(jsUrl).then(() => {
|
|
// Everything below is per-nested-plugin machinery; a throw here (bad tag name, a nested
|
|
// element that blows up in its connectedCallback, a serialise failure) must NOT crash the
|
|
// studio — it lands in THIS row's error pane. Hence the try/catch around the DOM mount, on
|
|
// top of the .catch() that already handles a failed SCRIPT load.
|
|
try {
|
|
const slot = this._slots && this._slots[row.key]
|
|
if (!slot) { row.state = 'error'; row.error = 'no slot'; return }
|
|
slot.innerHTML = ''
|
|
const el = document.createElement(row.tag)
|
|
// Include the current preview locale so a pane mounted before any language switch already
|
|
// renders in the studio's active language (ProfileLoader reads profile-config.locale).
|
|
const pc = { profile: row.label, instanceId: 'ps-' + row.key.replace(/\//g, '-'), locale: this.locale }
|
|
if (this.tk || this.cfg.tk) pc.tk = this.tk || this.cfg.tk
|
|
el.setAttribute('profile-config', JSON.stringify(pc))
|
|
el.style.cssText = 'display:block;position:absolute;inset:0;width:100%;height:100%;'
|
|
slot.appendChild(el)
|
|
row.state = 'ready'
|
|
} catch (e) {
|
|
row.state = 'error'
|
|
row.error = 'mount failed: ' + ((e && e.message) || String(e))
|
|
}
|
|
}).catch((e) => {
|
|
row.state = 'error'
|
|
row.error = (e && e.message) || String(e)
|
|
})
|
|
},
|
|
// iframe panes: a leaf that 404s or errors inside its own document would otherwise show a
|
|
// silent blank frame. onload fires for BOTH a good load and an error document, so we can't tell
|
|
// them apart cross-origin — instead we arm a watchdog when the src is set (in select) and treat
|
|
// a load that arrives as success (clear the watchdog + any error). onFrameError catches the rare
|
|
// same-origin error event. Either way ONE bad nested plugin is contained to its own pane.
|
|
onFrameLoad(row) {
|
|
if (row._frameTimer) { clearTimeout(row._frameTimer); row._frameTimer = null }
|
|
// A load event means the frame document resolved; leave any deeper in-frame failure to the
|
|
// leaf's own error UI. Only clear a watchdog-set error if the frame genuinely loaded late.
|
|
if (row.state === 'error' && row.error === 'nested plugin did not load in time') { row.state = 'ready'; row.error = '' }
|
|
else if (row.state !== 'error') row.state = 'ready'
|
|
},
|
|
onFrameError(row) {
|
|
if (row._frameTimer) { clearTimeout(row._frameTimer); row._frameTimer = null }
|
|
row.state = 'error'
|
|
row.error = 'the nested plugin failed to load'
|
|
},
|
|
copyText(text, btn) {
|
|
const ok = () => { const t = btn.textContent; btn.textContent = 'Copied'; setTimeout(() => { btn.textContent = t }, 1200) }
|
|
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(ok, () => {})
|
|
else {
|
|
try { const ta = document.createElement('textarea'); ta.value = text; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); ok() } catch { /* ignore */ }
|
|
}
|
|
}
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<style>
|
|
/* The shell (top bar + rail + body) is the shared VerticalTabsLayout. Only the studio's own
|
|
content-area styles remain here; mirror of PS_SHADOW_CSS above. Keep the two in sync.
|
|
Reskinned to the dx-life admin tokens (neutral surfaces, green #92b195 accent) to match the shell. */
|
|
.ps-logo { display: inline-flex; align-items: center; }
|
|
.ps-logo svg { display: block; }
|
|
.ps-count { color: #6d766e; }
|
|
.ps-locale { position: relative; display: inline-flex; }
|
|
.ps-locale-btn { display: inline-flex; align-items: center; gap: 6px; font: inherit; font-size: 12px; padding: 4px 9px; border: 1px solid #d1d4cd; border-radius: 8px; background: #d1d4cd; color: #3b463d; cursor: pointer; }
|
|
.ps-locale-btn:hover { background: #d1d4cd; }
|
|
.ps-flag { font-size: 13px; line-height: 1; }
|
|
.ps-locale-name { max-width: 11ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.ps-dots { color: #6d766e; }
|
|
.ps-locale-menu { position: absolute; top: 100%; right: 0; margin-top: 4px; z-index: 30; min-width: 180px; list-style: none; padding: 6px; border: 1px solid #d1d4cd; border-radius: 10px; background: #ffffff; box-shadow: 0 12px 32px rgba(0,0,0,.5); max-height: 320px; overflow: auto; }
|
|
.ps-locale-opt { display: flex; align-items: center; gap: 8px; width: 100%; text-align: left; font: inherit; font-size: 13px; padding: 7px 9px; border: 0; border-radius: 7px; background: none; color: #3b463d; cursor: pointer; }
|
|
.ps-locale-opt:hover { background: #d1d4cd; color: #3b463d; }
|
|
.ps-locale-opt.active { background: #d1d4cd; color: #3b463d; }
|
|
.ps-locale-opt.active .ps-locale-name { color: #92b195; }
|
|
.ps-locale-en { margin-left: auto; font-size: 11px; color: #6d766e; }
|
|
.ps-toolbar { display: flex; flex-direction: column; gap: 8px; padding: 10px 12px 0; }
|
|
.ps-bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
.ps-bar-t { flex: 1 1 auto; min-width: 0; }
|
|
.ps-bar-t b { font-size: 15px; color: #3b463d; }
|
|
.ps-bar-sub { display: block; font-size: 12px; color: #6d766e; }
|
|
.ps-btn { font: inherit; font-size: 12px; padding: 5px 10px; border: 1px solid #d1d4cd; border-radius: 8px; background: #d1d4cd; color: #3b463d; cursor: pointer; white-space: nowrap; }
|
|
.ps-btn:hover { background: #d1d4cd; }
|
|
.ps-embed { display: flex; flex-direction: column; gap: 6px; padding: 8px; border: 1px solid #d1d4cd; border-radius: 8px; background: #f3f3f1; }
|
|
.ps-embed label { font-size: 12px; color: #6d766e; display: flex; justify-content: space-between; align-items: center; }
|
|
.ps-embed textarea { width: 100%; height: 58px; font: 11px/1.35 ui-monospace, Menlo, monospace; border: 1px solid #d1d4cd; border-radius: 6px; background: #ffffff; color: #3b463d; padding: 6px; resize: vertical; }
|
|
.ps-stage { position: absolute; inset: 10px 12px 12px; border: 1px solid #d1d4cd; border-radius: 10px; overflow: hidden; background: #f3f3f1; }
|
|
.ps-pane { position: absolute; inset: 0; }
|
|
.ps-slot { position: absolute; inset: 0; }
|
|
.ps-frame { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; display: block; background: #f3f3f1; }
|
|
.ps-hint { position: absolute; inset: 0; display: grid; place-items: center; text-align: center; color: #6d766e; font-size: 13px; padding: 20px; }
|
|
.ps-hint.err { color: #f87171; }
|
|
.ps-hint small { color: #6d766e; }
|
|
</style>
|