mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-15 23:03:14 -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
|
// Widgets
|
||||||
getWidgets: () => request('/widgets'),
|
getWidgets: () => request('/widgets'),
|
||||||
|
getWidget: (id) => request('/widgets/' + id),
|
||||||
|
|
||||||
// Device Groups
|
// Device Groups
|
||||||
getGroups: () => request('/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.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 === '#/reports' && link.dataset.view === 'reports') link.classList.add('active');
|
||||||
else if (hash === '#/activity' && link.dataset.view === 'activity') 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 === '#/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 === '#/help' && link.dataset.view === 'help') link.classList.add('active');
|
||||||
else if (hash.startsWith('#/device/') && link.dataset.view === 'dashboard') 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/')) {
|
} else if (hash === '#/kiosk' || hash.startsWith('#/kiosk/')) {
|
||||||
currentView = kiosk;
|
currentView = kiosk;
|
||||||
kiosk.render(app);
|
kiosk.render(app);
|
||||||
} else if (hash === '#/designer') {
|
} else if (hash === '#/designer' || hash.startsWith('#/designer/')) {
|
||||||
currentView = 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') {
|
} else if (hash === '#/activity') {
|
||||||
currentView = activity;
|
currentView = activity;
|
||||||
activity.render(app);
|
activity.render(app);
|
||||||
|
|
|
||||||
|
|
@ -25,12 +25,18 @@ let bgValue = '#000000';
|
||||||
let bgImageDataUrl = null;
|
let bgImageDataUrl = null;
|
||||||
let dragging = null;
|
let dragging = null;
|
||||||
let dragStart = 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 = [];
|
elements = [];
|
||||||
selectedIdx = -1;
|
selectedIdx = -1;
|
||||||
bgValue = '#000000';
|
bgValue = '#000000';
|
||||||
bgImageDataUrl = null;
|
bgImageDataUrl = null;
|
||||||
|
editingWidgetId = null;
|
||||||
|
editingWidgetName = null;
|
||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="page-header">
|
<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(); } };
|
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 () => {
|
document.getElementById('publishBtn').onclick = async () => {
|
||||||
try {
|
try {
|
||||||
const html = generateHTML();
|
const config = { html: generateInnerHTML(), css: '', background: bgValue, design: { elements, bgValue, bgImage: bgImageDataUrl } };
|
||||||
const blob = new Blob([html], { type: 'text/html' });
|
const auth = { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}` };
|
||||||
const file = new File([blob], `design-${Date.now()}.html`, { type: 'text/html' });
|
// The URL is the source of truth for WHICH widget is being edited (#/designer/<id>). route() can
|
||||||
// Upload as a widget instead - create a text widget with the HTML
|
// re-fire and momentarily reset editingWidgetId, so fall back to the hash — otherwise a save races
|
||||||
const res = await fetch('/api/widgets', {
|
// to POST a duplicate instead of updating the original.
|
||||||
method: 'POST',
|
const targetId = editingWidgetId || (location.hash.match(/#\/designer\/([^/?]+)/) || [])[1] || null;
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}` },
|
let res;
|
||||||
body: JSON.stringify({ widget_type: 'text', name: t('designer.widget_name', { date: new Date().toLocaleDateString() }), config: { html: generateInnerHTML(), css: '', background: bgValue } })
|
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');
|
if (res.ok) showToast(t('designer.toast.published'), 'success');
|
||||||
else showToast(t('designer.toast.publish_failed'), 'error');
|
else showToast(t('designer.toast.publish_failed'), 'error');
|
||||||
} catch (err) { showToast(err.message, 'error'); }
|
} catch (err) { showToast(err.message, 'error'); }
|
||||||
|
|
@ -310,6 +323,87 @@ export function render(container) {
|
||||||
preview.onmouseup = () => { dragging = null; dragStart = null; };
|
preview.onmouseup = () => { dragging = null; dragStart = null; };
|
||||||
|
|
||||||
redraw();
|
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) {
|
function addElement(el) {
|
||||||
|
|
|
||||||
|
|
@ -1028,9 +1028,19 @@ export async function render(container) {
|
||||||
if (editBtn) {
|
if (editBtn) {
|
||||||
const w = widgets.find(x => x.id === editBtn.dataset.editWidget);
|
const w = widgets.find(x => x.id === editBtn.dataset.editWidget);
|
||||||
if (w) {
|
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;
|
editingWidget = w;
|
||||||
creatingType = w.widget_type;
|
creatingType = w.widget_type;
|
||||||
const config = JSON.parse(w.config || '{}');
|
|
||||||
config._name = w.name;
|
config._name = w.name;
|
||||||
showConfigForm(w.widget_type, config);
|
showConfigForm(w.widget_type, config);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue