@sanity/visual-editing
Version:
[](https://npm-stat.com/charts.html?package=@sanity/visual-editing) [](https://
694 lines (615 loc) • 21.5 kB
text/typescript
import {v4 as uuid} from 'uuid'
import type {
ElementNode,
EventHandlers,
OverlayController,
OverlayElement,
OverlayOptions,
ResolvedElement,
} from './types'
import {handleOverlayDrag} from './util/dragAndDrop'
import {findOverlayElement, isElementNode} from './util/elements'
import {
findSanityNodes,
isSanityArrayPath,
isSanityNode,
resolveDragAndDropGroup,
} from './util/findSanityNodes'
import {getRect} from './util/geometry'
/**
* Creates a controller which dispatches overlay related events
*
* @param handler - Dispatched event handler
* @param overlayElement - Parent element containing rendered overlay elements
* @public
*/
export function createOverlayController({
handler,
overlayElement,
inFrame,
inPopUp,
optimisticActorReady,
}: OverlayOptions): OverlayController {
let activated = false
// Map for getting element by ID
const elementIdMap = new Map<string, ElementNode>()
// WeakMap for getting data by element
const elementsMap = new WeakMap<ElementNode, OverlayElement>()
// Set for iterating over elements
const elementSet = new Set<ElementNode>()
// Weakmap keyed by measureElement to find associated element
const measureElements = new WeakMap<ElementNode, ElementNode>()
// Weakmap for storing user set cursor styles per element
const cursorMap = new WeakMap<ElementNode, string | undefined>()
let ro: ResizeObserver
let io: IntersectionObserver | undefined
let mo: MutationObserver
let activeDragSequence = false
// The `hoverStack` is used as a container for tracking which elements are hovered at any time.
// The browser supports hovering multiple nested elements simultanously, but we only want to
// highlight the "outer most" element.
//
// This is how it works:
// - Whenever the mouse enters an element, we add it to the stack.
// - Whenever the mouse leaves an element, we remove it from the stack.
//
// When we want to know which element is currently hovered, we take the element at the top of the
// stack. Since JavaScript does not have a Stack type, we use an array and take the last element.
let hoverStack: Array<ElementNode> = []
const getHoveredElement = () => hoverStack[hoverStack.length - 1] as ElementNode | undefined
function addEventHandlers(el: ElementNode, handlers: EventHandlers) {
el.addEventListener('click', handlers.click as EventListener, {
capture: true,
})
el.addEventListener('contextmenu', handlers.contextmenu as EventListener, {
capture: true,
})
// We listen for the initial mousemove event, in case the overlay is enabled whilst the cursor is already over an element
// mouseenter and mouseleave listeners are attached within this handler
el.addEventListener('mousemove', handlers.mousemove as EventListener, {
once: true,
capture: true,
})
// Listen for mousedown in case we need to prevent default behavior
el.addEventListener('mousedown', handlers.mousedown as EventListener, {
capture: true,
})
}
function removeEventHandlers(el: ElementNode, handlers: EventHandlers) {
el.removeEventListener('click', handlers.click as EventListener, {
capture: true,
})
el.removeEventListener('contextmenu', handlers.contextmenu as EventListener, {
capture: true,
})
el.removeEventListener('mousemove', handlers.mousemove as EventListener, {
capture: true,
})
el.removeEventListener('mousedown', handlers.mousedown as EventListener, {
capture: true,
})
el.removeEventListener('mouseenter', handlers.mouseenter as EventListener)
el.removeEventListener('mouseleave', handlers.mouseleave as EventListener)
}
/**
* Executed when element enters the viewport
* Enables an element’s event handlers
*/
function activateElement({id, elements, handlers}: OverlayElement) {
const {element, measureElement} = elements
addEventHandlers(element, handlers)
ro.observe(measureElement)
handler({
type: 'element/activate',
id,
})
}
/**
* Executed when element leaves the viewport
* Disables an element’s event handlers
*/
function deactivateElement({id, elements, handlers}: OverlayElement) {
const {element, measureElement} = elements
removeEventHandlers(element, handlers)
ro.unobserve(measureElement)
// Scrolling from a hovered element will not trigger mouseleave event, so filter the stack
hoverStack = hoverStack.filter((el) => el !== element)
handler({
type: 'element/deactivate',
id,
})
}
function setOverlayCursor(element: ElementNode) {
// Don't set the cursor if mutations are unavailable
if ((!inFrame && !inPopUp) || !optimisticActorReady) return
// Loops through the entire hoverStack, trying to set the cursor if the
// stack element matches the element passed to the function, otherwise
// restoring the cursor
for (const hoverstackElement of hoverStack) {
if (element === hoverstackElement) {
const targetSanityData = elementsMap.get(element)?.sanity
if (!targetSanityData || !isSanityNode(targetSanityData)) return
const dragGroup = resolveDragAndDropGroup(
element,
targetSanityData,
elementSet,
elementsMap,
)
if (dragGroup) {
// Store any existing cursor so it can be restored later
const existingCursor = element.style.cursor
if (existingCursor) {
cursorMap.set(element, existingCursor)
}
handler({
type: 'overlay/setCursor',
element,
cursor: 'move',
})
continue
}
}
restoreOverlayCursor(hoverstackElement)
}
}
function restoreOverlayCursor(element: ElementNode) {
// Restore any previously stored cursor (if it exists)
const previousCursor = cursorMap.get(element)
handler({
type: 'overlay/setCursor',
element,
cursor: previousCursor,
})
}
/**
* Stores an element’s DOM node and decoded sanity data in state and sets up event handlers
*/
function registerElement({type, elements, commonSanity, targets}: ResolvedElement) {
const {element, measureElement} = elements
const eventHandlers: EventHandlers = {
click(event) {
const target = event.target as ElementNode | null
if (element === getHoveredElement() && element.contains(target)) {
// Click events are only supported supported in iframes, not well supported in popups
// @TODO presentation tool should report wether it's visible or not, so we can adapt properly and allow multi-window preview workflows
if (inFrame) {
event.preventDefault()
event.stopPropagation()
}
const sanity = elementsMap.get(element)?.sanity
if (sanity && !activeDragSequence) {
handler({
type: 'element/click',
id,
sanity,
})
}
}
},
contextmenu(event) {
if (!('path' in commonSanity!) || (!inFrame && !inPopUp) || !optimisticActorReady) return
// This is a temporary check as the context menu only supports array
// items (for now). We split the path into segments, if a `_key` exists
// in last path segment, we assume it's an array item, and so return
// early if it is some other type.
if (!commonSanity.path.split('.').pop()?.includes('[_key==')) return
const target = event.target as ElementNode | null
if (element === getHoveredElement() && element.contains(target)) {
// Context menus are supported on both iframes and popups
if (inFrame || inPopUp) {
event.preventDefault()
event.stopPropagation()
}
handler({
type: 'element/contextmenu',
id,
position: {
x: event.clientX,
y: event.clientY,
},
sanity: commonSanity,
})
}
},
mousedown(event) {
// prevent iframe from taking focus
event.preventDefault()
if (event.currentTarget !== hoverStack.at(-1)) return
if (element.getAttribute('data-sanity-drag-disable')) return
// disable dnd in non-studio contexts
if ((!inFrame && !inPopUp) || !optimisticActorReady) return
const targetSanityData = elementsMap.get(element)?.sanity
if (
!targetSanityData ||
!isSanityNode(targetSanityData) ||
!isSanityArrayPath(targetSanityData.path)
)
return
const dragGroup = resolveDragAndDropGroup(element, commonSanity!, elementSet, elementsMap)
if (!dragGroup) return
handleOverlayDrag({
element,
handler,
mouseEvent: event as MouseEvent,
overlayGroup: dragGroup,
target: targetSanityData,
onSequenceStart: () => {
activeDragSequence = true
},
onSequenceEnd: () => {
// delay drag sequence end to prevent click events from firing just after drag sequences
setTimeout(() => {
activeDragSequence = false
}, 250)
},
})
},
mousemove(event) {
eventHandlers.mouseenter(event)
const el = event.currentTarget as ElementNode | null
if (el) {
el.addEventListener('mouseenter', eventHandlers.mouseenter as EventListener)
el.addEventListener('mouseleave', eventHandlers.mouseleave as EventListener)
}
},
mouseenter() {
// If the Vercel Visual Editing provided by Vercel Toolbar is active, do not overlap overlays
if (
(document.querySelector('vercel-live-feedback') &&
element.closest('[data-vercel-edit-info]')) ||
element.closest('[data-vercel-edit-target]')
) {
return
}
hoverStack.push(element)
handler({
type: 'element/mouseenter',
id,
rect: getRect(element),
})
setOverlayCursor(element)
},
mouseleave(e) {
function leave() {
hoverStack.pop()
const hoveredElement = getHoveredElement()
handler({
type: 'element/mouseleave',
id,
})
if (hoveredElement) {
setOverlayCursor(hoveredElement)
const overlayElement = elementsMap.get(hoveredElement)
if (overlayElement) {
handler({
type: 'element/mouseenter',
id: overlayElement.id,
rect: getRect(hoveredElement),
})
}
}
restoreOverlayCursor(element)
}
/**
* If moving to an element within the overlay which handles pointer events, attach a new
* event handler to that element and defer the original leave event
*/
function addDeferredLeave(el: ElementNode) {
const deferredLeave = (e: MouseEvent) => {
const {relatedTarget} = e
const deferredContainer = findOverlayElement(relatedTarget)
if (!deferredContainer) {
el.removeEventListener('mouseleave', deferredLeave as EventListener)
leave()
} else if (relatedTarget && isElementNode(relatedTarget)) {
el.removeEventListener('mouseleave', deferredLeave as EventListener)
addDeferredLeave(relatedTarget)
}
}
el.addEventListener('mouseleave', deferredLeave as EventListener)
}
const {relatedTarget} = e as MouseEvent
const container = findOverlayElement(relatedTarget)
const isInteractiveOverlayElement = overlayElement.contains(container)
if (isElementNode(container) && isInteractiveOverlayElement) {
return addDeferredLeave(container)
}
leave()
},
}
const id = uuid()
const sanityNode = {
type,
id,
elements,
sanity: commonSanity,
handlers: eventHandlers,
}
elementSet.add(element)
measureElements.set(measureElement, element)
elementIdMap.set(id, element)
elementsMap.set(element, sanityNode)
io?.observe(element)
handler({
type: 'element/register',
elementType: type,
id,
element,
rect: getRect(element),
sanity: commonSanity!,
dragDisabled: !!element.getAttribute('data-sanity-drag-disable'),
targets: targets.map((target) => ({
sanity: target.sanity,
element: target.elements.element,
})),
})
if (activated) {
activateElement(sanityNode)
}
}
function updateElement(resolvedElement: ResolvedElement) {
const {element} = resolvedElement.elements
const overlayElement = elementsMap.get(element)
if (overlayElement) {
elementsMap.set(element, {...overlayElement, sanity: resolvedElement.commonSanity})
handler({
type: 'element/update',
elementType: overlayElement.type,
id: overlayElement.id,
rect: getRect(element),
sanity: resolvedElement.commonSanity!,
targets: resolvedElement.targets.map((target) => ({
sanity: target.sanity,
element: target.elements.element,
})),
})
}
}
function parseElements(node: ElementNode | {childNodes: ElementNode[]}) {
const sanityNodes = findSanityNodes(node)
for (const sanityNode of sanityNodes) {
if (sanityNode.type === 'group') {
for (const target of sanityNode.targets) {
// Any child target of a group should be unregistered if registered as an element
const overlayElement = elementsMap.get(target.elements.element)
if (overlayElement && overlayElement.type === 'element') {
unregisterElement(target.elements.element)
}
}
if (sanityNode.targets.length === 0) {
// Group was updated without children, unregister the element
unregisterElement(sanityNode.elements.element)
}
}
if (!sanityNode.commonSanity) continue
const {element} = sanityNode.elements
if (elementsMap.has(element)) {
updateElement(sanityNode)
} else {
registerElement(sanityNode)
}
}
}
function unregisterElement(element: ElementNode) {
const overlayElement = elementsMap.get(element)
if (overlayElement) {
const {id, handlers} = overlayElement
removeEventHandlers(element, handlers)
ro.unobserve(element)
elementsMap.delete(element)
elementSet.delete(element)
elementIdMap.delete(id)
handler({
type: 'element/unregister',
id,
})
}
}
function handleMutation(mutations: MutationRecord[]) {
let mutationWasInScope = false
// For each DOM mutation, we find the relevant element node and register or
// update it. This function doesn't handle checking if the node actually
// contains any relevant Sanity data, it just detects new or changed DOM
// elements and hands them off to `parseElements` to and determine if we
// have Sanity nodes
for (const mutation of mutations) {
const {target, type} = mutation
// We need to target an element, so if the mutated node was just a text
// change, we look at that node's parent instead
const node: Node | null = type === 'characterData' ? target.parentElement : target
// We ignore any nodes related to the overlay container element
if (node === overlayElement || overlayElement.contains(node)) {
continue
}
mutationWasInScope = true
if (isElementNode(node)) {
const possibleGroupParent = node.parentElement?.closest('[data-sanity-edit-group]') || null
const updateNodeTarget = isElementNode(possibleGroupParent) ? possibleGroupParent : node
parseElements({childNodes: [updateNodeTarget]})
}
}
// If the mutation is "in scope" (i.e. happened outside of the overlay
// container) we need to check if it removed any of the elements we are
// currently tracking
if (mutationWasInScope) {
for (const element of elementSet) {
if (!element.isConnected) {
unregisterElement(element)
}
// If the element was a group and is no longer a group, unregister it
const overlayElement = elementsMap.get(element)
if (overlayElement?.type === 'group' && !element.hasAttribute('data-sanity-edit-group')) {
unregisterElement(element)
}
}
}
}
function updateRect(el: ElementNode) {
const overlayElement = elementsMap.get(el)
if (overlayElement) {
handler({
type: 'element/updateRect',
id: overlayElement.id,
rect: getRect(el),
})
}
}
function handleResize(entries: ResizeObserverEntry[]) {
for (const entry of entries) {
const target = entry.target
if (isElementNode(target)) {
const element = measureElements.get(target)
if (!element) return
updateRect(element)
}
}
}
function handleIntersection(entries: IntersectionObserverEntry[]) {
if (!activated) return
for (const entry of entries) {
const {target} = entry
const match = isElementNode(target) && elementsMap.get(target)
if (!match) continue
if (entry.isIntersecting) {
activateElement(match)
} else {
deactivateElement(match)
}
}
}
function handleBlur(event: MouseEvent) {
const element = findOverlayElement(event.target)
if (element) {
if (element.dataset['sanityOverlayElement'] === 'capture') {
event.preventDefault()
event.stopPropagation()
}
return
}
hoverStack = []
handler({
type: 'overlay/blur',
})
}
function handleExclusivePluginClosed() {
hoverStack = []
handler({
type: 'overlay/reset-mouse-state',
})
}
function handleWindowResize() {
for (const element of elementSet) {
updateRect(element)
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
hoverStack = []
handler({
type: 'overlay/blur',
})
}
}
function handleWindowScroll(event: Event) {
const {target} = event
if (target === window.document || !isElementNode(target)) {
return
}
for (const element of elementSet) {
if (target.contains(element)) {
updateRect(element)
}
}
}
function activate() {
if (activated) return
io = new IntersectionObserver(handleIntersection, {
threshold: 0.3,
})
elementSet.forEach((element) => io!.observe(element))
handler({
type: 'overlay/activate',
})
activated = true
}
function deactivate() {
if (!activated) return
io?.disconnect()
elementSet.forEach((element) => {
const overlayElement = elementsMap.get(element)
if (overlayElement) {
deactivateElement(overlayElement)
}
})
handler({
type: 'overlay/deactivate',
})
activated = false
}
function handleHeaderClick(event: CustomEvent<{id: string}>) {
const {id} = event.detail
const element = elementIdMap.get(id)
if (!element) return
const sanity = elementsMap.get(element)?.sanity
if (!sanity) return
handler({
type: 'element/click',
id,
sanity,
})
}
function destroy() {
window.removeEventListener('click', handleBlur)
window.removeEventListener('contextmenu', handleBlur)
window.removeEventListener(
'sanity-overlay/exclusive-plugin-closed',
handleExclusivePluginClosed,
)
window.removeEventListener('sanity-overlay/label-click', handleHeaderClick as EventListener)
window.removeEventListener('keydown', handleKeydown)
window.removeEventListener('resize', handleWindowResize)
window.removeEventListener('scroll', handleWindowScroll)
mo.disconnect()
ro.disconnect()
elementSet.forEach((element) => {
unregisterElement(element)
})
elementIdMap.clear()
elementSet.clear()
hoverStack = []
deactivate()
}
function create() {
window.addEventListener('click', handleBlur)
window.addEventListener('contextmenu', handleBlur)
window.addEventListener('sanity-overlay/exclusive-plugin-closed', handleExclusivePluginClosed)
window.addEventListener('sanity-overlay/label-click', handleHeaderClick as EventListener)
window.addEventListener('keydown', handleKeydown)
window.addEventListener('resize', handleWindowResize)
window.addEventListener('scroll', handleWindowScroll, {
capture: true,
passive: true,
})
ro = new ResizeObserver(handleResize)
mo = new MutationObserver(handleMutation)
mo.observe(document.body, {
attributes: true,
characterData: true,
childList: true,
subtree: true,
})
parseElements(document.body)
activate()
}
window.document.fonts.ready.then(() => {
for (const element of elementSet) {
updateRect(element)
}
})
create()
return {
activate,
deactivate,
destroy,
}
}