mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
feat(designer): edit designer-made widgets in the designer (+ legacy reconstruction) (#207)
Designs round-trip for visual editing: store the design source in the widget config, reroute Edit to the designer, reconstruct legacy HTML-only designs, PUT the original in place.
This commit is contained in:
parent
397c4e1aec
commit
3efae1d2d6
|
|
@ -134,6 +134,7 @@ export const api = {
|
|||
|
||||
// Widgets
|
||||
getWidgets: () => request('/widgets'),
|
||||
getWidget: (id) => request('/widgets/' + id),
|
||||
|
||||
// Device Groups
|
||||
getGroups: () => request('/groups'),
|
||||
|
|
|
|||
|
|
@ -393,7 +393,7 @@ function route() {
|
|||
else if ((hash.startsWith('#/wall') || hash === '#/walls') && link.dataset.view === 'walls') link.classList.add('active');
|
||||
else if (hash === '#/reports' && link.dataset.view === 'reports') link.classList.add('active');
|
||||
else if (hash === '#/activity' && link.dataset.view === 'activity') link.classList.add('active');
|
||||
else if (hash === '#/designer' && link.dataset.view === 'designer') link.classList.add('active');
|
||||
else if ((hash === '#/designer' || hash.startsWith('#/designer/')) && link.dataset.view === 'designer') link.classList.add('active');
|
||||
else if ((hash === '#/kiosk' || hash.startsWith('#/kiosk/')) && link.dataset.view === 'kiosk') link.classList.add('active');
|
||||
else if (hash === '#/help' && link.dataset.view === 'help') link.classList.add('active');
|
||||
else if (hash.startsWith('#/device/') && link.dataset.view === 'dashboard') link.classList.add('active');
|
||||
|
|
@ -431,9 +431,11 @@ function route() {
|
|||
} else if (hash === '#/kiosk' || hash.startsWith('#/kiosk/')) {
|
||||
currentView = kiosk;
|
||||
kiosk.render(app);
|
||||
} else if (hash === '#/designer') {
|
||||
} else if (hash === '#/designer' || hash.startsWith('#/designer/')) {
|
||||
currentView = designer;
|
||||
designer.render(app);
|
||||
// #/designer/<widgetId> reopens a designer-made widget for editing; #/designer starts fresh.
|
||||
const wid = hash.startsWith('#/designer/') ? hash.split('#/designer/')[1].split('/')[0] : null;
|
||||
designer.render(app, wid || undefined);
|
||||
} else if (hash === '#/activity') {
|
||||
currentView = activity;
|
||||
activity.render(app);
|
||||
|
|
|
|||
|
|
@ -25,12 +25,18 @@ let bgValue = '#000000';
|
|||
let bgImageDataUrl = null;
|
||||
let dragging = null;
|
||||
let dragStart = null;
|
||||
// When editing an existing designer-made widget: its id + name, so Publish UPDATES it (PUT) instead of
|
||||
// creating a new one. Null = a fresh design (create).
|
||||
let editingWidgetId = null;
|
||||
let editingWidgetName = null;
|
||||
|
||||
export function render(container) {
|
||||
export function render(container, widgetId) {
|
||||
elements = [];
|
||||
selectedIdx = -1;
|
||||
bgValue = '#000000';
|
||||
bgImageDataUrl = null;
|
||||
editingWidgetId = null;
|
||||
editingWidgetName = null;
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="page-header">
|
||||
|
|
@ -197,18 +203,25 @@ export function render(container) {
|
|||
|
||||
document.getElementById('deleteEl').onclick = () => { if (selectedIdx >= 0) { elements.splice(selectedIdx, 1); selectedIdx = -1; redraw(); } };
|
||||
|
||||
// Publish as dynamic HTML content
|
||||
// Publish as a widget. The config carries the rendered HTML (what the player shows) AND the `design`
|
||||
// source (elements + background) so the widget can be reopened in THIS designer and edited visually
|
||||
// instead of dropping into the raw HTML editor. Editing an existing designer widget PUTs it in place.
|
||||
document.getElementById('publishBtn').onclick = async () => {
|
||||
try {
|
||||
const html = generateHTML();
|
||||
const blob = new Blob([html], { type: 'text/html' });
|
||||
const file = new File([blob], `design-${Date.now()}.html`, { type: 'text/html' });
|
||||
// Upload as a widget instead - create a text widget with the HTML
|
||||
const res = await fetch('/api/widgets', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}` },
|
||||
body: JSON.stringify({ widget_type: 'text', name: t('designer.widget_name', { date: new Date().toLocaleDateString() }), config: { html: generateInnerHTML(), css: '', background: bgValue } })
|
||||
});
|
||||
const config = { html: generateInnerHTML(), css: '', background: bgValue, design: { elements, bgValue, bgImage: bgImageDataUrl } };
|
||||
const auth = { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}` };
|
||||
// The URL is the source of truth for WHICH widget is being edited (#/designer/<id>). route() can
|
||||
// re-fire and momentarily reset editingWidgetId, so fall back to the hash — otherwise a save races
|
||||
// to POST a duplicate instead of updating the original.
|
||||
const targetId = editingWidgetId || (location.hash.match(/#\/designer\/([^/?]+)/) || [])[1] || null;
|
||||
let res;
|
||||
if (targetId) {
|
||||
const body = { config };
|
||||
if (editingWidgetName) body.name = editingWidgetName; // preserve the name; server keeps it if omitted
|
||||
res = await fetch('/api/widgets/' + targetId, { method: 'PUT', headers: auth, body: JSON.stringify(body) });
|
||||
} else {
|
||||
res = await fetch('/api/widgets', { method: 'POST', headers: auth, body: JSON.stringify({ widget_type: 'text', name: t('designer.widget_name', { date: new Date().toLocaleDateString() }), config }) });
|
||||
}
|
||||
if (res.ok) showToast(t('designer.toast.published'), 'success');
|
||||
else showToast(t('designer.toast.publish_failed'), 'error');
|
||||
} catch (err) { showToast(err.message, 'error'); }
|
||||
|
|
@ -310,6 +323,87 @@ export function render(container) {
|
|||
preview.onmouseup = () => { dragging = null; dragStart = null; };
|
||||
|
||||
redraw();
|
||||
if (widgetId) loadWidgetForEdit(widgetId);
|
||||
}
|
||||
|
||||
// Reopen a designer-made widget for visual editing: pull its stored `design` source back into the
|
||||
// canvas and remember its id/name so Publish updates it in place. Widgets without a design source
|
||||
// (e.g. hand-written text widgets) simply don't route here.
|
||||
async function loadWidgetForEdit(widgetId) {
|
||||
try {
|
||||
const w = await api.getWidget(widgetId);
|
||||
const cfg = typeof w.config === 'string' ? JSON.parse(w.config || '{}') : (w.config || {});
|
||||
if (cfg.design && Array.isArray(cfg.design.elements)) {
|
||||
// Preferred: the exact design source saved with the widget.
|
||||
elements = cfg.design.elements;
|
||||
bgValue = cfg.design.bgValue || '#000000';
|
||||
bgImageDataUrl = cfg.design.bgImage || null;
|
||||
} else if (typeof cfg.html === 'string' && cfg.html) {
|
||||
// Legacy widget (published before the design source was stored): best-effort reconstruct the
|
||||
// editable elements from the deterministic HTML the designer produced.
|
||||
const r = reconstructDesign(cfg.html);
|
||||
elements = r.elements;
|
||||
bgValue = cfg.background || '#000000';
|
||||
bgImageDataUrl = r.bgImage || null;
|
||||
} else {
|
||||
return; // nothing to rehydrate
|
||||
}
|
||||
editingWidgetId = w.id;
|
||||
editingWidgetName = w.name;
|
||||
selectedIdx = -1;
|
||||
const pub = document.getElementById('publishBtn');
|
||||
if (pub) pub.textContent = t('common.save'); // updating an existing widget, not publishing a new one
|
||||
redraw();
|
||||
showToast(t('designer.toast.loaded'), 'success');
|
||||
} catch (e) { showToast((e && e.message) || t('designer.toast.invalid_file'), 'error'); }
|
||||
}
|
||||
|
||||
// Best-effort reverse of generateInnerHTML() for LEGACY widgets that stored only the rendered HTML.
|
||||
// The designer's output is deterministic (positioned elements + a known <script> per dynamic type), so
|
||||
// it round-trips cleanly for designer-made content; unrecognized nodes are skipped. Returns { elements,
|
||||
// bgImage }.
|
||||
function reconstructDesign(html) {
|
||||
const out = { elements: [], bgImage: null };
|
||||
let root;
|
||||
try { root = new DOMParser().parseFromString(`<div id="__r">${html}</div>`, 'text/html').getElementById('__r'); }
|
||||
catch (e) { return out; }
|
||||
if (!root) return out;
|
||||
const rgbToHex = (c) => {
|
||||
if (!c) return '#FFFFFF';
|
||||
if (c[0] === '#') return c;
|
||||
const m = c.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i);
|
||||
return m ? '#' + [1, 2, 3].map((i) => (+m[i]).toString(16).padStart(2, '0')).join('') : '#FFFFFF';
|
||||
};
|
||||
const pct = (v) => parseFloat(v) || 0;
|
||||
const fontVw = (el) => { const f = parseFloat(el.style.fontSize); return isFinite(f) ? Math.round(f * 10) : 36; };
|
||||
const scriptFor = (id) => { for (const s of root.querySelectorAll('script')) if ((s.textContent || '').includes(`'${id}'`)) return s.textContent || ''; return ''; };
|
||||
for (const node of Array.from(root.children)) {
|
||||
const tag = node.tagName.toLowerCase();
|
||||
if (tag === 'script' || tag === 'style') continue;
|
||||
const st = node.style;
|
||||
const x = pct(st.left), y = pct(st.top);
|
||||
if (tag === 'img') {
|
||||
if (!st.left && (st.inset || st.objectFit === 'cover')) { out.bgImage = node.getAttribute('src'); continue; } // baked background
|
||||
out.elements.push({ type: 'image', x, y, width: pct(st.width), height: pct(st.height), src: node.getAttribute('src') });
|
||||
} else if (tag === 'video') {
|
||||
out.elements.push({ type: 'video', x, y, width: pct(st.width), height: pct(st.height), src: node.getAttribute('src'), muted: node.hasAttribute('muted'), loop: node.hasAttribute('loop') });
|
||||
} else if (tag === 'iframe') {
|
||||
out.elements.push({ type: 'webpage', x, y, width: pct(st.width), height: pct(st.height), url: node.getAttribute('src') });
|
||||
} else if (tag === 'div') {
|
||||
const id = node.id || '';
|
||||
const base = { x, y, fontSize: fontVw(node), color: rgbToHex(st.color), fontFamily: st.fontFamily || 'Arial' };
|
||||
const cd = node.querySelector('[id^="cd"]'); // countdown: inner cdN div
|
||||
const tk = node.querySelector('[id^="t"]'); // ticker: inner tN div
|
||||
if (/^c\d+$/.test(id)) { const sc = scriptFor(id); out.elements.push({ ...base, bold: true, type: 'clock', showSeconds: /second:'2-digit'/.test(sc), format: /hour12:false/.test(sc) ? '24h' : '12h' }); }
|
||||
else if (/^d\d+$/.test(id)) { out.elements.push({ ...base, type: 'date' }); }
|
||||
else if (/^w\d+$/.test(id)) { const sc = scriptFor(id); const loc = (sc.match(/wttr\.in\/([^?]+)\?/) || [])[1]; out.elements.push({ ...base, type: 'weather', location: loc ? decodeURIComponent(loc) : '', units: /temp_C/.test(sc) ? 'metric' : 'imperial' }); }
|
||||
else if (cd) { const sc = scriptFor(cd.id); const lbl = node.querySelector('div'); out.elements.push({ ...base, type: 'countdown', label: lbl ? lbl.textContent : '', targetDate: (sc.match(/new Date\('([^']+)'\)/) || [])[1] || '' }); }
|
||||
else if (tk && (st.overflow === 'hidden' || st.display === 'flex')) { const sc = scriptFor(tk.id); const feed = (sc.match(/rss_url=([^']+)'/) || [])[1]; out.elements.push({ type: 'ticker', x, y, width: pct(st.width), height: pct(st.height), bgColor: rgbToHex(st.background || st.backgroundColor), color: rgbToHex(tk.style.color), feedUrl: feed ? decodeURIComponent(feed) : '', speed: parseFloat((tk.style.animation || '').match(/([\d.]+)s/)?.[1]) || 30, fontSize: fontVw(tk) }); }
|
||||
else if ((st.background || st.backgroundColor) && st.width && st.height && !node.textContent.trim()) { const circle = st.borderRadius === '50%'; out.elements.push({ type: 'shape', x, y, width: pct(st.width), height: pct(st.height), color: rgbToHex(st.background || st.backgroundColor), opacity: parseFloat(st.opacity) || 1, shape: circle ? 'circle' : 'rect', radius: circle ? 0 : (parseInt(st.borderRadius) || 0) }); }
|
||||
else { out.elements.push({ ...base, type: 'text', text: node.textContent, bold: st.fontWeight === 'bold' || st.fontWeight === '700', shadow: !!st.textShadow }); }
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function addElement(el) {
|
||||
|
|
|
|||
|
|
@ -1028,9 +1028,19 @@ export async function render(container) {
|
|||
if (editBtn) {
|
||||
const w = widgets.find(x => x.id === editBtn.dataset.editWidget);
|
||||
if (w) {
|
||||
const config = JSON.parse(w.config || '{}');
|
||||
// Reopen designer-made widgets IN the designer for visual editing instead of the raw HTML form.
|
||||
// New designs carry a `design` source; legacy ones (HTML only) are detected by the designer's
|
||||
// signature output (every element is absolutely positioned) — the designer reconstructs their
|
||||
// elements from the HTML. Hand-written HTML text widgets lack the pattern and keep the editor.
|
||||
const designerMade = (config.design && Array.isArray(config.design.elements))
|
||||
|| (w.widget_type === 'text' && typeof config.html === 'string' && /position:absolute;left:/.test(config.html));
|
||||
if (designerMade) {
|
||||
window.location.hash = '#/designer/' + w.id;
|
||||
return;
|
||||
}
|
||||
editingWidget = w;
|
||||
creatingType = w.widget_type;
|
||||
const config = JSON.parse(w.config || '{}');
|
||||
config._name = w.name;
|
||||
showConfigForm(w.widget_type, config);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue