Packages

Server-side OG-image / social-card rendering, built on Fresco. A scene model (text, image, shape, button) with fills/gradients, {{slot}} templating, measured word-wrap, anchoring, and responsive sizing — rendered to SVG or PNG (via the optional resvg dependency).

Current section

Files

Jump to
open_fresco priv static open_fresco.js
Raw

priv/static/open_fresco.js

// OpenFresco editor hook — the browser half of the server-authoritative
// editor stage (see OpenFresco.Editor).
//
// Model: the server owns layout; the client owns the GESTURE. At
// pointerdown the hook hit-tests locally (the rendered SVG tags every
// top-level element with data-uuid), so select-then-drag is one gesture
// with no async race: the drag is seeded synchronously from the node
// under the pointer, previewed with a local transform, and committed as
// ONE operation on pointerup. Mid-gesture server patches may replace the
// SVG; updated() re-applies the live preview so the gesture never
// visually resets. pointercancel rolls back without committing.
//
// Coordinates convert through svg.getScreenCTM().inverse() — correct
// under CSS transforms, page zoom, and fractional DPR (a plain
// getBoundingClientRect ratio is not).
//
// Keyboard (stage focused): Tab / Shift+Tab cycle element selection,
// arrows nudge 1px (Shift = 10px), Delete/Backspace delete, Escape
// deselects, "]" bring-to-front, "[" send-to-back, Alt+] / Alt+[ step
// forward/backward.
//
// Spread window.OpenFrescoHooks into your LiveSocket hooks:
// hooks: { ...window.OpenFrescoHooks, ... }
(function () {
const DRAG_THRESHOLD = 1.5; // canvas px of motion before a drag commits
const OpenFrescoEditor = {
mounted() {
this.gesture = null;
this.onDown = (e) => this.pointerDown(e);
this.onMove = (e) => this.pointerMove(e);
this.onUp = (e) => this.pointerUp(e);
this.onCancel = (e) => this.pointerCancel(e);
this.onKeyDown = (e) => this.onKey(e);
this.el.addEventListener("pointerdown", this.onDown);
this.el.addEventListener("pointermove", this.onMove);
this.el.addEventListener("pointerup", this.onUp);
this.el.addEventListener("pointercancel", this.onCancel);
this.el.addEventListener("keydown", this.onKeyDown);
// Focusability comes from the template's tabindex="0" — setting it
// here would be stripped by the next LiveView patch.
},
destroyed() {
this.el.removeEventListener("pointerdown", this.onDown);
this.el.removeEventListener("pointermove", this.onMove);
this.el.removeEventListener("pointerup", this.onUp);
this.el.removeEventListener("pointercancel", this.onCancel);
this.el.removeEventListener("keydown", this.onKeyDown);
},
// A server patch replaced the SVG mid-gesture: re-apply the live
// preview to the fresh nodes so the element doesn't visually snap
// back while the pointer is still down.
updated() {
if (this.gesture) this.applyPreview(this.gesture.lastDx || 0, this.gesture.lastDy || 0);
},
svg() {
return this.el.querySelector("svg");
},
// Client px → canvas px through the SVG's current transform matrix.
toCanvas(clientX, clientY) {
const svg = this.svg();
if (!svg) return { x: 0, y: 0 };
const ctm = svg.getScreenCTM();
if (!ctm) return { x: 0, y: 0 };
const pt = new DOMPoint(clientX, clientY).matrixTransform(ctm.inverse());
return { x: pt.x, y: pt.y };
},
// The data-uuid element containing `target` (the server tags every
// top-level element group), or null.
elementAt(target) {
let node = target;
while (node && node !== this.el) {
if (node.dataset && node.dataset.uuid) return node.dataset.uuid;
node = node.parentNode;
}
return null;
},
// A resize handle (data-handle="0..3") at/above `target`, or null.
handleAt(target) {
let node = target;
while (node && node !== this.el) {
if (node.dataset && node.dataset.handle != null) return parseInt(node.dataset.handle, 10);
node = node.parentNode;
}
return null;
},
nodeFor(id) {
return id ? this.el.querySelector(`[data-uuid="${cssEscape(id)}"]`) : null;
},
selectionChromes() {
return Array.from(this.el.querySelectorAll("[data-selection]"));
},
selectedIds() {
const raw = this.el.dataset.selectedIds || "";
return raw ? raw.split(",").filter(Boolean) : [];
},
pointerDown(e) {
if (e.button != null && e.button !== 0) return;
this.el.focus({ preventScroll: true });
const pt = this.toCanvas(e.clientX, e.clientY);
const handle = this.handleAt(e.target);
if (handle != null && this.el.dataset.selected) {
// Resize gesture on the selected element's corner handle.
this.gesture = {
kind: "resize",
id: this.el.dataset.selected,
corner: handle,
startX: pt.x,
startY: pt.y,
moved: false,
};
} else {
// Synchronous local hit-test: the drag seeds from the node under
// the pointer in the SAME gesture — no waiting on a server
// round-trip (the old async select seeded from the PREVIOUS
// selection and dragged the wrong element).
const localId = this.elementAt(e.target);
if (localId && e.shiftKey) {
// Shift-click toggles set membership; no drag on this gesture.
this.pushEventTo(this.el, "editor:select_id", { id: localId, additive: true });
this.gesture = null;
} else if (localId) {
const set = this.selectedIds();
const inSet = set.indexOf(localId) !== -1;
if (!inSet && localId !== this.el.dataset.selected) {
// Keep the authoritative state in sync; the local value makes
// the gesture correct even before the server confirms.
this.el.dataset.selected = localId;
this.el.dataset.selectedIds = localId;
this.pushEventTo(this.el, "editor:select_id", { id: localId });
}
// Dragging a member of a multi-selection drags the whole set.
const ids = inSet && set.length > 1 ? set : [localId];
this.gesture = { kind: "drag", id: localId, ids, startX: pt.x, startY: pt.y, moved: false };
} else {
// Empty space: begin a marquee. If it never moves, pointerup
// degrades it to a plain click (server box hit-test decides).
this.gesture = {
kind: "marquee",
additive: !!e.shiftKey,
startX: pt.x,
startY: pt.y,
clientX: e.clientX,
clientY: e.clientY,
moved: false,
};
}
}
if (this.gesture && this.el.setPointerCapture) {
try {
this.el.setPointerCapture(e.pointerId);
} catch (_) {
/* capture is best-effort */
}
}
},
pointerMove(e) {
const g = this.gesture;
if (!g) return;
const pt = this.toCanvas(e.clientX, e.clientY);
const dx = pt.x - g.startX;
const dy = pt.y - g.startY;
if (Math.abs(dx) + Math.abs(dy) > DRAG_THRESHOLD) g.moved = true;
g.lastDx = dx;
g.lastDy = dy;
g.lastClientX = e.clientX;
g.lastClientY = e.clientY;
this.applyPreview(dx, dy);
},
applyPreview(dx, dy) {
const g = this.gesture;
if (!g) return;
if (g.kind === "drag") {
for (const id of g.ids || [g.id]) {
const node = this.nodeFor(id);
if (node) node.setAttribute("transform", `translate(${dx} ${dy})`);
}
for (const chrome of this.selectionChromes()) {
chrome.setAttribute("transform", `translate(${dx} ${dy})`);
}
} else if (g.kind === "marquee") {
this.drawMarquee(g);
} else if (g.kind === "resize") {
// Preview by reshaping the selection outline; the element itself
// re-lays-out server-side on commit (text wrap etc. can't be
// previewed with a transform without distortion).
const outline = this.el.querySelector("[data-selection-outline]");
if (!outline) return;
if (g.base == null) {
g.base = {
x: parseFloat(outline.getAttribute("x")),
y: parseFloat(outline.getAttribute("y")),
w: parseFloat(outline.getAttribute("width")),
h: parseFloat(outline.getAttribute("height")),
};
}
const b = resizedBox(g.base, g.corner, dx, dy);
outline.setAttribute("x", b.x);
outline.setAttribute("y", b.y);
outline.setAttribute("width", b.w);
outline.setAttribute("height", b.h);
}
},
clearPreview() {
const g = this.gesture;
if (!g) return;
for (const id of g.ids || (g.id ? [g.id] : [])) {
const node = this.nodeFor(id);
if (node) node.removeAttribute("transform");
}
for (const chrome of this.selectionChromes()) {
chrome.removeAttribute("transform");
}
this.removeMarquee();
},
// Client-side rubber-band rect (plain positioned div; cheap, outside
// the SVG so patches never touch it).
drawMarquee(g) {
if (!this.marqueeEl) {
const div = document.createElement("div");
div.style.cssText =
"position:absolute; border:1px dashed #2563eb; background:rgba(37,99,235,0.08); pointer-events:none; z-index:5;";
this.el.appendChild(div);
this.marqueeEl = div;
}
const host = this.el.getBoundingClientRect();
const x0 = Math.min(g.clientX, g.lastClientX) - host.left;
const y0 = Math.min(g.clientY, g.lastClientY) - host.top;
const w = Math.abs(g.lastClientX - g.clientX);
const h = Math.abs(g.lastClientY - g.clientY);
Object.assign(this.marqueeEl.style, {
left: x0 + "px",
top: y0 + "px",
width: w + "px",
height: h + "px",
});
},
removeMarquee() {
if (this.marqueeEl) {
this.marqueeEl.remove();
this.marqueeEl = null;
}
},
pointerUp(e) {
const g = this.gesture;
if (!g) return;
this.gesture = null;
const pt = this.toCanvas(e.clientX, e.clientY);
const dx = pt.x - g.startX;
const dy = pt.y - g.startY;
if (g.moved && g.kind === "drag") {
// One committed operation per gesture; the authoritative re-render
// replaces the preview transform with real geometry. (Dragging a
// multi-selection member commits ONE group move server-side.)
this.pushEventTo(this.el, "editor:move", { id: g.id, dx: dx, dy: dy });
} else if (g.moved && g.kind === "resize") {
this.pushEventTo(this.el, "editor:resize", {
id: g.id,
corner: g.corner,
dx: dx,
dy: dy,
});
} else if (g.kind === "marquee") {
this.gesture = g;
this.removeMarquee();
this.gesture = null;
if (g.moved) {
this.pushEventTo(this.el, "editor:select_box", {
x: Math.min(g.startX, pt.x),
y: Math.min(g.startY, pt.y),
w: Math.abs(dx),
h: Math.abs(dy),
additive: g.additive,
});
} else {
// Degenerate marquee = plain click on empty space.
this.pushEventTo(this.el, "editor:select", {
x: g.startX,
y: g.startY,
additive: g.additive,
});
}
return;
} else {
// A plain click — no geometry change; drop any stray preview state.
this.gesture = g;
this.clearPreview();
this.gesture = null;
}
},
pointerCancel(_e) {
// Rollback: remove the preview, commit nothing.
const g = this.gesture;
if (!g) return;
this.gesture = g;
this.clearPreview();
this.gesture = null;
},
elementIds() {
return Array.from(this.el.querySelectorAll("[data-uuid]")).map((n) => n.dataset.uuid);
},
onKey(e) {
const id = this.el.dataset.selected;
if (e.key === "Tab") {
const ids = this.elementIds();
if (ids.length === 0) return;
e.preventDefault();
const idx = ids.indexOf(id);
const step = e.shiftKey ? -1 : 1;
const next =
idx === -1
? e.shiftKey
? ids[ids.length - 1]
: ids[0]
: ids[(idx + step + ids.length) % ids.length];
this.el.dataset.selected = next;
this.pushEventTo(this.el, "editor:select_id", { id: next });
return;
}
if (e.key === "Escape") {
if (!id) return;
e.preventDefault();
this.el.dataset.selected = "";
this.pushEventTo(this.el, "editor:select_id", { id: "" });
return;
}
if (!id) return;
const nudge = e.shiftKey ? 10 : 1;
const arrows = {
ArrowLeft: [-nudge, 0],
ArrowRight: [nudge, 0],
ArrowUp: [0, -nudge],
ArrowDown: [0, nudge],
};
if (arrows[e.key]) {
e.preventDefault();
const [dx, dy] = arrows[e.key];
this.pushEventTo(this.el, "editor:move", { id, dx, dy });
} else if (e.key === "Delete" || e.key === "Backspace") {
e.preventDefault();
this.pushEventTo(this.el, "editor:delete", { id });
} else if (e.key === "]") {
e.preventDefault();
this.pushEventTo(this.el, e.altKey ? "editor:forward" : "editor:front", { id });
} else if (e.key === "[") {
e.preventDefault();
this.pushEventTo(this.el, e.altKey ? "editor:backward" : "editor:back", { id });
}
},
};
function resizedBox(b, corner, dx, dy) {
let { x, y, w, h } = b;
if (corner === 0) {
x += dx; y += dy; w -= dx; h -= dy;
} else if (corner === 1) {
y += dy; w += dx; h -= dy;
} else if (corner === 2) {
w += dx; h += dy;
} else if (corner === 3) {
x += dx; w -= dx; h += dy;
}
if (w < 0) { x += w; w = -w; }
if (h < 0) { y += h; h = -h; }
return { x, y, w: Math.max(1, w), h: Math.max(1, h) };
}
function cssEscape(s) {
return window.CSS && CSS.escape ? CSS.escape(s) : String(s).replace(/"/g, '\\"');
}
window.OpenFrescoHooks = Object.assign({}, window.OpenFrescoHooks, { OpenFrescoEditor });
})();