Initial import
This commit is contained in:
commit
ab891a315c
773 changed files with 257255 additions and 0 deletions
470
src/mapEditorPopup/changelogSplashWindowController.ts
Normal file
470
src/mapEditorPopup/changelogSplashWindowController.ts
Normal file
|
|
@ -0,0 +1,470 @@
|
|||
import { clampFloatingWindowRect } from "./floatingWindowUtils";
|
||||
|
||||
const CHANGELOG_SPLASH_WINDOW_KEY = "changelogSplash";
|
||||
const CHANGELOG_SPLASH_VERSION = "2026-06-22-world-editor-release-v6";
|
||||
const CHANGELOG_SPLASH_STORAGE_KEY = `content-editor-v2:map-editor:changelog-seen:${CHANGELOG_SPLASH_VERSION}`;
|
||||
const DEFAULT_WIDTH = 700;
|
||||
const DEFAULT_HEIGHT = 560;
|
||||
const MIN_WIDTH = 520;
|
||||
const MIN_HEIGHT = 360;
|
||||
|
||||
type LayerRect = {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type PersistedWindowState = {
|
||||
visible?: boolean;
|
||||
x?: number;
|
||||
y?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
type ControllerScope = {
|
||||
uiScope?: {
|
||||
toolWindowLayerEl?: HTMLElement | null;
|
||||
editorBodyEl?: HTMLElement | null;
|
||||
};
|
||||
sessionScope?: {
|
||||
getPersistedToolWindowState?: (key: string) => PersistedWindowState | null;
|
||||
setPersistedToolWindowState?: (key: string, value: Record<string, unknown>) => void;
|
||||
persistPopupSessionLayout?: () => void;
|
||||
};
|
||||
persistPopupSessionLayout?: () => void;
|
||||
};
|
||||
|
||||
type SplashState = {
|
||||
visible: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
shellEl: HTMLDivElement | null;
|
||||
titleEl: HTMLDivElement | null;
|
||||
metaEl: HTMLDivElement | null;
|
||||
sectionListEl: HTMLDivElement | null;
|
||||
actionBtnEl: HTMLButtonElement | null;
|
||||
resizeEl: HTMLDivElement | null;
|
||||
nextZIndex: number;
|
||||
};
|
||||
|
||||
type OpenOptions = {
|
||||
center?: boolean;
|
||||
markSeen?: boolean;
|
||||
};
|
||||
|
||||
type ChangelogItem = string | {
|
||||
text: string;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
const CHANGELOG_SECTIONS = [
|
||||
{
|
||||
title: "World Rendering",
|
||||
items: [
|
||||
"Added live image opacity support in the renderer for both tiles and entity sprites.",
|
||||
{
|
||||
text: "Fixed world painting placement drift on very wide displays.",
|
||||
note: "For the many of you out there using this on superultrawide monitors.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Graphics & Animation",
|
||||
items: [
|
||||
"Added animation frame timelines to the Graphic Painter.",
|
||||
"Added frame duplication, enable/disable, delete, drag reorder, and default-frame selection.",
|
||||
"Added animation speed and playback settings to graphics data.",
|
||||
"Added animation preview support from the Graphic Painter.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "World Overview",
|
||||
items: [
|
||||
"Added chunk move, duplicate, rotate, flip, and delete workflows from the world overview.",
|
||||
"Added safer chunk management feedback and confirmation flows.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Editor Stability",
|
||||
items: [
|
||||
"Fixed unified graphics conversion so saved image properties flow correctly into runtime tiles and sprites.",
|
||||
"Improved live refresh behavior for renderer-facing graphic updates.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "TBI (To Be Implemented [Or thought about really hard])",
|
||||
items: [
|
||||
"Image animations on renderer with hotkey toggle and engine override toggle.",
|
||||
"Add premade frame duplication effects, like duplicate and shift by direction.",
|
||||
"Add animation effects that change opacity over time.",
|
||||
"Add animation effects that shift saturation over time.",
|
||||
"Work on a system to use an image with animation frames as a tile strip, placing the instance once but specifying which subimage to use.",
|
||||
"Add an elevation system where some tiles can appear at different z-indexes and show a different subframe.",
|
||||
"Allow unsnapped tile placement, possibly via hotkey, writing into a chunk patch instead of chunk rows.",
|
||||
"Explore whether unsnapped placement should be true free placement or an additional sub-layer drawn over an existing layer.",
|
||||
"Add custom prompts for text input and confirmation dialogs.",
|
||||
"Prototype terrain painters for meta chunk painting and tile replacement, like rivers, mountains, and woods.",
|
||||
"Talk to Justin more about zone and subzone music regions via chunk painting.",
|
||||
"Autotilers.",
|
||||
"Prefab stamps.",
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
function clampWindowRect(layerRect: LayerRect, left: number, top: number, width: number, height: number) {
|
||||
return clampFloatingWindowRect(layerRect, left, top, width, height, MIN_WIDTH, MIN_HEIGHT, DEFAULT_WIDTH, DEFAULT_HEIGHT);
|
||||
}
|
||||
|
||||
export function createChangelogSplashWindowController(scope: ControllerScope) {
|
||||
let initialized = false;
|
||||
const uiScope = (scope.uiScope || scope) as NonNullable<ControllerScope["uiScope"]>;
|
||||
const sessionScope = (scope.sessionScope || scope) as NonNullable<ControllerScope["sessionScope"]>;
|
||||
const persistedState = typeof sessionScope.getPersistedToolWindowState === "function"
|
||||
? sessionScope.getPersistedToolWindowState(CHANGELOG_SPLASH_WINDOW_KEY)
|
||||
: null;
|
||||
const state: SplashState = {
|
||||
visible: false,
|
||||
x: Number(persistedState?.x) || 88,
|
||||
y: Number(persistedState?.y) || 56,
|
||||
width: Number(persistedState?.width) || DEFAULT_WIDTH,
|
||||
height: Number(persistedState?.height) || DEFAULT_HEIGHT,
|
||||
shellEl: null,
|
||||
titleEl: null,
|
||||
metaEl: null,
|
||||
sectionListEl: null,
|
||||
actionBtnEl: null,
|
||||
resizeEl: null,
|
||||
nextZIndex: 132,
|
||||
};
|
||||
|
||||
function getLayerRect(): LayerRect {
|
||||
return uiScope.toolWindowLayerEl?.getBoundingClientRect() || uiScope.editorBodyEl?.getBoundingClientRect() || {
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: DEFAULT_WIDTH,
|
||||
height: DEFAULT_HEIGHT,
|
||||
};
|
||||
}
|
||||
|
||||
function persistState() {
|
||||
if (typeof sessionScope.setPersistedToolWindowState === "function") {
|
||||
sessionScope.setPersistedToolWindowState(CHANGELOG_SPLASH_WINDOW_KEY, {
|
||||
visible: state.visible === true,
|
||||
mode: "floating",
|
||||
x: state.x,
|
||||
y: state.y,
|
||||
width: state.width,
|
||||
height: state.height,
|
||||
order: 994,
|
||||
});
|
||||
}
|
||||
scope.persistPopupSessionLayout?.();
|
||||
sessionScope.persistPopupSessionLayout?.();
|
||||
}
|
||||
|
||||
function markSeen() {
|
||||
try {
|
||||
window.localStorage.setItem(CHANGELOG_SPLASH_STORAGE_KEY, "1");
|
||||
} catch {
|
||||
// Ignore localStorage write errors and keep the splash functional.
|
||||
}
|
||||
}
|
||||
|
||||
function hasSeenCurrentVersion() {
|
||||
try {
|
||||
return window.localStorage.getItem(CHANGELOG_SPLASH_STORAGE_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function focusWindow() {
|
||||
if (!state.shellEl || state.visible !== true) {
|
||||
return;
|
||||
}
|
||||
state.nextZIndex += 1;
|
||||
state.shellEl.style.zIndex = String(state.nextZIndex);
|
||||
state.shellEl.classList.add("is-focused");
|
||||
}
|
||||
|
||||
function clearFocus() {
|
||||
state.shellEl?.classList.remove("is-focused");
|
||||
}
|
||||
|
||||
function applyWindowRect() {
|
||||
if (!state.shellEl) {
|
||||
return;
|
||||
}
|
||||
state.shellEl.style.left = `${Math.round(state.x)}px`;
|
||||
state.shellEl.style.top = `${Math.round(state.y)}px`;
|
||||
state.shellEl.style.width = `${Math.round(state.width)}px`;
|
||||
state.shellEl.style.height = `${Math.round(state.height)}px`;
|
||||
}
|
||||
|
||||
function centerWindow() {
|
||||
const layerRect = getLayerRect();
|
||||
const centeredLeft = Math.max(0, ((Number(layerRect.width) || DEFAULT_WIDTH) - state.width) / 2);
|
||||
const centeredTop = Math.max(0, ((Number(layerRect.height) || DEFAULT_HEIGHT) - state.height) / 2);
|
||||
const nextRect = clampWindowRect(layerRect, centeredLeft, centeredTop, state.width, state.height);
|
||||
state.x = nextRect.left;
|
||||
state.y = nextRect.top;
|
||||
state.width = nextRect.width;
|
||||
state.height = nextRect.height;
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
if (state.titleEl) {
|
||||
state.titleEl.textContent = "What's New";
|
||||
}
|
||||
if (state.metaEl) {
|
||||
state.metaEl.textContent = `Release ${CHANGELOG_SPLASH_VERSION}`;
|
||||
}
|
||||
if (!state.sectionListEl) {
|
||||
return;
|
||||
}
|
||||
state.sectionListEl.innerHTML = "";
|
||||
CHANGELOG_SECTIONS.forEach((section) => {
|
||||
const sectionEl = document.createElement("section");
|
||||
sectionEl.className = "changelog-splash-section";
|
||||
const headingEl = document.createElement("h3");
|
||||
headingEl.className = "changelog-splash-section-title";
|
||||
headingEl.textContent = section.title;
|
||||
const listEl = document.createElement("ul");
|
||||
listEl.className = "changelog-splash-bullets";
|
||||
section.items.forEach((item) => {
|
||||
const itemEl = document.createElement("li");
|
||||
const normalizedItem: ChangelogItem = item;
|
||||
if (typeof normalizedItem === "string") {
|
||||
itemEl.textContent = normalizedItem;
|
||||
} else {
|
||||
const textEl = document.createElement("div");
|
||||
textEl.textContent = normalizedItem.text;
|
||||
itemEl.appendChild(textEl);
|
||||
if (normalizedItem.note) {
|
||||
const noteEl = document.createElement("div");
|
||||
noteEl.className = "changelog-splash-bullet-note";
|
||||
noteEl.textContent = normalizedItem.note;
|
||||
itemEl.appendChild(noteEl);
|
||||
}
|
||||
}
|
||||
listEl.appendChild(itemEl);
|
||||
});
|
||||
sectionEl.appendChild(headingEl);
|
||||
sectionEl.appendChild(listEl);
|
||||
state.sectionListEl?.appendChild(sectionEl);
|
||||
});
|
||||
}
|
||||
|
||||
function ensureShell() {
|
||||
if (state.shellEl && state.shellEl.isConnected) {
|
||||
return state.shellEl;
|
||||
}
|
||||
const shellEl = document.createElement("div");
|
||||
shellEl.className = "tool-popout-window changelog-splash-window hidden";
|
||||
const titlebarEl = document.createElement("div");
|
||||
titlebarEl.className = "tool-popout-titlebar";
|
||||
titlebarEl.innerHTML =
|
||||
`<div class="tool-popout-title">What's New</div>` +
|
||||
`<div class="tool-popout-hint">Shown once per release</div>` +
|
||||
`<button class="tool-popout-close-btn" type="button" aria-label="Close changelog">X</button>`;
|
||||
const bodyEl = document.createElement("div");
|
||||
bodyEl.className = "tool-popout-body";
|
||||
const cardEl = document.createElement("div");
|
||||
cardEl.className = "changelog-splash-card";
|
||||
const heroEl = document.createElement("div");
|
||||
heroEl.className = "changelog-splash-hero";
|
||||
const kickerEl = document.createElement("div");
|
||||
kickerEl.className = "changelog-splash-kicker";
|
||||
kickerEl.textContent = "Mid-release update";
|
||||
const titleEl = document.createElement("div");
|
||||
titleEl.className = "changelog-splash-title";
|
||||
const metaEl = document.createElement("div");
|
||||
metaEl.className = "changelog-splash-meta";
|
||||
heroEl.appendChild(kickerEl);
|
||||
heroEl.appendChild(titleEl);
|
||||
heroEl.appendChild(metaEl);
|
||||
const sectionListEl = document.createElement("div");
|
||||
sectionListEl.className = "changelog-splash-list";
|
||||
const footerEl = document.createElement("div");
|
||||
footerEl.className = "changelog-splash-footer";
|
||||
const footnoteEl = document.createElement("div");
|
||||
footnoteEl.className = "changelog-splash-footnote";
|
||||
footnoteEl.textContent = "Major features and fixes only. Removed experiments are intentionally omitted.";
|
||||
const actionBtnEl = document.createElement("button");
|
||||
actionBtnEl.type = "button";
|
||||
actionBtnEl.className = "mini-btn";
|
||||
actionBtnEl.textContent = "Let's go";
|
||||
actionBtnEl.addEventListener("click", () => {
|
||||
close();
|
||||
});
|
||||
footerEl.appendChild(footnoteEl);
|
||||
footerEl.appendChild(actionBtnEl);
|
||||
cardEl.appendChild(heroEl);
|
||||
cardEl.appendChild(sectionListEl);
|
||||
cardEl.appendChild(footerEl);
|
||||
bodyEl.appendChild(cardEl);
|
||||
const resizeEl = document.createElement("div");
|
||||
resizeEl.className = "tool-popout-resize";
|
||||
const closeBtnEl = titlebarEl.querySelector<HTMLButtonElement>(".tool-popout-close-btn");
|
||||
shellEl.appendChild(titlebarEl);
|
||||
shellEl.appendChild(bodyEl);
|
||||
shellEl.appendChild(resizeEl);
|
||||
|
||||
shellEl.addEventListener("pointerdown", () => {
|
||||
focusWindow();
|
||||
});
|
||||
titlebarEl.addEventListener("pointerdown", (event: PointerEvent) => {
|
||||
if (closeBtnEl && event.target instanceof Node && closeBtnEl.contains(event.target)) {
|
||||
return;
|
||||
}
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
focusWindow();
|
||||
const layerRect = getLayerRect();
|
||||
const originLeft = Number(state.x) || 0;
|
||||
const originTop = Number(state.y) || 0;
|
||||
const startX = event.clientX;
|
||||
const startY = event.clientY;
|
||||
const move = (moveEvent: PointerEvent) => {
|
||||
const nextRect = clampWindowRect(
|
||||
layerRect,
|
||||
originLeft + (moveEvent.clientX - startX),
|
||||
originTop + (moveEvent.clientY - startY),
|
||||
state.width,
|
||||
state.height,
|
||||
);
|
||||
state.x = nextRect.left;
|
||||
state.y = nextRect.top;
|
||||
applyWindowRect();
|
||||
};
|
||||
const up = () => {
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", up);
|
||||
persistState();
|
||||
};
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", up);
|
||||
});
|
||||
closeBtnEl?.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
close();
|
||||
});
|
||||
resizeEl.addEventListener("pointerdown", (event: PointerEvent) => {
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
focusWindow();
|
||||
const layerRect = getLayerRect();
|
||||
const startX = event.clientX;
|
||||
const startY = event.clientY;
|
||||
const originWidth = Number(state.width) || DEFAULT_WIDTH;
|
||||
const originHeight = Number(state.height) || DEFAULT_HEIGHT;
|
||||
const move = (moveEvent: PointerEvent) => {
|
||||
const nextRect = clampWindowRect(
|
||||
layerRect,
|
||||
state.x,
|
||||
state.y,
|
||||
Math.max(MIN_WIDTH, originWidth + (moveEvent.clientX - startX)),
|
||||
Math.max(MIN_HEIGHT, originHeight + (moveEvent.clientY - startY)),
|
||||
);
|
||||
state.width = nextRect.width;
|
||||
state.height = nextRect.height;
|
||||
applyWindowRect();
|
||||
};
|
||||
const up = () => {
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", up);
|
||||
persistState();
|
||||
};
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", up);
|
||||
});
|
||||
|
||||
state.shellEl = shellEl;
|
||||
state.titleEl = titleEl;
|
||||
state.metaEl = metaEl;
|
||||
state.sectionListEl = sectionListEl;
|
||||
state.actionBtnEl = actionBtnEl;
|
||||
state.resizeEl = resizeEl;
|
||||
uiScope.toolWindowLayerEl?.appendChild(shellEl);
|
||||
applyWindowRect();
|
||||
shellEl.classList.toggle("hidden", state.visible !== true);
|
||||
refresh();
|
||||
return shellEl;
|
||||
}
|
||||
|
||||
function open(options: OpenOptions = {}) {
|
||||
ensureShell();
|
||||
if (options.center === true) {
|
||||
centerWindow();
|
||||
} else {
|
||||
const nextRect = clampWindowRect(getLayerRect(), state.x, state.y, state.width, state.height);
|
||||
state.x = nextRect.left;
|
||||
state.y = nextRect.top;
|
||||
state.width = nextRect.width;
|
||||
state.height = nextRect.height;
|
||||
}
|
||||
state.visible = true;
|
||||
if (options.markSeen !== false) {
|
||||
markSeen();
|
||||
}
|
||||
refresh();
|
||||
state.shellEl?.classList.remove("hidden");
|
||||
applyWindowRect();
|
||||
focusWindow();
|
||||
persistState();
|
||||
return true;
|
||||
}
|
||||
|
||||
function close() {
|
||||
state.visible = false;
|
||||
clearFocus();
|
||||
state.shellEl?.classList.add("hidden");
|
||||
persistState();
|
||||
return true;
|
||||
}
|
||||
|
||||
function maybeOpenForCurrentVersion() {
|
||||
if (hasSeenCurrentVersion()) {
|
||||
return false;
|
||||
}
|
||||
return open({ markSeen: true, center: true });
|
||||
}
|
||||
|
||||
function initialize() {
|
||||
if (initialized) {
|
||||
return;
|
||||
}
|
||||
initialized = true;
|
||||
ensureShell();
|
||||
window.addEventListener("resize", () => {
|
||||
const nextRect = clampWindowRect(getLayerRect(), state.x, state.y, state.width, state.height);
|
||||
state.x = nextRect.left;
|
||||
state.y = nextRect.top;
|
||||
state.width = nextRect.width;
|
||||
state.height = nextRect.height;
|
||||
applyWindowRect();
|
||||
persistState();
|
||||
});
|
||||
state.visible = false;
|
||||
state.shellEl?.classList.add("hidden");
|
||||
}
|
||||
|
||||
return {
|
||||
initialize,
|
||||
open,
|
||||
close,
|
||||
refresh,
|
||||
maybeOpenForCurrentVersion,
|
||||
isOpen: () => state.visible === true,
|
||||
version: CHANGELOG_SPLASH_VERSION,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue