Restructure project as Worldshaper
This commit is contained in:
parent
ab891a315c
commit
b4dbd4ee8e
583 changed files with 279 additions and 189269 deletions
|
|
@ -1,852 +0,0 @@
|
|||
/* eslint-disable @typescript-eslint/ban-ts-comment, no-empty */
|
||||
// @ts-nocheck
|
||||
|
||||
export function createHistoryController(scope) {
|
||||
const documentScope = scope.documentScope || scope;
|
||||
const renderScope = scope.renderScope || scope;
|
||||
const historyScope = scope.historyScope || scope;
|
||||
const uiScope = scope.uiScope || scope;
|
||||
const sessionScope = scope.sessionScope || scope;
|
||||
const MAX_HISTORY_ENTRIES = 40;
|
||||
const MAX_PERSISTED_HISTORY_CHARS = 1_500_000;
|
||||
const OPERATION_CHECKPOINT_INTERVAL = 12;
|
||||
let pendingPersistTimer = 0;
|
||||
|
||||
function cloneValue(value) {
|
||||
if (typeof structuredClone === "function") {
|
||||
return structuredClone(value);
|
||||
}
|
||||
return value == null ? value : JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function clearPendingPersistTimer() {
|
||||
if (!pendingPersistTimer) {
|
||||
return;
|
||||
}
|
||||
window.clearTimeout(pendingPersistTimer);
|
||||
pendingPersistTimer = 0;
|
||||
}
|
||||
|
||||
function persistHistoryState() {
|
||||
clearPendingPersistTimer();
|
||||
try {
|
||||
const savedIndex = Math.max(0, Math.min(
|
||||
scope.historyEntries.findIndex((entry) => Number(entry?.id) === Number(historyScope.lastSavedHistoryId)),
|
||||
scope.historyEntries.length - 1,
|
||||
));
|
||||
const savedState = scope.historyEntries.length > 0
|
||||
? captureHistoryStateAtIndex(savedIndex >= 0 ? savedIndex : scope.historyIndex)
|
||||
: captureState();
|
||||
const payload = {
|
||||
mapId: String(documentScope.mapId || scope.mapId || ""),
|
||||
savedStateSignature: getStateSignature(savedState),
|
||||
historyEntries: historyScope.historyEntries,
|
||||
historyIndex: historyScope.historyIndex,
|
||||
historySelectionIndex: historyScope.historySelectionIndex,
|
||||
nextHistoryId: historyScope.nextHistoryId,
|
||||
lastSavedHistoryId: historyScope.lastSavedHistoryId,
|
||||
};
|
||||
const serialized = JSON.stringify(payload);
|
||||
if (serialized.length > MAX_PERSISTED_HISTORY_CHARS) {
|
||||
window.localStorage.removeItem(historyScope.historyStorageKey);
|
||||
return false;
|
||||
}
|
||||
window.localStorage.setItem(historyScope.historyStorageKey, serialized);
|
||||
return true;
|
||||
} catch {}
|
||||
return false;
|
||||
}
|
||||
|
||||
function schedulePersistHistoryState() {
|
||||
clearPendingPersistTimer();
|
||||
pendingPersistTimer = window.setTimeout(() => {
|
||||
pendingPersistTimer = 0;
|
||||
persistHistoryState();
|
||||
}, 120);
|
||||
return true;
|
||||
}
|
||||
|
||||
function restoreHistoryState() {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(historyScope.historyStorageKey);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function applyHistorySnapshot(snapshot) {
|
||||
if (!snapshot || typeof snapshot !== "object") {
|
||||
return false;
|
||||
}
|
||||
const snapshotMapId = String(snapshot.mapId || "").trim();
|
||||
const currentMapId = String(documentScope.mapId || scope.mapId || "").trim();
|
||||
if (snapshotMapId && currentMapId && snapshotMapId !== currentMapId) {
|
||||
return false;
|
||||
}
|
||||
const entries = Array.isArray(snapshot.historyEntries) ? snapshot.historyEntries : null;
|
||||
if (!entries || entries.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const savedId = Number(snapshot.lastSavedHistoryId) || 0;
|
||||
const currentId = Number(snapshot.historyEntries?.[Number(snapshot.historyIndex) || 0]?.id) || 0;
|
||||
if (!savedId || !currentId || savedId !== currentId) {
|
||||
return false;
|
||||
}
|
||||
const savedStateSignature = String(snapshot.savedStateSignature || "").trim();
|
||||
if (savedStateSignature) {
|
||||
const currentLoadedSignature = getStateSignature(captureState());
|
||||
if (savedStateSignature !== currentLoadedSignature) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
historyScope.historyEntries = entries;
|
||||
historyScope.historyIndex = Math.max(0, Math.min(Number(snapshot.historyIndex) || 0, historyScope.historyEntries.length - 1));
|
||||
historyScope.historySelectionIndex = Math.max(0, Math.min(Number(snapshot.historySelectionIndex) || historyScope.historyIndex, historyScope.historyEntries.length - 1));
|
||||
historyScope.nextHistoryId = Math.max(1, Number(snapshot.nextHistoryId) || (historyScope.historyEntries[historyScope.historyEntries.length - 1]?.seq || 0) + 1);
|
||||
historyScope.lastSavedHistoryId = Math.max(1, Number(snapshot.lastSavedHistoryId) || historyScope.historyEntries[historyScope.historyIndex]?.id || 1);
|
||||
if (!restoreToHistoryIndex(historyScope.historyIndex)) {
|
||||
const currentState = historyScope.historyEntries[historyScope.historyIndex] && historyScope.historyEntries[historyScope.historyIndex].state ? historyScope.historyEntries[historyScope.historyIndex].state : null;
|
||||
if (currentState) {
|
||||
applyState(currentState);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function captureState() {
|
||||
return {
|
||||
width: Number(documentScope.width) || 1,
|
||||
height: Number(documentScope.height) || 1,
|
||||
mapName: String(documentScope.mapName || scope.mapId || ""),
|
||||
backgroundColor: documentScope.normalizeMapBackgroundColor(documentScope.backgroundColor),
|
||||
backgroundTileId: String(documentScope.backgroundTileId || "").trim(),
|
||||
heightBlurStep: Math.max(0, Math.min(1, Number(documentScope.heightBlurStep ?? documentScope.heightDetailStep) || 0.1)),
|
||||
layers: documentScope.cloneLayers(documentScope.roomLayers),
|
||||
heightLayers: documentScope.cloneHeightLayers(documentScope.heightLayers),
|
||||
npcs: documentScope.cloneNpcOverlays(documentScope.npcOverlays),
|
||||
worldChunkBackgrounds: typeof scope.captureWorldChunkBackgroundState === "function"
|
||||
? scope.captureWorldChunkBackgroundState()
|
||||
: {},
|
||||
worldBookmarks: typeof scope.captureWorldBookmarkState === "function"
|
||||
? scope.captureWorldBookmarkState()
|
||||
: [],
|
||||
editorUi: documentScope.cloneEditorUiState(),
|
||||
};
|
||||
}
|
||||
|
||||
function refreshUiAfterHistoryMutation() {
|
||||
documentScope.ensureBaseLayer();
|
||||
sessionScope.activeLayer = documentScope.roomLayers.some((layer) => layer.layer === sessionScope.activeLayer) ? sessionScope.activeLayer : 0;
|
||||
if (!documentScope.npcOverlays.some((npc) => npc.id === sessionScope.selectedNpcId)) {
|
||||
sessionScope.selectedNpcId = documentScope.npcOverlays[0] ? String(documentScope.npcOverlays[0].id || "") : "";
|
||||
}
|
||||
if (uiScope.refreshInstanceSectionState) {
|
||||
uiScope.refreshInstanceSectionState();
|
||||
}
|
||||
uiScope.renderPaintPalette();
|
||||
if (uiScope.renderHeightLayerList) {
|
||||
uiScope.renderHeightLayerList();
|
||||
}
|
||||
uiScope.renderInstancePalette();
|
||||
uiScope.renderLayerList();
|
||||
uiScope.renderNpcList();
|
||||
if (uiScope.renderTriggerList) {
|
||||
uiScope.renderTriggerList();
|
||||
}
|
||||
if (uiScope.renderMonsterList) {
|
||||
uiScope.renderMonsterList();
|
||||
}
|
||||
if (uiScope.renderPathList) {
|
||||
uiScope.renderPathList();
|
||||
}
|
||||
if (uiScope.renderTransitionList) {
|
||||
uiScope.renderTransitionList();
|
||||
}
|
||||
uiScope.refreshInformationPanel();
|
||||
if (typeof scope.refreshWorldOverviewWindow === "function") {
|
||||
scope.refreshWorldOverviewWindow();
|
||||
}
|
||||
renderScope.draw();
|
||||
}
|
||||
|
||||
function applyState(state, options) {
|
||||
const config = options && typeof options === "object" ? options : {};
|
||||
documentScope.width = Math.max(1, Number(state?.width) || documentScope.width || 1);
|
||||
documentScope.height = Math.max(1, Number(state?.height) || documentScope.height || 1);
|
||||
documentScope.mapName = String(state?.mapName || scope.mapId || documentScope.mapName || "");
|
||||
documentScope.backgroundColor = documentScope.normalizeMapBackgroundColor(state?.backgroundColor || documentScope.backgroundColor);
|
||||
documentScope.backgroundTileId = documentScope.normalizeBackgroundTileId(state?.backgroundTileId);
|
||||
documentScope.heightBlurStep = Math.max(0, Math.min(1, Number(state?.heightBlurStep ?? state?.heightDetailStep) || documentScope.heightBlurStep || documentScope.heightDetailStep || 0.1));
|
||||
documentScope.roomLayers = documentScope.cloneLayers(Array.isArray(state.layers) ? state.layers : []);
|
||||
documentScope.heightLayers = documentScope.cloneHeightLayers(Array.isArray(state.heightLayers) ? state.heightLayers : []);
|
||||
const nextNpcs = documentScope.cloneNpcOverlays(Array.isArray(state.npcs) ? state.npcs : []);
|
||||
sessionScope.editorUiState = state && state.editorUi ? documentScope.cloneEditorUiState(state.editorUi) : { panelLayouts: {} };
|
||||
if (!documentScope.getHeightLayerById(sessionScope.activeHeightLayerId)) {
|
||||
sessionScope.activeHeightLayerId = String(documentScope.heightLayers[0]?.id || "").trim();
|
||||
}
|
||||
if (sessionScope.editingTargetKind === "height" && !sessionScope.activeHeightLayerId) {
|
||||
sessionScope.editingTargetKind = "room";
|
||||
}
|
||||
nextNpcs.forEach((npc) => documentScope.syncNpcOverlayFromRecord(npc));
|
||||
documentScope.npcOverlays.length = 0;
|
||||
nextNpcs.forEach((npc) => documentScope.npcOverlays.push(npc));
|
||||
if (typeof scope.applyWorldChunkBackgroundState === "function" && scope.isWorldModeActive?.()) {
|
||||
scope.applyWorldChunkBackgroundState(state?.worldChunkBackgrounds || {});
|
||||
}
|
||||
if (typeof scope.applyWorldBookmarkState === "function" && scope.isWorldModeActive?.()) {
|
||||
scope.applyWorldBookmarkState(state?.worldBookmarks || []);
|
||||
}
|
||||
if (typeof scope.rebuildVisibleWorldChunksFromDocument === "function" && typeof scope.isWorldModeActive === "function" && scope.isWorldModeActive()) {
|
||||
scope.rebuildVisibleWorldChunksFromDocument();
|
||||
}
|
||||
if (!config.deferRefresh) {
|
||||
refreshUiAfterHistoryMutation();
|
||||
}
|
||||
}
|
||||
|
||||
function ensureLayerForOperation(layerNumber) {
|
||||
const normalizedLayer = Number(layerNumber) || 0;
|
||||
let layerEntry = scope.roomLayers.find((layer) => Number(layer.layer) === normalizedLayer) || null;
|
||||
if (layerEntry) {
|
||||
return layerEntry;
|
||||
}
|
||||
layerEntry = {
|
||||
layer: normalizedLayer,
|
||||
name: undefined,
|
||||
zIndex: 0,
|
||||
rows: scope.normalizeRows([], normalizedLayer === 0 ? "." : " "),
|
||||
instanceIds: [],
|
||||
};
|
||||
scope.roomLayers.push(layerEntry);
|
||||
scope.roomLayers = scope.roomLayers
|
||||
.slice()
|
||||
.sort((left, right) => Number(left.layer) - Number(right.layer));
|
||||
return scope.roomLayers.find((layer) => Number(layer.layer) === normalizedLayer) || layerEntry;
|
||||
}
|
||||
|
||||
function setStoredTileCharAt(layerNumber, tileX, tileY, nextStoredChar) {
|
||||
if (tileX < 0 || tileX >= scope.width || tileY < 0 || tileY >= scope.height) {
|
||||
return false;
|
||||
}
|
||||
const normalizedLayer = Number(layerNumber) || 0;
|
||||
const layerEntry = ensureLayerForOperation(normalizedLayer);
|
||||
const fillChar = normalizedLayer === 0 ? "." : " ";
|
||||
const rows = scope.normalizeRows(layerEntry.rows, fillChar);
|
||||
const row = rows[tileY] || fillChar.repeat(scope.width);
|
||||
const safeChar = String(nextStoredChar || fillChar).charAt(0) || fillChar;
|
||||
if ((row.charAt(tileX) || fillChar) === safeChar) {
|
||||
return false;
|
||||
}
|
||||
rows[tileY] = row.slice(0, tileX) + safeChar + row.slice(tileX + 1);
|
||||
layerEntry.rows = rows;
|
||||
if (typeof scope.syncWorldChunkCellFromLocalTile === "function" && typeof scope.isWorldModeActive === "function" && scope.isWorldModeActive()) {
|
||||
scope.syncWorldChunkCellFromLocalTile(normalizedLayer, tileX, tileY, safeChar);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveTileOperationCellCoord(cell) {
|
||||
if (typeof scope.isWorldModeActive === "function" && scope.isWorldModeActive()) {
|
||||
const worldX = Number(cell?.worldX);
|
||||
const worldY = Number(cell?.worldY);
|
||||
if (Number.isFinite(worldX) && Number.isFinite(worldY)) {
|
||||
return {
|
||||
x: Math.floor(worldX - (Number(scope.worldTileOffsetX) || 0)),
|
||||
y: Math.floor(worldY - (Number(scope.worldTileOffsetY) || 0)),
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
x: Math.floor(Number(cell?.x) || 0),
|
||||
y: Math.floor(Number(cell?.y) || 0),
|
||||
};
|
||||
}
|
||||
|
||||
function applyTileCellsOperation(operation, direction) {
|
||||
const isRedo = direction !== "undo";
|
||||
const nextBackgroundTileId = isRedo
|
||||
? operation.afterBackgroundTileId
|
||||
: operation.beforeBackgroundTileId;
|
||||
if (nextBackgroundTileId !== undefined) {
|
||||
scope.backgroundTileId = scope.normalizeBackgroundTileId(nextBackgroundTileId);
|
||||
}
|
||||
const cells = Array.isArray(operation.cells) ? operation.cells : [];
|
||||
cells.forEach((cell) => {
|
||||
const resolvedCoord = resolveTileOperationCellCoord(cell, scope.width, scope.height);
|
||||
const nextStoredChar = isRedo ? cell.afterStoredChar : cell.beforeStoredChar;
|
||||
setStoredTileCharAt(cell.layer, resolvedCoord.x, resolvedCoord.y, nextStoredChar);
|
||||
});
|
||||
if (nextBackgroundTileId !== undefined && typeof scope.rebuildVisibleWorldChunksFromDocument === "function" && typeof scope.isWorldModeActive === "function" && scope.isWorldModeActive()) {
|
||||
scope.rebuildVisibleWorldChunksFromDocument();
|
||||
}
|
||||
scope.invalidateTileSurface();
|
||||
}
|
||||
|
||||
function buildNpcTargetEntries(operation, direction) {
|
||||
const useAfter = direction !== "undo";
|
||||
const rawEntries = Array.isArray(operation.entries) ? operation.entries : [];
|
||||
return rawEntries
|
||||
.map((entry) => {
|
||||
const snapshot = useAfter ? entry.after : entry.before;
|
||||
const targetIndex = useAfter ? entry.afterIndex : entry.beforeIndex;
|
||||
if (!snapshot || typeof snapshot !== "object") {
|
||||
return null;
|
||||
}
|
||||
const cloned = scope.cloneNpcOverlays([cloneValue(snapshot)])[0];
|
||||
if (!cloned) {
|
||||
return null;
|
||||
}
|
||||
scope.syncNpcOverlayFromRecord(cloned);
|
||||
return {
|
||||
npc: cloned,
|
||||
index: Math.max(0, Number(targetIndex) || 0),
|
||||
};
|
||||
})
|
||||
.filter((entry) => entry !== null)
|
||||
.sort((left, right) => left.index - right.index);
|
||||
}
|
||||
|
||||
function applyNpcEntriesOperation(operation, direction) {
|
||||
const rawEntries = Array.isArray(operation.entries) ? operation.entries : [];
|
||||
const touchedPositions = [];
|
||||
rawEntries.forEach((entry) => {
|
||||
const beforePos = entry?.before && typeof entry.before === "object" ? entry.before : null;
|
||||
const afterPos = entry?.after && typeof entry.after === "object" ? entry.after : null;
|
||||
if (beforePos) {
|
||||
const x = Math.floor(Number(beforePos.x));
|
||||
const y = Math.floor(Number(beforePos.y));
|
||||
if (Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0) {
|
||||
touchedPositions.push({ x, y });
|
||||
}
|
||||
}
|
||||
if (afterPos) {
|
||||
const x = Math.floor(Number(afterPos.x));
|
||||
const y = Math.floor(Number(afterPos.y));
|
||||
if (Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0) {
|
||||
touchedPositions.push({ x, y });
|
||||
}
|
||||
}
|
||||
});
|
||||
const affectedIds = new Set(
|
||||
rawEntries.flatMap((entry) => {
|
||||
const ids = [];
|
||||
const beforeId = String(entry?.before?.id || "").trim();
|
||||
const afterId = String(entry?.after?.id || "").trim();
|
||||
if (beforeId) {
|
||||
ids.push(beforeId);
|
||||
}
|
||||
if (afterId) {
|
||||
ids.push(afterId);
|
||||
}
|
||||
return ids;
|
||||
}),
|
||||
);
|
||||
if (affectedIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
const remainingNpcs = scope.npcOverlays.filter((npc) => !affectedIds.has(String(npc.id || "").trim()));
|
||||
scope.npcOverlays.length = 0;
|
||||
remainingNpcs.forEach((npc) => scope.npcOverlays.push(npc));
|
||||
affectedIds.forEach((npcId) => {
|
||||
delete scope.npcImages[npcId];
|
||||
});
|
||||
const targetEntries = buildNpcTargetEntries(operation, direction);
|
||||
targetEntries.forEach((entry) => {
|
||||
const nextIndex = Math.max(0, Math.min(scope.npcOverlays.length, entry.index));
|
||||
scope.ensureNpcImageLoaded(entry.npc);
|
||||
scope.npcOverlays.splice(nextIndex, 0, entry.npc);
|
||||
});
|
||||
if (typeof scope.rebuildWorldChunksForLocalBounds === "function" && typeof scope.isWorldModeActive === "function" && scope.isWorldModeActive() && touchedPositions.length > 0) {
|
||||
const xs = touchedPositions.map((entry) => entry.x);
|
||||
const ys = touchedPositions.map((entry) => entry.y);
|
||||
scope.rebuildWorldChunksForLocalBounds({
|
||||
minX: Math.min(...xs),
|
||||
minY: Math.min(...ys),
|
||||
maxX: Math.max(...xs),
|
||||
maxY: Math.max(...ys),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function applyOperation(operation, direction, options) {
|
||||
const config = options && typeof options === "object" ? options : {};
|
||||
if (!operation || typeof operation !== "object") {
|
||||
return false;
|
||||
}
|
||||
if (operation.type === "tile_cells") {
|
||||
applyTileCellsOperation(operation, direction);
|
||||
} else if (operation.type === "npc_entries") {
|
||||
applyNpcEntriesOperation(operation, direction);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
if (!config.deferRefresh) {
|
||||
refreshUiAfterHistoryMutation();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function cloneHistoryState(state) {
|
||||
if (!state || typeof state !== "object") {
|
||||
return captureState();
|
||||
}
|
||||
return {
|
||||
width: Math.max(1, Number(state.width) || 1),
|
||||
height: Math.max(1, Number(state.height) || 1),
|
||||
mapName: String(state.mapName || scope.mapId || ""),
|
||||
backgroundColor: documentScope.normalizeMapBackgroundColor(state.backgroundColor),
|
||||
backgroundTileId: documentScope.normalizeBackgroundTileId(state.backgroundTileId),
|
||||
heightBlurStep: Math.max(0, Math.min(1, Number(state.heightBlurStep ?? state.heightDetailStep) || 0.1)),
|
||||
layers: documentScope.cloneLayers(Array.isArray(state.layers) ? state.layers : []),
|
||||
heightLayers: documentScope.cloneHeightLayers(Array.isArray(state.heightLayers) ? state.heightLayers : []),
|
||||
npcs: documentScope.cloneNpcOverlays(Array.isArray(state.npcs) ? state.npcs : []),
|
||||
editorUi: documentScope.cloneEditorUiState(state.editorUi || {}),
|
||||
};
|
||||
}
|
||||
|
||||
function ensureLayerForStateOperation(state, layerNumber) {
|
||||
const normalizedLayer = Number(layerNumber) || 0;
|
||||
let layerEntry = state.layers.find((layer) => Number(layer.layer) === normalizedLayer) || null;
|
||||
if (layerEntry) {
|
||||
return layerEntry;
|
||||
}
|
||||
layerEntry = {
|
||||
layer: normalizedLayer,
|
||||
name: undefined,
|
||||
zIndex: 0,
|
||||
rows: scope.normalizeRows([], normalizedLayer === 0 ? "." : " "),
|
||||
instanceIds: [],
|
||||
};
|
||||
state.layers.push(layerEntry);
|
||||
state.layers = state.layers
|
||||
.slice()
|
||||
.sort((left, right) => Number(left.layer) - Number(right.layer));
|
||||
return state.layers.find((layer) => Number(layer.layer) === normalizedLayer) || layerEntry;
|
||||
}
|
||||
|
||||
function setStoredTileCharAtInState(state, layerNumber, tileX, tileY, nextStoredChar) {
|
||||
if (tileX < 0 || tileX >= state.width || tileY < 0 || tileY >= state.height) {
|
||||
return false;
|
||||
}
|
||||
const normalizedLayer = Number(layerNumber) || 0;
|
||||
const layerEntry = ensureLayerForStateOperation(state, normalizedLayer);
|
||||
const fillChar = normalizedLayer === 0 ? "." : " ";
|
||||
const rows = scope.normalizeRows(layerEntry.rows, fillChar);
|
||||
const row = rows[tileY] || fillChar.repeat(state.width);
|
||||
const safeChar = String(nextStoredChar || fillChar).charAt(0) || fillChar;
|
||||
if ((row.charAt(tileX) || fillChar) === safeChar) {
|
||||
return false;
|
||||
}
|
||||
rows[tileY] = row.slice(0, tileX) + safeChar + row.slice(tileX + 1);
|
||||
layerEntry.rows = rows;
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyTileCellsOperationToState(state, operation, direction) {
|
||||
const nextState = cloneHistoryState(state);
|
||||
const isRedo = direction !== "undo";
|
||||
const nextBackgroundTileId = isRedo
|
||||
? operation.afterBackgroundTileId
|
||||
: operation.beforeBackgroundTileId;
|
||||
if (nextBackgroundTileId !== undefined) {
|
||||
nextState.backgroundTileId = documentScope.normalizeBackgroundTileId(nextBackgroundTileId);
|
||||
}
|
||||
const cells = Array.isArray(operation.cells) ? operation.cells : [];
|
||||
cells.forEach((cell) => {
|
||||
const resolvedCoord = resolveTileOperationCellCoord(cell, nextState.width, nextState.height);
|
||||
const nextStoredChar = isRedo ? cell.afterStoredChar : cell.beforeStoredChar;
|
||||
setStoredTileCharAtInState(nextState, cell.layer, resolvedCoord.x, resolvedCoord.y, nextStoredChar);
|
||||
});
|
||||
return nextState;
|
||||
}
|
||||
|
||||
function applyNpcEntriesOperationToState(state, operation, direction) {
|
||||
const nextState = cloneHistoryState(state);
|
||||
const rawEntries = Array.isArray(operation.entries) ? operation.entries : [];
|
||||
const affectedIds = new Set(
|
||||
rawEntries.flatMap((entry) => {
|
||||
const ids = [];
|
||||
const beforeId = String(entry?.before?.id || "").trim();
|
||||
const afterId = String(entry?.after?.id || "").trim();
|
||||
if (beforeId) {
|
||||
ids.push(beforeId);
|
||||
}
|
||||
if (afterId) {
|
||||
ids.push(afterId);
|
||||
}
|
||||
return ids;
|
||||
}),
|
||||
);
|
||||
if (affectedIds.size === 0) {
|
||||
return nextState;
|
||||
}
|
||||
const useAfter = direction !== "undo";
|
||||
const remainingNpcs = nextState.npcs.filter((npc) => !affectedIds.has(String(npc.id || "").trim()));
|
||||
const targetEntries = rawEntries
|
||||
.map((entry) => {
|
||||
const snapshot = useAfter ? entry.after : entry.before;
|
||||
const targetIndex = useAfter ? entry.afterIndex : entry.beforeIndex;
|
||||
if (!snapshot || typeof snapshot !== "object") {
|
||||
return null;
|
||||
}
|
||||
const cloned = documentScope.cloneNpcOverlays([cloneValue(snapshot)])[0];
|
||||
if (!cloned) {
|
||||
return null;
|
||||
}
|
||||
documentScope.syncNpcOverlayFromRecord(cloned);
|
||||
return {
|
||||
npc: cloned,
|
||||
index: Math.max(0, Number(targetIndex) || 0),
|
||||
};
|
||||
})
|
||||
.filter((entry) => entry !== null)
|
||||
.sort((left, right) => left.index - right.index);
|
||||
nextState.npcs = remainingNpcs;
|
||||
targetEntries.forEach((entry) => {
|
||||
const nextIndex = Math.max(0, Math.min(nextState.npcs.length, entry.index));
|
||||
nextState.npcs.splice(nextIndex, 0, entry.npc);
|
||||
});
|
||||
return nextState;
|
||||
}
|
||||
|
||||
function captureHistoryStateAtIndex(targetIndex) {
|
||||
const normalizedTargetIndex = Math.max(0, Math.min(Number(targetIndex) || 0, scope.historyEntries.length - 1));
|
||||
const targetEntry = scope.historyEntries[normalizedTargetIndex] || null;
|
||||
if (!targetEntry) {
|
||||
return captureState();
|
||||
}
|
||||
if (targetEntry.state) {
|
||||
return cloneHistoryState(targetEntry.state);
|
||||
}
|
||||
const snapshotIndex = findNearestSnapshotIndex(normalizedTargetIndex);
|
||||
if (snapshotIndex < 0) {
|
||||
return captureState();
|
||||
}
|
||||
let nextState = cloneHistoryState(scope.historyEntries[snapshotIndex].state);
|
||||
for (let index = snapshotIndex + 1; index <= normalizedTargetIndex; index += 1) {
|
||||
const entry = scope.historyEntries[index];
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
if (entry.state) {
|
||||
nextState = cloneHistoryState(entry.state);
|
||||
continue;
|
||||
}
|
||||
if (!entry.operation || typeof entry.operation !== "object") {
|
||||
continue;
|
||||
}
|
||||
if (entry.operation.type === "tile_cells") {
|
||||
nextState = applyTileCellsOperationToState(nextState, entry.operation, "redo");
|
||||
} else if (entry.operation.type === "npc_entries") {
|
||||
nextState = applyNpcEntriesOperationToState(nextState, entry.operation, "redo");
|
||||
}
|
||||
}
|
||||
return nextState;
|
||||
}
|
||||
|
||||
function findNearestSnapshotIndex(targetIndex) {
|
||||
for (let index = Math.max(0, Number(targetIndex) || 0); index >= 0; index -= 1) {
|
||||
if (scope.historyEntries[index] && scope.historyEntries[index].state) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function restoreToHistoryIndex(targetIndex) {
|
||||
const normalizedTargetIndex = Math.max(0, Math.min(Number(targetIndex) || 0, scope.historyEntries.length - 1));
|
||||
const snapshotIndex = findNearestSnapshotIndex(normalizedTargetIndex);
|
||||
if (snapshotIndex < 0) {
|
||||
return false;
|
||||
}
|
||||
applyState(scope.historyEntries[snapshotIndex].state, { deferRefresh: true });
|
||||
for (let index = snapshotIndex + 1; index <= normalizedTargetIndex; index += 1) {
|
||||
const entry = scope.historyEntries[index];
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
if (entry.state) {
|
||||
applyState(entry.state, { deferRefresh: true });
|
||||
continue;
|
||||
}
|
||||
if (entry.operation) {
|
||||
applyOperation(entry.operation, "redo", { deferRefresh: true });
|
||||
}
|
||||
}
|
||||
refreshUiAfterHistoryMutation();
|
||||
return true;
|
||||
}
|
||||
|
||||
function getStateSignature(state) {
|
||||
const layerSig = scope.cloneLayers(state.layers)
|
||||
.sort((a, b) => a.layer - b.layer)
|
||||
.map((layer) => ({
|
||||
layer: layer.layer,
|
||||
name: typeof layer.name === "string" ? layer.name : "",
|
||||
rows: scope.normalizeRows(layer.rows, layer.layer === 0 ? "." : " "),
|
||||
}));
|
||||
const heightLayerSig = scope.cloneHeightLayers(state.heightLayers)
|
||||
.sort((a, b) => String(a.id || "").localeCompare(String(b.id || "")))
|
||||
.map((entry) => ({
|
||||
id: String(entry.id || ""),
|
||||
name: typeof entry.name === "string" ? entry.name : "",
|
||||
z: Number(entry.z) || 1,
|
||||
x: Number(entry.x) || 0,
|
||||
y: Number(entry.y) || 0,
|
||||
rows: Array.isArray(entry.rows) ? entry.rows.map((row) => String(row || "")) : [],
|
||||
}));
|
||||
const npcSig = scope.cloneNpcOverlays(state.npcs)
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
.map((entry) => ({
|
||||
id: entry.id,
|
||||
layer: Number(entry.layer) || 0,
|
||||
name: entry.name,
|
||||
spriteId: entry.spriteId,
|
||||
x: entry.x,
|
||||
y: entry.y,
|
||||
}));
|
||||
return JSON.stringify({
|
||||
width: Number(state.width) || 1,
|
||||
height: Number(state.height) || 1,
|
||||
mapName: String(state.mapName || ""),
|
||||
backgroundColor: scope.normalizeMapBackgroundColor(state.backgroundColor),
|
||||
backgroundTileId: scope.normalizeBackgroundTileId(state.backgroundTileId),
|
||||
heightBlurStep: Math.max(0, Math.min(1, Number(state.heightBlurStep ?? state.heightDetailStep) || 0.1)),
|
||||
layerSig,
|
||||
heightLayerSig,
|
||||
npcSig,
|
||||
worldChunkBackgrounds: state && state.worldChunkBackgrounds && typeof state.worldChunkBackgrounds === "object" && !Array.isArray(state.worldChunkBackgrounds)
|
||||
? state.worldChunkBackgrounds
|
||||
: {},
|
||||
editorUi: scope.cloneEditorUiState(state.editorUi || {}),
|
||||
});
|
||||
}
|
||||
|
||||
function formatCellCoord(cell) {
|
||||
return "(" + cell.x + "," + cell.y + ")";
|
||||
}
|
||||
|
||||
function formatHistoryLabel(entry) {
|
||||
const pairText = (entry.before || entry.after)
|
||||
? (" (" + (entry.before || "?") + " -> " + (entry.after || "?") + ")")
|
||||
: "";
|
||||
return entry.label + pairText;
|
||||
}
|
||||
|
||||
function renderHistoryPreview() {
|
||||
const selectedEntry = scope.historyEntries[scope.historySelectionIndex] || null;
|
||||
if (!selectedEntry) {
|
||||
scope.historyPreviewEl.innerHTML = '<h4>Change Preview</h4><div class="history-preview-empty">Select a history entry to inspect it.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const details = Array.isArray(selectedEntry.details) ? selectedEntry.details : [];
|
||||
const detailHtml = details.length > 0
|
||||
? "<ul>" + details.map((detail) => "<li>" + detail + "</li>").join("") + "</ul>"
|
||||
: '<div class="history-preview-empty">No additional details recorded.</div>';
|
||||
const currentText = scope.historySelectionIndex === scope.historyIndex ? "Current state" : "Selected step " + selectedEntry.seq;
|
||||
scope.historyPreviewEl.innerHTML =
|
||||
"<h4>" + currentText + "</h4>" +
|
||||
'<div style="margin-bottom:6px;">' + formatHistoryLabel(selectedEntry) + "</div>" +
|
||||
detailHtml +
|
||||
'<button class="mini-btn" id="jumpHistoryBtn" type="button" style="margin-top:8px;">Restore To Selected</button>';
|
||||
|
||||
const nextJumpBtn = document.getElementById("jumpHistoryBtn");
|
||||
nextJumpBtn.disabled = scope.isSaving || scope.historySelectionIndex === scope.historyIndex;
|
||||
nextJumpBtn.addEventListener("click", () => {
|
||||
if (scope.historySelectionIndex === scope.historyIndex) {
|
||||
return;
|
||||
}
|
||||
scope.historyIndex = scope.historySelectionIndex;
|
||||
restoreToHistoryIndex(scope.historyIndex);
|
||||
refreshToolbarState();
|
||||
scope.setStatus("Restored to history step " + scope.historyEntries[scope.historyIndex].seq + ".", false);
|
||||
});
|
||||
}
|
||||
|
||||
function renderHistoryList() {
|
||||
scope.historyListEl.innerHTML = "";
|
||||
if (scope.historyCurrentEl) {
|
||||
const currentEntry = scope.historyEntries[scope.historyIndex] || null;
|
||||
scope.historyCurrentEl.innerHTML = currentEntry
|
||||
? (
|
||||
'<div class="history-current-label">Current State</div>' +
|
||||
'<button type="button" class="history-row current-row">' +
|
||||
"<span>" + String(currentEntry.seq) + ". " + formatHistoryLabel(currentEntry) + "</span>" +
|
||||
'<span class="history-meta">' + new Date(currentEntry.createdAt).toLocaleTimeString() + "</span>" +
|
||||
"</button>"
|
||||
)
|
||||
: '<div class="history-current-label">Current State</div><div class="history-current-empty">No history yet.</div>';
|
||||
}
|
||||
scope.historyEntries.forEach((entry, index) => {
|
||||
if (index === scope.historyIndex) {
|
||||
return;
|
||||
}
|
||||
const row = document.createElement("button");
|
||||
row.type = "button";
|
||||
row.className = "history-row" + (index === scope.historySelectionIndex ? " active" : "");
|
||||
const timeText = new Date(entry.createdAt).toLocaleTimeString();
|
||||
row.innerHTML =
|
||||
"<span>" + String(entry.seq) + ". " + formatHistoryLabel(entry) + "</span>" +
|
||||
'<span class="history-meta">' + timeText + "</span>";
|
||||
row.addEventListener("click", () => {
|
||||
if (index === scope.historySelectionIndex) {
|
||||
return;
|
||||
}
|
||||
scope.historySelectionIndex = index;
|
||||
renderHistoryList();
|
||||
renderHistoryPreview();
|
||||
});
|
||||
scope.historyListEl.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function refreshToolbarState(preserveCurrentStatus) {
|
||||
const canUndo = scope.historyIndex > 0;
|
||||
const canRedo = scope.historyIndex < scope.historyEntries.length - 1;
|
||||
const currentHistoryId = scope.historyEntries[scope.historyIndex] ? scope.historyEntries[scope.historyIndex].id : 0;
|
||||
const isDirtyFromSaved = currentHistoryId !== scope.lastSavedHistoryId;
|
||||
|
||||
scope.undoBtn.disabled = scope.isSaving || !canUndo;
|
||||
scope.redoBtn.disabled = scope.isSaving || !canRedo;
|
||||
scope.saveBtn.disabled = scope.isSaving || !isDirtyFromSaved;
|
||||
|
||||
renderHistoryList();
|
||||
renderHistoryPreview();
|
||||
|
||||
if (preserveCurrentStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (scope.isSaving) {
|
||||
scope.setStatus("Saving...", false);
|
||||
} else if (canRedo) {
|
||||
scope.setStatus("History branch active. New edits will replace future steps.", false);
|
||||
} else if (isDirtyFromSaved) {
|
||||
scope.setStatus("Unsaved history changes.", false);
|
||||
} else {
|
||||
scope.setStatus("All changes saved.", false);
|
||||
}
|
||||
}
|
||||
|
||||
function registerHistory(label, before, after, details, options) {
|
||||
const config = options && typeof options === "object" ? options : {};
|
||||
const operation = config.operation ? cloneValue(config.operation) : null;
|
||||
if (operation && operation.type === "tile_cells" && (!Array.isArray(operation.cells) || operation.cells.length === 0)) {
|
||||
return;
|
||||
}
|
||||
if (operation && operation.type === "npc_entries" && (!Array.isArray(operation.entries) || operation.entries.length === 0)) {
|
||||
return;
|
||||
}
|
||||
const shouldStoreOperationOnly = Boolean(operation);
|
||||
const nextState = shouldStoreOperationOnly ? null : (config.nextState || captureState());
|
||||
const currentEntry = scope.historyEntries[scope.historyIndex] || null;
|
||||
const currentState = currentEntry && currentEntry.state
|
||||
? currentEntry.state
|
||||
: captureHistoryStateAtIndex(scope.historyIndex);
|
||||
if (!config.skipStateCheck && nextState && currentState && getStateSignature(nextState) === getStateSignature(currentState)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (scope.historyIndex < scope.historyEntries.length - 1) {
|
||||
scope.historyEntries = scope.historyEntries.slice(0, scope.historyIndex + 1);
|
||||
}
|
||||
|
||||
let operationEntriesSinceSnapshot = 0;
|
||||
if (shouldStoreOperationOnly) {
|
||||
for (let index = scope.historyEntries.length - 1; index >= 0; index -= 1) {
|
||||
const entry = scope.historyEntries[index];
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
if (entry.state) {
|
||||
break;
|
||||
}
|
||||
if (entry.operation) {
|
||||
operationEntriesSinceSnapshot += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
const checkpointState = shouldStoreOperationOnly && operationEntriesSinceSnapshot + 1 >= OPERATION_CHECKPOINT_INTERVAL
|
||||
? captureState()
|
||||
: null;
|
||||
|
||||
const entry = {
|
||||
id: scope.nextHistoryId,
|
||||
seq: scope.nextHistoryId,
|
||||
createdAt: Date.now(),
|
||||
label,
|
||||
before,
|
||||
after,
|
||||
details: Array.isArray(details) ? details : [],
|
||||
state: nextState || checkpointState,
|
||||
operation,
|
||||
};
|
||||
scope.nextHistoryId += 1;
|
||||
|
||||
scope.historyEntries.push(entry);
|
||||
scope.historyIndex = scope.historyEntries.length - 1;
|
||||
scope.historySelectionIndex = scope.historyIndex;
|
||||
if (scope.historyEntries.length > MAX_HISTORY_ENTRIES) {
|
||||
const trimmedCount = scope.historyEntries.length - MAX_HISTORY_ENTRIES;
|
||||
scope.historyEntries = scope.historyEntries.slice(trimmedCount);
|
||||
scope.historyIndex = Math.max(0, scope.historyIndex - trimmedCount);
|
||||
scope.historySelectionIndex = Math.max(0, scope.historySelectionIndex - trimmedCount);
|
||||
}
|
||||
|
||||
schedulePersistHistoryState();
|
||||
refreshToolbarState();
|
||||
}
|
||||
|
||||
function undo() {
|
||||
if (scope.historyIndex <= 0) {
|
||||
return;
|
||||
}
|
||||
scope.historyIndex -= 1;
|
||||
scope.historySelectionIndex = scope.historyIndex;
|
||||
restoreToHistoryIndex(scope.historyIndex);
|
||||
schedulePersistHistoryState();
|
||||
refreshToolbarState();
|
||||
scope.setStatus("Undo to step " + scope.historyEntries[scope.historyIndex].seq + ".", false);
|
||||
}
|
||||
|
||||
function redo() {
|
||||
if (scope.historyIndex >= scope.historyEntries.length - 1) {
|
||||
return;
|
||||
}
|
||||
scope.historyIndex += 1;
|
||||
scope.historySelectionIndex = scope.historyIndex;
|
||||
restoreToHistoryIndex(scope.historyIndex);
|
||||
schedulePersistHistoryState();
|
||||
refreshToolbarState();
|
||||
scope.setStatus("Redo to step " + scope.historyEntries[scope.historyIndex].seq + ".", false);
|
||||
}
|
||||
|
||||
return {
|
||||
persistHistoryState,
|
||||
schedulePersistHistoryState,
|
||||
restoreHistoryState,
|
||||
applyHistorySnapshot,
|
||||
captureState,
|
||||
applyState,
|
||||
applyOperation,
|
||||
restoreToHistoryIndex,
|
||||
getStateSignature,
|
||||
formatCellCoord,
|
||||
formatHistoryLabel,
|
||||
renderHistoryPreview,
|
||||
renderHistoryList,
|
||||
refreshToolbarState,
|
||||
registerHistory,
|
||||
undo,
|
||||
redo,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue