471 lines
16 KiB
TypeScript
471 lines
16 KiB
TypeScript
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 = `worldshaper:studio: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,
|
|
};
|
|
}
|
|
|