use-theme-editor
Version:
Zero configuration CSS variables based theme editor
226 lines (193 loc) • 7.81 kB
JavaScript
import React, {createContext, useCallback, useLayoutEffect, useRef, useState} from 'react';
import { useInsertionEffect } from 'react';
import {useLocalStorage} from '../../hooks/useLocalStorage';
import { useResumableState } from '../../hooks/useResumableReducer';
export const AreasContext = createContext({});
const byHostAndOrder = ([, [hostIdA, orderA]], [, [hostIdB, orderB]]) => {
if (hostIdA === hostIdB) {
return orderA > orderB ? 1 : -1;
}
return hostIdA > hostIdB ? 1 : -1;
};
const updateElementLocation = (panelMap, id, targetAreaId, targetElementId) => {
if (!targetElementId) {
const otherOrders = Object.values(panelMap)
.filter(([area]) => area === targetAreaId)
.map(([, order]) => order);
const lastAreaOrder = Math.max(...otherOrders);
// Add behind last element in the area.
return {
...panelMap,
[id]: [targetAreaId, lastAreaOrder + 1]
};
}
const panelOrders = {};
return Object.entries(panelMap).sort(byHostAndOrder).reduce(
(
newPanelMap,
[otherElementId, [otherAreaId]],
) => {
if (!panelOrders[otherAreaId]) {
panelOrders[otherAreaId] = 0;
}
if (targetElementId === otherElementId && targetAreaId === otherAreaId) {
panelOrders[otherAreaId]++;
newPanelMap[id] = [targetAreaId, panelOrders[otherAreaId]];
}
if (otherElementId === id) {
return newPanelMap;
}
panelOrders[otherAreaId]++;
const newOtherOrder = panelOrders[otherAreaId];
return {
...newPanelMap,
[otherElementId]: [otherAreaId, newOtherOrder],
};
},
{},
);
};
// todo: use TS to define shape of default hooks and require it on argument hooks.
export const defaultHooks = {
showMovers() {
return useState(false);
},
drawerOpen() {
return useResumableState(false, 'drawer-open');
},
dragEnabled() {
return useLocalStorage('drag-on', true);
}
};
export function MovablePanels({stateHook, children, hooks = defaultHooks}) {
const areaRefs = useRef({});
const origLocationsRef = useRef({});
const [overElement, setOverElement] = useState(null);
const [overArea, setOverArea] = useState(null);
const [draggedElement, setDraggedElement] = useState(null);
const [panelMap, setPanelMap] = stateHook();
const [showMovers, setShowMovers] = hooks.showMovers();
const [drawerOpen, setDrawerOpen] = hooks.drawerOpen();
const [dragEnabled, setDragEnabled] = hooks.dragEnabled();
const movePanelTo = useCallback((id, areaId, overElementId) => {
setOverElement(null);
// Create initial area order if the area wasn't used before.
if (!Object.values(panelMap).some(([otherAreaId]) => otherAreaId === areaId)) {
let i = 0;
Object.entries(origLocationsRef.current).forEach(([element, area]) => {
if (area === areaId && !(element in panelMap)) {
panelMap[element] = [area, i];
i += 1;
}
});
}
const newPanelMap = updateElementLocation(panelMap, id, areaId, overElementId);
setPanelMap(newPanelMap);
}, [panelMap]);
// A 3 pass render is needed. Should not involve overhead.
// Contained elements won't get rendered more than once.
const [areasRendered, setAreasRendered] = useState(false);
const [elementsRendered, setElementsRendered] = useState(false);
useLayoutEffect(() => {
// After first pass all initial areas should have been rendered, allowing the second pass
// to render elements in places that didn't exist before.
if (!areasRendered) {
setAreasRendered(true);
return;
}
setElementsRendered(true);
}, [areasRendered]);
useInsertionEffect(() => {
// Reorder the elements according to their `order` property.
// Elements are assumed to be properly ordered by the panelmap with no duplicate indexes.
// Should work as React also doesn't care multiple elements are portaling to the same element.
//
// For now I keep the CSS order as it guarantees the elements are immediately "rendered" in the
// right place. It only affects the case where an inner layout effect would read calculated
// style, like the scroll offset.
if (!elementsRendered) {
return;
}
// console.time('Rectify order');
// Naive algorithm for moving elements to the right position.
// Performs poorly when moving down, it moves all other elements
// individually above the moved element.
// The impact of this (around 2ms for moving 20 places down) is still rather limited
// as it only occurs when moving an element across a large distance (and down).
// Moving up is always 1 operation.
// Moving between completely different arrangements is also accounted for.
for (const {current: areaEl} of Object.values(areaRefs.current)) {
// Order should be on all elements, or none if no element was moved into the area.
// Hence we only need to check the first.
if (areaEl.children[0]?.style.order === '') {
continue;
}
const prevOrderIndexes = [];
for (const el of areaEl.children) {
const order = parseInt(el.style.order);
let spliceIndex, spliceEl;
let index = 0;
for (const [prevOrder, prevEl] of prevOrderIndexes) {
if (order < prevOrder) {
spliceIndex = index;
spliceEl = prevEl;
break;
}
index++;
}
if (!spliceEl) {
prevOrderIndexes.push([order, el]);
} else {
prevOrderIndexes.splice(spliceIndex, 0, [order, el]);
areaEl.insertBefore(el, spliceEl);
const focusedEl = el.querySelector(':focus');
if (focusedEl) {
// If you drag an element downwards, it won't be moved itself, instead
// other elements will be moved before it.
// If you drag upwards, the element itself is moved,
// causing it to lose focus, unlike the downward direction.
// Hence, check for a focused element and give it focus again after moving.
// This can only happen when starting a drag from within a focusable element.
focusedEl.focus();
}
// console.log('Moving Element', el, 'before', spliceEl);
}
}
}
// console.timeEnd('Rectify order');
}, [JSON.stringify(panelMap), elementsRendered, drawerOpen]);
const resetPanels = () => {
setPanelMap({});
};
const timeoutRef = useRef({element: null, area: null});
// Have all initial areas been rendered?
// Trigger sync render, before which areaRefs should be fully populated by the first pass.
// This should incur no overhead, as all elements that don't need the
// second pass are immediately bailed out of by React.
useLayoutEffect(() => {
setAreasRendered(true);
}, []);
return <AreasContext.Provider value={{
areaRefs,
origLocationsRef,
panelMap, setPanelMap,
movePanelTo,
resetPanels,
showMovers, setShowMovers,
overElement, setOverElement,
overArea, setOverArea,
timeoutRef,
draggedElement, setDraggedElement,
dragEnabled, setDragEnabled,
drawerOpen, setDrawerOpen,
}}>
<div
className={'movable-container' + (draggedElement ? ' is-dragging' : '')}
>
{children}
</div>
</AreasContext.Provider>;
}
// Wait for some time before actually considering the drag leave event as
// having happened. Not sure why I did this really.
export const DRAG_LEAVE_TIMEOUT = 10;