use-theme-editor
Version:
Zero configuration CSS variables based theme editor
463 lines (417 loc) • 13.5 kB
JavaScript
import React, {
createContext,
useLayoutEffect,
useMemo,
useState,
useSyncExternalStore,
} from 'react';
import { hotkeysOptions } from '../components/Hotkeys';
import { useHotkeys } from 'react-hotkeys-hook';
export const HistoryNavigateContext = createContext({});
const INITIAL_STATE = {
currentId: null,
lastActions: {
HISTORY: {
type: 'INIT',
payload: {},
},
},
lastSet: 0,
historyStack: [],
historyOffset: 0,
historyWarnOnUpdateLimit: 5,
states: {},
oldStates: {},
};
// The following are also "state", however the code strictly considers them append only.
// Keys are expected to live indefinitely and re-assignment currently doesn't happen.
const initialStates = {};
const reducers = {};
const dispatchers = {};
const subscribers = {};
const getSnapshots = {};
function addReducer(id, reducer, initialState, initializer) {
reducers[id] = reducer;
initialStates[id] =
typeof initializer === 'function'
? initializer(initialState)
: initialState;
dispatchers[id] = (action, options = {}) => {
historyDispatch(
{
type: 'PERFORM_ACTION',
payload: { id, action },
},
options
);
};
subscribers[id] = (notify) => {
if (!(id in notifiers)) {
notifiers[id] = new Set();
}
notifiers[id].add(notify);
return () => {
notifiers[id].delete(notify);
if (notifiers[id].size === 0) {
delete notifiers[id];
}
};
}
getSnapshots[id] = () => {
return currentStates.hasOwnProperty(id) ? currentStates[id] : initialStates[id];
}
}
function historyReducer(state, action, options) {
const { states, historyStack, historyOffset } = state;
const {
payload: { id, amount = 1 } = {},
} = action;
switch (action.type) {
case 'HISTORY_BACKWARD': {
const oldIndex = historyStack.length - historyOffset;
if (oldIndex < 1) {
return state;
}
const oldStates =
historyOffset === 0
? states
: historyStack[oldIndex].states;
return {
...state,
oldStates,
historyOffset: historyOffset + amount,
};
}
case 'HISTORY_FORWARD': {
if (historyOffset === 0) {
return state;
}
const newOffset = Math.max(0, historyOffset - amount);
return {
...state,
historyOffset: newOffset,
oldStates: historyStack[historyStack.length - historyOffset].states,
};
}
case 'CLEAR_HISTORY': {
const currentlyInThePast = historyOffset > 0;
const baseStates = !currentlyInThePast ? states : historyStack[historyStack.length - historyOffset].states;
const lastActions = !currentlyInThePast ? state.lastActions : historyStack[historyStack.length - historyOffset].lastActions;
return {
...state,
historyStack: [],
historyOffset: 0,
states: baseStates,
lastActions,
};
}
case 'PERFORM_ACTION': {
const forwardedReducer = reducers[id];
if (!forwardedReducer) {
return state;
}
if (state.historyOffset > state.historyWarnOnUpdateLimit) {
if (!window.confirm('You are about to erase the future, this is your last chance to reconsider.')) {
return state;
}
}
// `currentlyInThePast` should be very infrequent case: one edit on the past clears future.
// Mmost actions happen against the latest state.
// Hence I think the current approach of treating the latest state as a separate
// object has better overall performance characteristics,
// compared to just using the last entry of the history as the latest state.
// Especially if the changes are fluid and history is skipped every time.
// It's hard to validate this assumption, though, because this runs really fast even
// with hundreds of history entries.
const currentlyInThePast = historyOffset > 0;
const baseIndex = historyStack.length - historyOffset;
const historyEntry = !currentlyInThePast ? state : historyStack[baseIndex];
const {states: baseStates, lastActions} = historyEntry;
const performedAction = action.payload.action;
const baseState = id in baseStates ? baseStates[id] : initialStates[id];
const newState = forwardedReducer(
baseState,
// Action can be a function in case of setState.
typeof performedAction === 'function ' ? performedAction(baseState) : performedAction
);
// const isNowDefaultState = newState === initialStates[id];
// const previousAlsoDefaultState = isNowDefaultState && baseIndex && !(id in historyStack[baseIndex - 1].states);
// const {[id]: _, ...otherStates} = !isNowDefaultState ? {} : baseStates;
const now = performance.now();
const slowEnough =
!state.lastSet || now - state.lastSet > 500;
const skipHistory = !slowEnough || options?.skipHistory;
const skippedHistoryNowSameAsPrevious =
skipHistory && historyStack[baseIndex - 1]?.states[id] === newState;
// Uses || skipHistory to take the cheapest path when prevHistory is not used.
const prevHistory =
!currentlyInThePast || skipHistory
? historyStack
: historyStack.slice(0, -historyOffset);
return {
...state,
states: {
...baseStates,
[id]: newState,
},
oldStates: baseStates,
historyOffset: 0,
historyStack: skipHistory
? skippedHistoryNowSameAsPrevious
? historyStack.slice(0, -1)
: historyStack
: [
...prevHistory,
{
states: baseStates,
lastActions,
// alternateFutures: [],
},
],
currentId: id,
// If the previous state was removed from the history because it was duplicate,
// it should result in a new entry in any subsequent dispatches to the same id.
// Otherwise, it would be possible to remove multiple recent entries just by
// having the same value for any short amount of time.
lastSet: skippedHistoryNowSameAsPrevious ? null : now,
lastActions: !skipHistory
? { [id]: performedAction }
: { ...state.lastActions, [id]: performedAction },
};
}
}
return state;
}
let state = INITIAL_STATE;
let currentStates = state.states;
let forceHistoryRender = () => {};
const notifiers = {};
// const USE_BROWSER_HISTORY = false;
// Notify one ID without checking.
function notifyOne(id) {
const keyNotifiers = notifiers[id];
if (!keyNotifiers) {
return;
}
for (const n of keyNotifiers.values()) {
n();
}
forceHistoryRender();
}
function checkNotifyAll() {
const { oldStates } = state;
// const neither = [];
// const added = [];
// const removed = [];
// const diff = [];
// const same = [];
const bothKeys = new Set([...Object.keys(oldStates), ...Object.keys(currentStates)]);
for (const id of bothKeys.values()) {
const keyNotifiers = notifiers[id];
if (!keyNotifiers) {
continue;
}
// For this to work it's important that unchanged state members
// are the same object referentially.
const inOld = oldStates.hasOwnProperty(id), inNew = currentStates.hasOwnProperty(id);
const oldValue = !inOld ? initialStates[id] : oldStates[id] ;
const newValue = !inNew ? initialStates[id] : currentStates[id];
const changed = oldValue !== newValue;
// if (!inOld ) {
// added.push(id);
// }
// else if (!inNew) {
// removed.push(id);
// } else {
// changed ? diff.push(id) : same.push(id);
// }
if (changed) {
for (const n of keyNotifiers.values()) {
n();
}
}
}
// This is a temporary fix.
forceHistoryRender();
// console.log(JSON.parse(JSON.stringify(oldStates)), JSON.parse(JSON.stringify(currentStates)) );
// console.log('neither', neither);
// console.log('added', added);
// console.log('removed', removed,);
// console.log('diff', diff);
// console.log('same', same);
}
// if (USE_BROWSER_HISTORY) {
// // Clean up old history.
// // If the data in this state is accurate, it should remove all in page history,
// // but still preserve prior history like the previous page.
// if ((history.state?.length || 0) > 0) {
// // Go to the state before
// history.go(-history.state.length + history.state.historyOffset - 1);
// }
// const initialHistoryState = { historyOffset: 0, length: 0 };
// if ('length' in (history.state || {})) {
// history.pushState(initialHistoryState, '');
// } else {
// history.replaceState(initialHistoryState, '');
// }
// // Ignore the first popstate event.
// let ignorePopstate = true;
// window.onpopstate = ({ state: historyState }) => {
// if (ignorePopstate) {
// ignorePopstate = false;
// return;
// }
// const { historyOffset } = state;
// const diff =
// state.historyStack.length - historyOffset - (historyState?.length || 0);
// if (diff === 0) {
// return;
// }
// const type = diff < 0 ? 'HISTORY_FORWARD' : 'HISTORY_BACKWARD';
// historyDispatch({
// type,
// payload: {
// fromBrowser: true,
// amount: Math.abs(diff),
// },
// });
// history.replaceState({ ...historyState, historyOffset }, '');
// };
// }
// const dispatchTimes = {};
const historyDispatch = (action, options) => {
// const start = performance.now();
const newState = historyReducer(state, action, options);
if (newState === state) {
return
}
state = newState;
const {states, historyOffset, historyStack } = state;
currentStates =
historyOffset > 0
? historyStack[historyStack.length - historyOffset].states
: states;
// if (USE_BROWSER_HISTORY) {
// switch (action.type) {
// case 'PERFORM_ACTION': {
// if (!options.skipHistory) {
// history.pushState(
// { historyOffset: 0, length: historyStack.length },
// ''
// );
// }
// break;
// }
// case 'HISTORY_FORWARD': {
// if (!action.payload?.fromBrowser) {
// ignorePopstate = true;
// history.forward();
// }
// break;
// }
// case 'HISTORY_BACKWARD': {
// if (!action.payload?.fromBrowser) {
// ignorePopstate = true;
// history.back();
// }
// break;
// }
// }
// }
// const duration = performance.now() - start;
// const key = `${action.payload?.id || action.type}~${
// action.payload?.action?.type?.name || action.payload?.action?.type || ''
// }`;
// if (!dispatchTimes[key]) {
// dispatchTimes[key] = [];
// }
if (action.type === 'PERFORM_ACTION') {
notifyOne(action.payload.id);
} else {
checkNotifyAll();
}
// dispatchTimes[key].push(duration);
// if (logTimeout) {
// clearTimeout(logTimeout);
// }
// logTimeout = setTimeout(() => {
// console.log(dispatchTimes);
// }, 1000)
}
// let logTimeout;
// This component acts as a boundary for history.
// Todo: use a global history if no boundary is provided.
export function SharedActionHistory(props) {
const { previewComponents, children } = props;
const [,forceRender] = useState();
const {
states,
historyStack,
historyOffset,
currentId,
lastActions,
} = state;
useHotkeys(
'ctrl+z,cmd+z',
() => {
historyDispatch({ type: 'HISTORY_BACKWARD' });
},
hotkeysOptions
);
useHotkeys(
'ctrl+shift+z,cmd+shift+z',
() => {
historyDispatch({ type: 'HISTORY_FORWARD' });
},
hotkeysOptions
);
const historyNavigationData = useMemo(
() => ({
historyStack,
historyOffset,
currentId,
lastActions,
dispatch: historyDispatch ,
states,
currentStates,
previewComponents,
}),
[historyStack, historyOffset, currentId, lastActions, states]
);
useLayoutEffect(() => {
forceHistoryRender = () => forceRender({});
return () => {
forceHistoryRender = () => {};
};
} ,[]);
return (
<HistoryNavigateContext.Provider value={historyNavigationData}>
{children}
</HistoryNavigateContext.Provider>
);
}
export function useResumableReducer(
reducer,
initialState,
initializer = (s) => s,
id
) {
if (!reducers.hasOwnProperty(id)) {
// First one gets to add the reducer, but really it shouldn't matter.
addReducer(id, reducer, initialState, initializer);
}
const currentState = useSyncExternalStore(
subscribers[id],
getSnapshots[id],
);
return [currentState, dispatchers[id]];
}
const stateReducer = (s, v) => v;
export function useResumableState(initial = null, id) {
return useResumableReducer(
stateReducer,
null,
() => (typeof initial === 'function' ? initial() : initial),
id
);
}