Corner widgets: a plugin can declare a fixed corner of the Plugin Studio, and Rudder takes bottom-right
BUILD-CORNER-WIDGET-CLASS-RUDDER-IN-PLUGIN-STUDIO (design approved by dx-shepherd). A profile's .meta.json
declares "widget": { "corner": "bottom-right" | ... }; generate-tree carries it into manifest.json, so the
Studio keeps no list. The Studio mounts each widget once in a fixed corner host (z-index above everything),
outside the rail and the pane, collapsed to a button, with its open state remembered. Picking it in the rail
opens the corner instead of mounting a second copy in a pane. The host and the mounted element live in a
page-level singleton on document.body, so a Studio hot update or remount re-attaches the same element and
Rudder keeps its live stream. rudder-voice declares bottom-right. Needs eyes: open, HMR, collapse.
This commit is contained in:
parent
4531374e68
commit
1775f5acb5
3 changed files with 83 additions and 3 deletions
|
|
@ -313,8 +313,11 @@ function profileReachesAMap(dir, file) {
|
|||
function metaStageHeavy(dir, profileName) {
|
||||
try {
|
||||
const meta = JSON.parse(readFileSync(join(dir, `${profileName}.meta.json`), 'utf8'))
|
||||
return { stage: meta?.stage === 'production' ? 'production' : 'experimental', heavy: meta?.heavy === true }
|
||||
} catch { return { stage: 'experimental', heavy: false } }
|
||||
const corner = meta?.widget?.corner
|
||||
return { stage: meta?.stage === 'production' ? 'production' : 'experimental', heavy: meta?.heavy === true,
|
||||
// "widget": { "corner": "bottom-right" } — a corner widget the Plugin Studio mounts once, fixed, not in a pane
|
||||
widget: ['bottom-right', 'bottom-left', 'top-right', 'top-left'].includes(corner) ? { corner } : null }
|
||||
} catch { return { stage: 'experimental', heavy: false, widget: null } }
|
||||
}
|
||||
|
||||
function metaIsMap(dir, profileName) {
|
||||
|
|
@ -1302,11 +1305,12 @@ function scanBundles() {
|
|||
? composesAMapLeaf(dir, profileName, file)
|
||||
: (metaIsMap(dir, profileName) ?? profileReachesAMap(dir, file))
|
||||
|
||||
const { stage: profileStage, heavy: profileHeavy } = metaStageHeavy(dir, profileName)
|
||||
const { stage: profileStage, heavy: profileHeavy, widget: profileWidget } = metaStageHeavy(dir, profileName)
|
||||
return {
|
||||
profile: profileName,
|
||||
stage: profileStage,
|
||||
...(profileHeavy ? { heavy: true } : {}),
|
||||
...(profileWidget ? { widget: profileWidget } : {}),
|
||||
file,
|
||||
description: description || fileTag || '',
|
||||
element: tag,
|
||||
|
|
|
|||
|
|
@ -915,6 +915,7 @@ export default defineComponent({
|
|||
const useModel = (m) => {
|
||||
this.manifestBundles = (m && m.bundles) || []
|
||||
this.rows = this.rowsFromManifest(m)
|
||||
this.setupWidgets()
|
||||
this.route()
|
||||
}
|
||||
const baked = (typeof window !== 'undefined' && window.__psBaked) || null
|
||||
|
|
@ -961,6 +962,9 @@ export default defineComponent({
|
|||
cap,
|
||||
icon: CAP_ICON[cap] || 'embed',
|
||||
description: p.description || '',
|
||||
// CORNER WIDGET (the Driver, BUILD-CORNER-WIDGET-CLASS): a profile whose .meta.json declares
|
||||
// "widget": { "corner": ... } is mounted once in a fixed corner of the Studio, not in a pane.
|
||||
widget: (p.widget && ['bottom-right', 'bottom-left', 'top-right', 'top-left'].includes(p.widget.corner)) ? p.widget.corner : '',
|
||||
heavy,
|
||||
bundleJs: b.scriptUrl || (b.bundle + '/' + b.bundle + '.js'),
|
||||
embed: (p.embed && p.embed.script) || '',
|
||||
|
|
@ -1017,6 +1021,10 @@ export default defineComponent({
|
|||
// guard would blank the content pane (flatRows has no matching entry to render) with the "Choose
|
||||
// an element" hint also gone — a real, if minor, regression a synthetic row must never cause.
|
||||
if (!this.rows.some(r => r.key === key)) return
|
||||
// A corner widget is never mounted in a pane (one live instance, in its corner): picking it in the
|
||||
// rail opens its corner instead.
|
||||
const wrow = this.rows.find(r => r.key === key)
|
||||
if (wrow && wrow.widget) { this.openWidget(wrow, true); return }
|
||||
const prevKey = this.activeKey
|
||||
this.activeKey = key
|
||||
this.embedOpen = false
|
||||
|
|
@ -1050,6 +1058,73 @@ export default defineComponent({
|
|||
row.state = 'idle'
|
||||
row.error = ''
|
||||
},
|
||||
// ── CORNER WIDGETS ────────────────────────────────────────────────────────────────────────────────
|
||||
// One fixed host per corner, created on document.body and kept in a PAGE-LEVEL singleton
|
||||
// (window.__doxaStudioWidgets), outside Vue's tree: a Studio hot update or remount finds the same host
|
||||
// and the same mounted element, so a widget like Rudder keeps its live state across HMR. Collapsed to a
|
||||
// button by default; the open state is remembered per widget.
|
||||
widgetStore() {
|
||||
if (!window.__doxaStudioWidgets) window.__doxaStudioWidgets = { hosts: {}, els: {} }
|
||||
return window.__doxaStudioWidgets
|
||||
},
|
||||
widgetHost(corner) {
|
||||
const W = this.widgetStore()
|
||||
if (W.hosts[corner] && W.hosts[corner].isConnected) return W.hosts[corner]
|
||||
const h = document.createElement('div')
|
||||
h.setAttribute('data-studio-widgets', corner)
|
||||
const [v, x] = corner.split('-')
|
||||
h.style.cssText = `position:fixed;${v}:12px;${x}:12px;z-index:2147483647;display:flex;flex-direction:column;align-items:${x === 'right' ? 'flex-end' : 'flex-start'};gap:8px;`
|
||||
document.body.appendChild(h)
|
||||
W.hosts[corner] = h
|
||||
return h
|
||||
},
|
||||
setupWidgets() {
|
||||
for (const row of this.rows.filter(r => r.widget)) {
|
||||
const W = this.widgetStore()
|
||||
const host = this.widgetHost(row.widget)
|
||||
let box = host.querySelector(`[data-widget="${row.key}"]`)
|
||||
if (!box) {
|
||||
box = document.createElement('div')
|
||||
box.setAttribute('data-widget', row.key)
|
||||
box.style.cssText = 'display:flex;flex-direction:column;align-items:inherit;gap:6px;'
|
||||
const panel = document.createElement('div')
|
||||
panel.setAttribute('data-widget-panel', '')
|
||||
panel.style.cssText = 'display:none;width:min(380px,calc(100vw - 24px));height:min(520px,calc(100vh - 80px));position:relative;background:#fff;border-radius:10px;box-shadow:0 8px 30px rgba(0,0,0,.25);overflow:hidden;'
|
||||
const btn = document.createElement('button')
|
||||
btn.type = 'button'
|
||||
btn.textContent = row.label
|
||||
btn.setAttribute('aria-expanded', 'false')
|
||||
btn.style.cssText = 'font:600 13px system-ui,sans-serif;padding:8px 14px;border-radius:999px;border:1px solid #30363d;background:#0d1117;color:#e6edf3;cursor:pointer;box-shadow:0 2px 10px rgba(0,0,0,.25);'
|
||||
btn.addEventListener('click', () => this.openWidget(row, panel.style.display === 'none'))
|
||||
box.appendChild(panel); box.appendChild(btn); host.appendChild(box)
|
||||
}
|
||||
if (W.els[row.key] && !box.querySelector(row.tag)) box.querySelector('[data-widget-panel]').appendChild(W.els[row.key])
|
||||
let open = false
|
||||
try { open = localStorage.getItem('plugin-studio:widget-open:' + row.key) === '1' } catch (_) {}
|
||||
if (open) this.openWidget(row, true)
|
||||
}
|
||||
},
|
||||
openWidget(row, open) {
|
||||
const host = this.widgetHost(row.widget)
|
||||
const box = host.querySelector(`[data-widget="${row.key}"]`)
|
||||
if (!box) { this.setupWidgets(); return }
|
||||
const panel = box.querySelector('[data-widget-panel]')
|
||||
const btn = box.querySelector('button')
|
||||
panel.style.display = open ? 'block' : 'none'
|
||||
btn.setAttribute('aria-expanded', open ? 'true' : 'false')
|
||||
try { localStorage.setItem('plugin-studio:widget-open:' + row.key, open ? '1' : '0') } catch (_) {}
|
||||
const W = this.widgetStore()
|
||||
if (!open || W.els[row.key]) return
|
||||
loadScriptOnce(this.resolve(row.bundleJs)).then(() => whenTagDefined(row.tag)).then(() => {
|
||||
if (W.els[row.key]) return
|
||||
const el = document.createElement(row.tag)
|
||||
el.setAttribute('profile-config', JSON.stringify(this.paneConfig(row, this.locale)))
|
||||
if (this.base) el.setAttribute('tree-base', this.base.replace(/\/+$/, ''))
|
||||
el.style.cssText = 'display:block;position:absolute;inset:0;width:100%;height:100%;'
|
||||
W.els[row.key] = el
|
||||
panel.appendChild(el)
|
||||
}).catch((e) => { panel.textContent = (e && e.message) || String(e) })
|
||||
},
|
||||
mountElement(row) {
|
||||
row.mounted = true
|
||||
row.state = 'loading'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"_comment": "Rudder — private, offline, streaming voice-to-text. DESKTOP target: the speech model runs in the local backend (backend/rudder-engine: NVIDIA NeMo-Speech.cpp + Nemotron 3.5 ASR GGUF), started by `bun run dev` and run by Tauri as a sidecar. Not for public web embeds: a website has no local engine.",
|
||||
"type": "single",
|
||||
"widget": { "corner": "bottom-right" },
|
||||
"label": "Rudder — voice to text (desktop)",
|
||||
"targets": ["desktop"],
|
||||
"backend": "backend/rudder-engine"
|
||||
|
|
|
|||
Loading…
Reference in a new issue