Skip to content

Instantly share code, notes, and snippets.

@seanconnelly34
Created May 27, 2024 18:35
Show Gist options
  • Save seanconnelly34/16b8066bf39710c8ea894cfc049db6e6 to your computer and use it in GitHub Desktop.
Save seanconnelly34/16b8066bf39710c8ea894cfc049db6e6 to your computer and use it in GitHub Desktop.
IframeScript.js
/**This script gets injected into the stencil template iframes on the edit page and
* is not processed as part of the app webpack build. This is why the file is self
* contained and does not use any imports. Due to the way create-react-app locks down
* the webpack settings, we were not able to modularize this file.
*/
const domLoaded = () => {
// variables
const __parentWindow = window.parent;
const __parentOrigin = window.origin;
const editableElements = document.querySelectorAll('[contenteditable]:not(.custom)');
const customElements = document.getElementsByClassName('custom');
const customTextElements = document.getElementsByClassName('custom-text');
const moveableElements = document.querySelectorAll('[data-customizable]');
const guidelineElements = Array.from(moveableElements).filter(
(node) => !node.dataset.customizable.includes('text')
);
const imageElements = Array.from(moveableElements).filter(
(node) => node.nodeName === 'IMG' && node.id !== 'propertyQrCode'
);
let newImgSrc = '';
let variableName = '';
let imgSources = imageElements.map((img) => ({ id: img.id, src: img.src }));
let iframeWidth = document.body.offsetWidth;
let iframeHeight = document.body.offsetHeight;
// store element location and mousedown timestamp to determine if user moved on click event
let elementLocation = {};
let mousedownTime = 0;
let lastClicked = null;
// store the initial zoom for moveable
let initialZoom = 1;
// make the custom text elements editable
Array.from(customTextElements).forEach((node) => (node.contentEditable = true));
// functions
const getAllEditableFieldsAsMergeVariables = function () {
let mergeVariables = [];
Array.prototype.forEach.call(editableElements, function (el) {
const name = el.id;
const page = `${window.name}`;
const value = el.nodeName === 'IMG' ? el.src : el.innerHTML;
mergeVariables.push({ name, page, value });
});
return mergeVariables;
};
const setAllEditableFieldsAsMergeVariables = function (mergeVariables) {
mergeVariables.forEach(function (item) {
const name = item.name;
const value = item.value;
const el = document.getElementById(name);
if (!el) return;
el.innerHTML = value;
});
};
const isMoveable = (element) => element.dataset?.customizable?.includes('move');
const isResizable = (element) => element.dataset?.customizable?.includes('resize');
const sendPostMessage = (data) => {
__parentWindow.postMessage(data, __parentOrigin);
};
const handleImgDrop = ({ target }, name) => {
// for some reason we don't know what the new image should be, don't change
if (!newImgSrc) return;
target.src = newImgSrc;
if (target.classList.contains('custom')) {
sendPostMessage({ type: 'updateCustomImage', id: target.id, src: newImgSrc, variableName });
} else {
sendPostMessage({
type: 'updateField',
name,
value: newImgSrc,
resetImages: 'true',
page: window.name,
});
}
let imgIndex = imgSources.findIndex((img) => img.id === target.id);
if (imgIndex !== -1) imgSources[imgIndex].src = newImgSrc;
target.style.opacity = '';
target.style.border = '';
resetEditing();
};
const handleDragEnter = ({ target }) => {
target.style.opacity = 1;
if (newImgSrc) target.src = newImgSrc;
target.style.border = 'solid 2px green';
};
const handleDragLeave = ({ target }) => {
target.style.opacity = '';
target.style.border = '';
target.src = imgSources.find((img) => img.id === target.id).src;
};
const setImagesSelectable = (isSelectable) => {
imageElements.forEach((img) => {
if (isSelectable) {
img.classList.add('can-select');
} else {
img.classList.remove('can-select');
}
});
};
const hideCallToAction = (hide) => {
let ctaElem = document.getElementById('_cta') || document.getElementById('cta');
if (ctaElem === null) return;
ctaElem.style.display = hide ? 'none' : 'initial';
};
const insertCallToAction = (cta) => {
let ctaElem = document.getElementById('_cta') || document.getElementById('cta');
if (ctaElem === null) return;
ctaElem.innerHTML = cta;
};
const updateAllFields = (newData) => {
newData.forEach((field) => {
let node = document.getElementById(field.name);
if (node?.nodeName === 'IMG') node.src = field.value;
else if (node) node.innerHTML = field.value;
});
};
const removeEditing = () => {
window.getSelection().removeAllRanges();
document.activeElement.blur();
document.querySelectorAll('[data-customizable]').forEach((el) => {
el.classList.remove('editing');
el.classList.remove('can-select');
});
};
/** Set the selected element and edit mode in live editor
* @param {Element} target - The element to be set
* @param {string} [mode=move] - The editor mode "move" or "text"
*/
const setSelectedElement = (target, mode = 'move') => {
if (mode === 'move') removeEditing();
editingElement = target;
if (!editingElement?.dataset?.customizable) {
return;
}
const compStyles = window.getComputedStyle(editingElement);
const canResize = !!isResizable(target);
// handle image select
const isImage = target.nodeName === 'IMG' && isMoveable(target);
const isShape = editingElement?.dataset?.customizable?.includes('shape');
if (isImage || isShape) {
setMoveableTarget(target);
moveable.resizable = canResize;
const currentStyles = {
opacity: compStyles.opacity,
objectFit: compStyles.objectFit,
objectPosition: compStyles.objectPosition,
fill: compStyles.fill,
stroke: compStyles.stroke,
strokeWidth: parseInt(compStyles.strokeWidth),
width: compStyles.width,
height: compStyles.height,
zIndex: Number.isNaN(parseInt(compStyles.zIndex)) ? 0 : parseInt(compStyles.zIndex),
};
sendPostMessage({
type: 'setEditing',
id: editingElement.id,
currentStyles,
editType: isImage ? 'image' : 'shape',
editMode: 'move',
canResize,
});
return;
}
// handle text select
if (editingElement?.dataset?.customizable?.includes('text')) {
if (mode === 'text' && editingElement.contentEditable) {
// set element to text mode
if (document.activeElement !== editingElement) {
editingElement.focus();
// set caret to end rather than front
document.execCommand('selectAll', false, null);
document.getSelection().collapseToEnd();
}
setMoveableTarget(null);
editingElement.classList.add('editing');
} else {
// set element to moveable mode
editingElement.blur();
setMoveableTarget(editingElement);
moveable.resizable = canResize;
}
const [fontSize, lineHeight, letterSpacing, zIndex] = [
{ prop: 'font-size', default: 16 },
{ prop: 'line-height', default: undefined },
{ prop: 'letter-spacing', default: 0 },
{ prop: 'z-index', default: 0 },
].map((item) => {
const val = parseInt(compStyles[item.prop].replace('px', ''));
return Number.isNaN(val) ? item.default : val;
});
const currentStyles = {
fontFamily: compStyles.fontFamily.replace(/"/g, ''),
fontSize,
lineHeight: lineHeight ?? fontSize * 1.2,
letterSpacing,
zIndex,
textAlign: compStyles.textAlign,
fontWeight: compStyles.fontWeight,
fontStyle: compStyles.fontStyle,
textDecoration: compStyles.textDecoration,
textTransform: compStyles.textTransform,
color: compStyles.color,
opacity: compStyles.opacity,
};
sendPostMessage({
type: 'setEditing',
id: editingElement.id,
currentStyles,
editType: 'text',
editMode: mode,
});
} else {
sendPostMessage({ type: 'setEditing', id: '' });
setMoveableTarget(null);
}
};
function htmlToElement(html) {
var template = document.createElement('template');
html = html.trim(); // Never return a text node of whitespace as the result
template.innerHTML = html;
return template.content.firstChild;
}
const setFieldValue = (data) => {
const { id, value } = data;
const el = document.getElementById(id);
if (el?.nodeName === 'IMG') el.src = value;
else if (el.contentEditable) el.innerHTML = value;
};
/** When adding a custom element to the iframe this function will return a promise
* once the element has a position of "absolute". This lets us know that the custom
* CSS has been applied before taking further actions.
* @param {HTMLElement} node - The node to check the CSS of
*/
const waitForNodeCSS = (node) => {
return new Promise((resolve) => {
const observer = new MutationObserver(() => {
const nodePosition = window.getComputedStyle(node).getPropertyValue('position');
if (nodePosition === 'absolute') {
resolve();
observer.disconnect();
}
});
observer.observe(document.getElementById('custom-styles'), {
childList: true,
subtree: true,
characterData: true,
});
document.body.appendChild(node);
});
};
/** Handle postMessage events from the parent window
* @param {Event} e - The postMessage event
*/
async function receiver(e) {
let root = document.documentElement;
const type = e.data?.type;
if (Array.isArray(e.data)) {
setAllEditableFieldsAsMergeVariables(e.data);
} else if (e.data === 'getAllEditableFieldsAsMergeVariables') {
sendPostMessage({ type: 'setFields', fields: getAllEditableFieldsAsMergeVariables() });
} else if (type === 'updateBrandColor') {
root.style.setProperty('--brand-color', e.data.value);
} else if (type === 'imageSelected') {
newImgSrc = e.data?.imgSrc;
variableName = e.data?.variableName;
setImagesSelectable(!!e.data?.imgSrc);
} else if (type === 'hideCTA') {
const { hideCTA } = e.data;
hideCallToAction(hideCTA);
} else if (type === 'cta') {
const { CTA } = e.data;
insertCallToAction(CTA);
} else if (type === 'updateAllFields') {
updateAllFields(e.data?.replaceFieldData);
} else if (type === 'setFieldValue') {
setFieldValue(e.data);
} else if (type === 'customStyles') {
let customStyles = document.getElementById('custom-styles');
if (customStyles) customStyles.innerHTML = e.data?.fullCssString;
} else if (type === 'removeTransform') {
const node = document.getElementById(e.data.id);
node.style.transform = '';
if (moveable.target) {
setTimeout(() => moveable.updateTarget(), 0);
} else {
setSelectedElement(node);
}
} else if (type === 'resetSelected') {
removeEditing();
if (moveable?.target) moveable.target = null;
} else if (type === 'addElement') {
// want iFrame to focus so that it is possible to move the newly added element ...
// ... without clicking on it first inside the document
window.focus();
const { content, id, src } = e.data;
const newNode = htmlToElement(content);
addEditListeners(newNode, true);
addClickListeners(newNode);
await waitForNodeCSS(newNode);
if (src) {
// adding an image node
const onLoad = () => {
setSelectedElement(newNode);
return newNode.removeEventListener('load', onLoad);
};
imgSources.push({ id, src });
imageElements.push(newNode);
newNode.addEventListener('load', onLoad);
} else {
// adding a text or shape node
moveable.elementGuidelines.push(newNode);
setSelectedElement(newNode);
}
} else if (type === 'resizeElement') {
const { id, dim, value } = e.data;
document.getElementById(id).style[dim] = value;
moveable.updateRect();
} else if (type === 'deleteElement') {
const { id } = e.data;
document.getElementById(id).remove();
moveable.target = null;
} else if (type === 'hideElement') {
const { id } = e.data;
document.getElementById(id).style.visibility = 'hidden';
moveable.target = null;
} else if (type === 'updateZoom') {
const zoom = 1 / e.data.zoomValue || 1;
initialZoom = zoom;
if (moveable) moveable.zoom = zoom;
} else {
// Type not recognized. Log the message.
console.log(JSON.stringify(e.data));
}
}
function preventDefault(e) {
e.preventDefault();
e.stopPropagation();
}
function loadError(oError) {
throw new URIError('The script ' + oError.target.src + " didn't load correctly.");
}
function prefixScript(url, onloadFunction) {
var newScript = document.createElement('script');
newScript.onerror = loadError;
if (onloadFunction) {
newScript.onload = onloadFunction;
}
document.currentScript.parentNode.insertBefore(newScript, document.currentScript);
newScript.src = url;
}
// the element currently being edited
let editingElement = null;
const onScreen = (node) => {
if (!node) return false;
const { top, bottom, right, left } = node.getBoundingClientRect();
const { clientWidth, clientHeight } = document.body;
if (top < 0 || left < 0 || right > clientWidth || bottom > clientHeight) return false;
return true;
};
const setRotateHandle = (target) => {
const rotateHandle = document.querySelector('.moveable-rotation-control');
if (!target || !rotateHandle) return;
moveable.rotationPosition = 'top';
if (onScreen(rotateHandle)) return;
moveable.rotationPosition = 'bottom';
if (onScreen(rotateHandle)) return;
moveable.rotationPosition = 'left';
if (onScreen(rotateHandle)) return;
moveable.rotationPosition = 'right';
if (onScreen(rotateHandle)) return;
moveable.rotationPosition = 'top-left';
if (onScreen(rotateHandle)) return;
moveable.rotationPosition = 'top-right';
if (onScreen(rotateHandle)) return;
moveable.rotationPosition = 'bottom-left';
if (onScreen(rotateHandle)) return;
moveable.rotationPosition = 'bottom-right';
};
// add the moveable instance
let moveable = null;
const setMoveableTarget = (node) => {
document.querySelectorAll('.moving').forEach((node) => node.classList.remove('moving'));
if (moveable) moveable.target = node;
setRotateHandle(node);
if (node) node.classList.add('moving');
};
// Add moveable library
prefixScript('//daybrush.com/moveable/release/0.24.5/dist/moveable.min.js', () => {
// eslint-disable-next-line no-undef
moveable = new Moveable(document.body, {
draggable: true,
rotatable: true,
origin: false,
throttleRotate: 1,
snappable: true,
snapVertical: true,
snapHorizontal: true,
snapElement: true,
snapCenter: true,
elementGuidelines: guidelineElements,
verticalGuidelines: [
0,
iframeWidth * 0.2,
iframeWidth * 0.4,
iframeWidth * 0.5,
iframeWidth * 0.6,
iframeWidth * 0.8,
iframeWidth,
],
horizontalGuidelines: [
0,
iframeHeight * 0.2,
iframeHeight * 0.4,
iframeHeight * 0.5,
iframeHeight * 0.6,
iframeHeight * 0.8,
iframeHeight,
],
snapThreshold: 5,
zoom: initialZoom,
});
moveable
.on('drag', ({ target, transform }) => {
target.style.transform = transform;
setRotateHandle(target);
})
.on('dragEnd', ({ target, lastEvent }) => {
if (lastEvent) {
sendPostMessage({
type: 'updateCSS',
id: target.id,
newData: { transform: lastEvent.transform },
});
}
})
.on('rotate', ({ target, transform }) => {
target.style.transform = transform;
})
.on('rotateEnd', ({ target, lastEvent }) => {
if (lastEvent) {
sendPostMessage({
type: 'updateCSS',
id: target.id,
newData: { transform: lastEvent.transform },
});
}
})
.on('resize', ({ target, inputEvent, width, height, drag: { transform } }) => {
moveable.keepRatio = inputEvent.shiftKey;
target.style.width = Math.max(width, 6) + 'px';
target.style.height = Math.max(height, 6) + 'px';
target.style.transform = transform;
})
.on('resizeEnd', ({ target, lastEvent }) => {
if (lastEvent) {
const {
width,
height,
drag: { transform },
} = lastEvent;
sendPostMessage({
type: 'updateCSS',
id: target.id,
newData: {
transform,
width: width + 'px',
height: height + 'px',
},
});
// update input dimensions
setSelectedElement(target, 'move');
}
});
});
// If the element was moved or the click lasted longer than 500ms assume the user intended a drag event
// do not set to text edit mode in these cases
function wasElementDragged(start, end, mouseupTime) {
return (
Math.abs(start.x - end.x) > 5 ||
Math.abs(start.y - end.y) > 5 ||
mouseupTime - mousedownTime > 500
);
}
function findAndSet(e) {
const { type, currentTarget } = e;
// only run focus event if tab action (not from user click)
if (type === 'focus' && currentTarget === lastClicked) {
lastClicked = null;
return;
}
const isDrag = wasElementDragged(
elementLocation,
currentTarget.getBoundingClientRect(),
Date.now()
);
const isEditing = currentTarget.classList.contains('editing');
// determine movable vs text mode on click
const editMode = (isDrag || currentTarget !== moveable?.target) && !isEditing ? 'move' : 'text';
setSelectedElement(currentTarget, editMode);
sendPostMessage({ elementClicked: true });
}
window.addEventListener('message', receiver, false);
// prevent drop events on elements that are not images
document.querySelectorAll('body :not(img)').forEach((node) => {
node.addEventListener('drop', (e) => {
e.preventDefault();
resetEditing();
});
});
const addClickListeners = (el) => {
el.addEventListener('mousedown', (e) => {
elementLocation = e.currentTarget.getBoundingClientRect();
mousedownTime = Date.now();
lastClicked = e.currentTarget;
});
el.addEventListener('click', findAndSet);
el.addEventListener('focus', findAndSet);
el.addEventListener('dblclick', findAndSet);
};
moveableElements.forEach((el) => {
addClickListeners(el);
});
const resetEditing = () => {
removeEditing();
if (moveable?.target) moveable.target = null;
sendPostMessage({ type: 'setEditing', id: '' });
};
const handleKeydown = (e) => {
const ARROW_KEYS = {
left: 'ArrowLeft',
down: 'ArrowDown',
right: 'ArrowRight',
up: 'ArrowUp',
};
const MODIFIER_SCALING = {
alt: 10,
shift: 5,
default: 1,
};
const { key, altKey, shiftKey, ctrlKey, metaKey, view } = e;
if (moveable.target && Object.values(ARROW_KEYS).includes(key)) {
e.preventDefault();
const scale = altKey
? MODIFIER_SCALING.alt
: shiftKey
? MODIFIER_SCALING.shift
: MODIFIER_SCALING.default;
const direction = [ARROW_KEYS.left, ARROW_KEYS.right].includes(key)
? 'horizontal'
: 'vertical';
const delta = ([ARROW_KEYS.left, ARROW_KEYS.down].includes(key) ? -1 : 1) * scale;
/**
* Define an edge property only if the ctrlKey (metaKey → Command on Mac) is used.
* Depending on the direction, the edge has either an 'x' or 'y' property ...
* ... indicating the absolute final position.
*
* @note all iFrames have the same widths and height → can use first
*/
const edge =
ctrlKey || metaKey
? direction === 'horizontal'
? { x: key === ARROW_KEYS.left ? 0 : view.innerWidth }
: { y: key === ARROW_KEYS.up ? 0 : view.innerHeight }
: undefined;
/**
* without ctrlKey, 'edge' is undefined and movement is relative to current position
* with ctrlKey, movement is absolute (towards the edges of the document)
*/
if (!edge) {
// top left is (0,0) so need to adjust delta for vertical to be negative
const deltaX = direction === 'horizontal' ? delta : 0;
const deltaY = direction === 'vertical' ? -1 * delta : 0;
moveable.request('draggable', { deltaX, deltaY }, true);
} else {
const { width, height } = moveable.getRect();
if (edge.x !== undefined) {
// for right edge, need to adjust by the width of the target to make sure it is fully visible
const visibleX = edge.x > 0 ? edge.x - width : edge.x;
moveable.request('draggable', { x: visibleX, deltaY: 0 }, true);
} else if (edge.y !== undefined) {
// for bottom edge, need to adjust by the height of the target to make sure it is fully visible
const visibleY = edge.y > 0 ? edge.y - height : edge.y;
moveable.request('draggable', { deltaX: 0, y: visibleY }, true);
}
}
} else {
const { activeElement } = document;
const { lastChild } = activeElement ?? {};
sendPostMessage({
type: 'keydown',
key,
modifiers: { ctrlKey, metaKey, shiftKey, altKey },
textEdit: activeElement.classList.contains('editing'),
imgSrc: lastChild.nodeName === 'IMG' ? lastChild.getAttribute('src') : undefined,
});
}
};
document.addEventListener('keydown', handleKeydown);
function strip(html) {
let doc = new DOMParser().parseFromString(html, 'text/html');
return doc.body.textContent || '';
}
function handlePaste(e) {
// Stop data actually being pasted into div
e.stopPropagation();
e.preventDefault();
// Get pasted data via clipboard API
const clipboardData = (e.clipboardData || window.clipboardData).getData('text');
const textNode = document.createTextNode(strip(clipboardData));
const selection = window.getSelection();
if (!selection.rangeCount) return false;
selection.deleteFromDocument();
const range = selection.getRangeAt(0);
range.insertNode(textNode);
// move cursor to end of selection
range.setStart(textNode, textNode.length);
range.setEnd(textNode, textNode.length);
selection.removeAllRanges();
selection.addRange(range);
}
const addEditListeners = (el) => {
const name = el.id;
const value = el.innerHTML;
const isCustom = el.classList.contains('custom');
let changed = false;
let newValue = null;
const listener = function () {
newValue = el.innerHTML;
if (value !== newValue) changed = true;
};
const notifier = function () {
if (!changed) return;
if (!__parentWindow) return;
if (!__parentOrigin) return;
if (isCustom) sendPostMessage({ type: 'updateCustomEdit', id: name, text: newValue });
else sendPostMessage({ type: 'updateField', name, value: newValue, page: window.name });
};
const updateAndNotify = () => {
listener();
notifier();
};
if (el.nodeName === 'IMG' && el.id !== 'propertyQrCode') {
el.addEventListener('dragstart', (e) => e.preventDefault());
el.addEventListener('drop', (e) => {
preventDefault(e);
handleImgDrop(e, name);
});
el.addEventListener('dragenter', (e) => {
preventDefault(e);
handleDragEnter(e);
});
el.addEventListener('dragover', (e) => e.preventDefault());
el.addEventListener('dragleave', handleDragLeave);
} else {
if (el.contentEditable) el.style.minWidth = '1rem';a
el.addEventListener('input', updateAndNotify);
el.addEventListener('blur', notifier);
el.addEventListener('keyup', updateAndNotify);
el.addEventListener('paste', handlePaste);
el.addEventListener('change', updateAndNotify);
el.addEventListener('copy', listener);
el.addEventListener('cut', updateAndNotify);
el.addEventListener('delete', updateAndNotify);
el.addEventListener('mouseup', listener);
}
};
Array.prototype.forEach.call(editableElements, function (el) {
addEditListeners(el);
});
Array.prototype.forEach.call(customElements, function (el) {
addEditListeners(el);
});
};
window.onload = domLoaded();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment