var desktopModePostsWindow = function(exports) { "use strict"; const TEXT_DOMAIN = "desktop-mode"; function i18n() { return window.wp?.i18n; } function __(text, domain = TEXT_DOMAIN) { return i18n()?.__(text, domain) ?? text; } function sprintf(format, ...args) { const impl = i18n()?.sprintf; if (impl) { return impl(format, ...args); } let i = 0; return format.replace(/%[sd]/g, () => String(args[i++] ?? "")); } const NONCE_HEADER = "X-WP-Nonce"; function injectRestNonce(input, init) { const nonce = readRestNonce(); if (!nonce) { return init; } const url = resolveUrl(input); if (!url || !isSameOriginRestUrl(url)) { return init; } const baseHeaders = init?.headers ?? (typeof Request !== "undefined" && input instanceof Request ? input.headers : void 0); const headers = new Headers(baseHeaders ?? {}); if (headers.has(NONCE_HEADER)) { return init; } headers.set(NONCE_HEADER, nonce); return { ...init ?? {}, headers }; } function readRestNonce() { if (typeof window === "undefined") { return void 0; } const cfg = window.desktopModeConfig; const value = cfg?.restNonce; return typeof value === "string" && value.length > 0 ? value : void 0; } function resolveUrl(input) { try { const base = typeof window !== "undefined" && window.location ? window.location.href : void 0; if (typeof input === "string") { return new URL(input, base); } if (input instanceof URL) { return input; } if (typeof Request !== "undefined" && input instanceof Request) { return new URL(input.url, base); } return null; } catch { return null; } } function isSameOriginRestUrl(url) { if (typeof window === "undefined" || !window.location || url.origin !== window.location.origin) { return false; } if (url.pathname.includes("/wp-json/")) { return true; } if (url.searchParams.has("rest_route")) { return true; } return false; } function trackedFetch(input, init, opts = {}) { const fn = window.wp?.desktop?.fetch; if (typeof fn === "function") { return fn(input, init, opts); } const finalInit = injectRestNonce(input, init); return fetch(input, finalInit); } const gravatarCache = /* @__PURE__ */ new Map(); async function resolveAvatarUrl(raw) { if (!raw) { return null; } let parsed; try { parsed = new URL(raw, window.location.href); } catch { return raw; } if (!/gravatar\.com$/i.test(parsed.hostname)) { return raw; } parsed.searchParams.delete("d"); parsed.searchParams.delete("s"); const cacheKey2 = parsed.toString(); const cached = gravatarCache.get(cacheKey2); if (cached !== void 0) { return cached instanceof Promise ? cached : cached; } const probeUrl = new URL(raw, window.location.href); probeUrl.searchParams.set("d", "blank"); const probe = new Promise((resolve) => { const img = new Image(); img.crossOrigin = "anonymous"; img.onload = () => { try { const canvas = document.createElement("canvas"); canvas.width = 1; canvas.height = 1; const ctx = canvas.getContext("2d", { willReadFrequently: true }); if (!ctx) { resolve(raw); return; } ctx.drawImage(img, 0, 0, 1, 1); const pixel = ctx.getImageData(0, 0, 1, 1).data; resolve(pixel[3] === 0 ? null : raw); } catch { resolve(raw); } }; img.onerror = () => resolve(null); img.src = probeUrl.toString(); }).then((next) => { gravatarCache.set(cacheKey2, next); return next; }); gravatarCache.set(cacheKey2, probe); return probe; } function applyAvatarSrc(avatar, raw) { if (!raw) { return; } void resolveAvatarUrl(raw).then((url) => { if (!avatar.isConnected) { return; } if (url) { avatar.setAttribute("src", url); } else { avatar.removeAttribute("src"); } }); } const ROOT_ID = "__root__"; const PALETTE = [ 2257329, // wp blue 8141549, // violet 366185, // emerald 14362487, // pink 15357964, // orange 561586 // cyan ]; function buildSeedTree() { const seeds = [ { id: "science", name: __("Science"), parent: ROOT_ID }, { id: "biology", name: __("Biology"), parent: "science" }, { id: "astronomy", name: __("Astronomy"), parent: "science" }, { id: "physics", name: __("Physics"), parent: "science" }, { id: "society", name: __("Society"), parent: ROOT_ID }, { id: "economics", name: __("Economics"), parent: "society" }, { id: "politics", name: __("Politics"), parent: "society" }, { id: "culture", name: __("Culture"), parent: ROOT_ID }, { id: "music", name: __("Music"), parent: "culture" }, { id: "cinema", name: __("Cinema"), parent: "culture" } ]; const map = /* @__PURE__ */ new Map(); seeds.forEach((s, i) => { map.set(s.id, { id: s.id, name: s.name, parent: s.parent, color: PALETTE[i % PALETTE.length], radius: s.parent === ROOT_ID ? 34 : 24, x: 0, y: 0, vx: 0, vy: 0, tx: 0, ty: 0, gfx: null, label: null, dragging: false, ...makeFloatPhase(i, 4, 3.5) }); }); return map; } function makeFloatPhase(seed, ampX, ampY) { const r = (n) => { const x = Math.sin(seed * 9301 + n * 49297) * 233280; return x - Math.floor(x); }; return { phaseX: r(1) * Math.PI * 2, phaseY: r(2) * Math.PI * 2, // 0.0006–0.0012 rad/ms ≈ 5–10 second periods. freqX: 6e-4 + r(3) * 6e-4, freqY: 6e-4 + r(4) * 6e-4, ampX, ampY }; } const TAG_SEEDS = [ { id: "t-wp", name: "wordpress", count: 42, hue: 210 }, { id: "t-design", name: "design", count: 28, hue: 280 }, { id: "t-code", name: "code", count: 33, hue: 145 }, { id: "t-photo", name: "photo", count: 22, hue: 320 }, { id: "t-news", name: "news", count: 19, hue: 10 } ]; const TAG_FONT_MIN = 11; const TAG_FONT_MAX = 16; const TAG_PAD_X = 9; const TAG_PAD_Y = 4; const TAG_GAP_HASH = 3; const TAG_GAP_COUNT = 6; function fontSizeFor$1(count, max) { if (max <= 0) { return TAG_FONT_MIN; } const t = Math.min(1, count / max); return TAG_FONT_MIN + (TAG_FONT_MAX - TAG_FONT_MIN) * t; } function darkenColor(color, factor) { const r = Math.round(Math.floor(color / 65536) * factor); const g = Math.round(Math.floor(color % 65536 / 256) * factor); const b = Math.round(color % 256 * factor); return r * 65536 + g * 256 + b; } function hslToInt$2(h, s, l) { const sat = s / 100; const lig = l / 100; const c = (1 - Math.abs(2 * lig - 1)) * sat; const hp = (h % 360 + 360) % 360 / 60; const xCol = c * (1 - Math.abs(hp % 2 - 1)); let r = 0; let g = 0; let b = 0; if (hp < 1) { r = c; g = xCol; } else if (hp < 2) { r = xCol; g = c; } else if (hp < 3) { g = c; b = xCol; } else if (hp < 4) { g = xCol; b = c; } else if (hp < 5) { r = xCol; b = c; } else { r = c; b = xCol; } const m = lig - c / 2; const R = Math.round((r + m) * 255); const G = Math.round((g + m) * 255); const B = Math.round((b + m) * 255); return R * 65536 + G * 256 + B; } function isDescendant(nodes, candidateId, targetId) { if (candidateId === targetId) { return true; } let cur = candidateId; const visited = /* @__PURE__ */ new Set(); while (cur && !visited.has(cur)) { visited.add(cur); const n = nodes.get(cur); if (!n) { return false; } if (n.parent === targetId) { return true; } cur = n.parent; } return false; } function layoutTree(nodes, width, height) { const cx = width / 2; const cy = height * 0.4; const roots = Array.from(nodes.values()).filter((n) => n.parent === ROOT_ID); const mindmapH = height * 0.62; const rootR = Math.min(width, mindmapH) * 0.22; roots.forEach((root, i) => { const angle = i / Math.max(1, roots.length) * Math.PI * 2 - Math.PI / 2; root.tx = cx + Math.cos(angle) * rootR; root.ty = cy + Math.sin(angle) * rootR; layoutChildren(nodes, root, angle); }); } function layoutTags(tags, width, height) { const bandTop = height * 0.72; const bandH = height * 0.26; const bandCy = bandTop + bandH / 2; const gap = 8; const rows = [[]]; let rowW = 0; tags.forEach((t) => { const w = t.width || 60; if (rowW + w + gap > width - 24 && rows[rows.length - 1].length > 0) { rows.push([]); rowW = 0; } rows[rows.length - 1].push(t); rowW += w + gap; }); const rowSpacing = 38; const totalRowsH = rows.length * rowSpacing - rowSpacing; const startY = bandCy - totalRowsH / 2; rows.forEach((row, rIdx) => { const total = row.reduce((acc, t) => acc + (t.width || 60), 0) + gap * Math.max(0, row.length - 1); let cursor = (width - total) / 2; row.forEach((t) => { const w = t.width || 60; t.tx = cursor + w / 2; t.ty = startY + rIdx * rowSpacing; cursor += w + gap; }); }); } function layoutChildren(nodes, parent, parentAngle) { const children = Array.from(nodes.values()).filter( (n) => n.parent === parent.id ); if (children.length === 0) { return; } const spread = Math.PI * 0.9; const baseAngle = parentAngle; const step = children.length === 1 ? 0 : spread / (children.length - 1); const start = baseAngle - spread / 2; const r = 95; children.forEach((child, i) => { const a = children.length === 1 ? baseAngle : start + step * i; child.tx = parent.tx + Math.cos(a) * r; child.ty = parent.ty + Math.sin(a) * r; layoutChildren(nodes, child, a); }); } function layoutTagChip(chip) { chip.hashText.style.fontSize = chip.fontSize; chip.nameText.style.fontSize = chip.fontSize; chip.countText.style.fontSize = Math.max(9, Math.round(chip.fontSize * 0.6)); const hashW = chip.hashText.width; const nameW = chip.nameText.width; const nameH = chip.nameText.height; const countW = chip.countText.width; const countH = chip.countText.height; const countBadgeW = Math.max(16, countW + 8); const countBadgeH = Math.max(13, countH + 3); chip.width = TAG_PAD_X + hashW + TAG_GAP_HASH + nameW + TAG_GAP_COUNT + countBadgeW + TAG_PAD_X; chip.height = Math.max(nameH, countBadgeH) + TAG_PAD_Y * 2; } function paintTagChip(chip) { const totalW = chip.width; const totalH = chip.height; const left = -totalW / 2; const top = -totalH / 2; const radius = totalH / 2; const fillBg = chip.hover ? hslToInt$2(chip.hue, 70, 88) : hslToInt$2(chip.hue, 60, 95); const borderColor = hslToInt$2(chip.hue, 50, 70); const textColor = 1909543; const hashColor = hslToInt$2(chip.hue, 65, 42); const countBg = hslToInt$2(chip.hue, 70, 50); chip.bg.clear(); chip.bg.roundRect(left, top, totalW, totalH, radius); chip.bg.fill(fillBg); chip.bg.stroke({ color: borderColor, width: chip.hover ? 1.6 : 1.2, alpha: 0.85 }); const hashW = chip.hashText.width; const nameW = chip.nameText.width; const nameH = chip.nameText.height; const countW = chip.countText.width; const countH = chip.countText.height; const countBadgeW = Math.max(16, countW + 8); const countBadgeH = Math.max(13, countH + 3); chip.hashText.x = left + TAG_PAD_X; chip.hashText.y = (totalH - nameH) / 2 + top; chip.hashText.style.fill = hashColor; chip.nameText.x = left + TAG_PAD_X + hashW + TAG_GAP_HASH; chip.nameText.y = (totalH - nameH) / 2 + top; chip.nameText.style.fill = textColor; const badgeX = left + TAG_PAD_X + hashW + TAG_GAP_HASH + nameW + TAG_GAP_COUNT; const badgeY = (totalH - countBadgeH) / 2 + top; chip.bg.roundRect(badgeX, badgeY, countBadgeW, countBadgeH, countBadgeH / 2); chip.bg.fill(countBg); chip.countText.x = badgeX + (countBadgeW - countW) / 2; chip.countText.y = badgeY + (countBadgeH - countH) / 2; } function renderFallback(stage) { stage.replaceChildren(); const note = document.createElement("p"); note.className = "wpd-intro__fallback"; note.textContent = __( "A new visual editor for Categories and Tags awaits inside — drag, drop, and reorganize your taxonomy in seconds." ); stage.appendChild(note); } async function showPostsIntroDialog() { return new Promise((resolve) => { const backdrop = document.createElement("div"); backdrop.className = "wpd-intro-backdrop"; const dialog = document.createElement("div"); dialog.className = "wpd-intro"; dialog.setAttribute("role", "dialog"); dialog.setAttribute("aria-modal", "true"); dialog.setAttribute("aria-labelledby", "wpd-intro-title"); dialog.tabIndex = -1; backdrop.appendChild(dialog); const titleEl = document.createElement("h2"); titleEl.id = "wpd-intro-title"; titleEl.className = "wpd-intro__title"; titleEl.textContent = __("Welcome to the new Posts"); dialog.appendChild(titleEl); const lede = document.createElement("p"); lede.className = "wpd-intro__lede"; lede.textContent = __( "A redesigned Posts experience built around how you actually work. Try the new Categories canvas — grab a node and drop it on another to reparent it." ); dialog.appendChild(lede); const stage = document.createElement("div"); stage.className = "wpd-intro__stage"; dialog.appendChild(stage); const escape = document.createElement("p"); escape.className = "wpd-intro__escape"; escape.textContent = __( "Prefer the classic Posts list? You can switch back any time from OS Settings → Features." ); dialog.appendChild(escape); const actions = document.createElement("div"); actions.className = "wpd-intro__actions"; const settingsBtn = document.createElement("button"); settingsBtn.type = "button"; settingsBtn.className = "wpd-intro__btn wpd-intro__btn--secondary"; settingsBtn.textContent = __("Take me to settings"); const confirmBtn = document.createElement("button"); confirmBtn.type = "button"; confirmBtn.className = "wpd-intro__btn wpd-intro__btn--primary"; confirmBtn.textContent = __("Got it"); actions.appendChild(settingsBtn); actions.appendChild(confirmBtn); dialog.appendChild(actions); document.body.appendChild(backdrop); let teardownPixi = null; const cleanup = (result) => { document.removeEventListener("keydown", onKey); teardownPixi?.(); backdrop.remove(); resolve(result); }; const onKey = (e) => { if (e.key === "Escape") { e.preventDefault(); cleanup("cancel"); } }; document.addEventListener("keydown", onKey); confirmBtn.addEventListener("click", () => cleanup("confirm")); settingsBtn.addEventListener("click", () => cleanup("settings")); backdrop.addEventListener("click", (e) => { if (e.target === backdrop) { cleanup("cancel"); } }); requestAnimationFrame(() => dialog.focus()); void mountPixi(stage).then((teardown) => { teardownPixi = teardown; }).catch(() => { renderFallback(stage); }); }); } async function mountPixi(stage) { const api = window.wp?.desktop; if (!api || typeof api.loadModules !== "function") { renderFallback(stage); return () => { }; } try { await api.loadModules(["pixijs"]); } catch { renderFallback(stage); return () => { }; } const pixiMaybe = window.PIXI; if (!pixiMaybe) { renderFallback(stage); return () => { }; } const pixi = pixiMaybe; const app = new pixi.Application(); await app.init({ resizeTo: stage, backgroundAlpha: 0, antialias: true, autoDensity: true, resolution: Math.min(window.devicePixelRatio || 1, 2) }); stage.appendChild(app.canvas); app.canvas.classList.add("wpd-intro__canvas"); const world = new pixi.Container(); world.sortableChildren = true; world.scale.set(1); app.stage.addChild(world); const edgeLayer = new pixi.Container(); const nodeLayer = new pixi.Container(); const tagLayer = new pixi.Container(); const postLayer = new pixi.Container(); edgeLayer.zIndex = 1; nodeLayer.zIndex = 2; tagLayer.zIndex = 3; postLayer.zIndex = 5; world.addChild(edgeLayer); world.addChild(postLayer); world.addChild(nodeLayer); world.addChild(tagLayer); const nodes = buildSeedTree(); nodes.forEach((n) => { const gfx = new pixi.Graphics(); gfx.eventMode = "static"; gfx.cursor = "grab"; const label = new pixi.Text({ text: n.name, style: { fill: 16777215, fontSize: 12, fontWeight: "600", fontFamily: "system-ui, -apple-system, sans-serif" }, resolution: 3, anchor: { x: 0.5, y: 0.5 } }); gfx.addChild(label); n.gfx = gfx; n.label = label; nodeLayer.addChild(gfx); }); const tags = []; const maxTagCount = TAG_SEEDS.reduce((m, t) => Math.max(m, t.count), 0); TAG_SEEDS.forEach((seed, i) => { const container = new pixi.Container(); container.eventMode = "static"; container.cursor = "grab"; const bg = new pixi.Graphics(); const fontSize = fontSizeFor$1(seed.count, maxTagCount); const hashText = new pixi.Text({ text: "#", style: { fill: 1909543, fontSize, fontWeight: "600", fontFamily: "system-ui, -apple-system, sans-serif" }, resolution: 3, anchor: { x: 0, y: 0 } }); const nameText = new pixi.Text({ text: seed.name, style: { fill: 1909543, fontSize, fontWeight: "600", fontFamily: "system-ui, -apple-system, sans-serif" }, resolution: 3, anchor: { x: 0, y: 0 } }); const countText = new pixi.Text({ text: String(seed.count), style: { fill: 16777215, fontSize: Math.max(9, Math.round(fontSize * 0.6)), fontWeight: "700", fontFamily: "system-ui, -apple-system, sans-serif" }, resolution: 3, anchor: { x: 0, y: 0 } }); container.addChild(bg, hashText, nameText, countText); tagLayer.addChild(container); const chip = { id: seed.id, name: seed.name, count: seed.count, hue: seed.hue, fontSize, width: 0, height: 0, x: 0, y: 0, tx: 0, ty: 0, bg, hashText, nameText, countText, container, dragging: false, hover: false, ...makeFloatPhase(100 + i, 5, 4) }; layoutTagChip(chip); paintTagChip(chip); tags.push(chip); }); let stageW = stage.clientWidth || 600; let stageH = stage.clientHeight || 360; layoutTree(nodes, stageW, stageH); layoutTags(tags, stageW, stageH); const cx0 = stageW / 2; const cy0 = stageH * 0.4; nodes.forEach((n) => { n.x = cx0; n.y = cy0; }); tags.forEach((t) => { t.x = t.tx; t.y = stageH + 40; }); const drawNode = (n, hovered, dropTarget) => { n.gfx.clear(); const r = n.radius * (hovered ? 1.08 : 1); if (dropTarget) { n.gfx.circle(0, 0, r + 10).fill({ color: n.color, alpha: 0.18 }); } n.gfx.circle(0, 0, r).fill({ color: n.color, alpha: 0.95 }).stroke({ color: 16777215, width: dropTarget ? 3 : 1.5, alpha: 0.9 }); const labelW = n.label.width; const labelH = n.label.height; if (labelW + 6 > r * 2) { const padX = 8; const padY = 3; const capW = labelW + padX * 2; const capH = labelH + padY * 2; n.gfx.roundRect(-capW / 2, -capH / 2, capW, capH, capH / 2).fill({ color: darkenColor(n.color, 0.55), alpha: 0.92 }); } n.gfx.x = n.x; n.gfx.y = n.y; }; const drawEdges = () => { const edgeLayerWithChildren = edgeLayer; const previousChildren = edgeLayerWithChildren.children.slice(); previousChildren.forEach((c) => edgeLayer.removeChild(c)); const edge = new pixi.Graphics(); nodes.forEach((n) => { if (!n.parent || n.parent === ROOT_ID) { return; } const parent = nodes.get(n.parent); if (!parent) { return; } const dx = n.x - parent.x; const cp1x = parent.x + dx * 0.5; const cp1y = parent.y; const cp2x = parent.x + dx * 0.5; const cp2y = n.y; edge.moveTo(parent.x, parent.y); edge.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, n.x, n.y); }); edge.stroke({ color: 9741240, width: 1.6, alpha: 0.55 }); edgeLayer.addChild(edge); }; const POSTS_BY_TAG = { "t-wp": [{ node: "politics", title: __("WordPress at scale") }, { node: "economics", title: __("Plugins economy") }, { node: "astronomy", title: __("Open-source orbits") }], "t-design": [{ node: "cinema", title: __("Title cards reborn") }, { node: "music", title: __("Album art trends") }, { node: "culture", title: __("Type as identity") }], "t-code": [{ node: "physics", title: __("Sim notebooks") }, { node: "astronomy", title: __("Pixel pipelines") }, { node: "science", title: __("Code as method") }], "t-photo": [{ node: "cinema", title: __("Anamorphic notes") }, { node: "biology", title: __("Field portraits") }, { node: "culture", title: __("Sunday playlist") }], "t-news": [{ node: "politics", title: __("Weekly briefing") }, { node: "economics", title: __("Markets recap") }] }; let fakePosts = []; const POSTS_BY_CATEGORY = { science: [__("What we learned"), __("Open questions"), __("Methodology notes"), __("Replication study")], biology: [__("Fieldwork log"), __("Cell shapes"), __("Microscope diary")], botany: [__("Pressed leaves"), __("Greenhouse notes"), __("Native species")], zoology: [__("Migration map"), __("Birding weekend"), __("Tracks at dawn")], astronomy: [__("Comet schedule"), __("Backyard telescope"), __("Lunar tides")], physics: [__("Lab notebook"), __("Toy models"), __("Phase transitions")], society: [__("Sunday digest"), __("Local elections"), __("Reader letters")], economics: [__("Macro recap"), __("Numbers I noticed"), __("Market mood")], macro: [__("Inflation trail"), __("Central banks")], micro: [__("Pricing tactics"), __("Coffee shop economics")], politics: [__("Campaign trail"), __("Town hall notes"), __("Policy explainer")], culture: [__("Type as identity"), __("Sunday playlist"), __("City walks")], music: [__("Liner notes"), __("Live this week"), __("Album re-listen")], cinema: [__("Title cards reborn"), __("Director cut"), __("Set on the road")], drama: [__("Three-act notes"), __("Stage to screen")], "sci-fi": [__("Anamorphic notes"), __("Future-proof tropes"), __("Worldbuilding 101")] }; const clearFakePosts = () => { fakePosts.forEach((p) => { try { postLayer.removeChild(p.container); p.container.destroy({ children: true }); } catch { } }); fakePosts = []; }; const buildPostChip = (title, anchorKind, anchorId, accentColor, angle, orbit, originX, originY, spawnedAt) => { const container = new pixi.Container(); container.alpha = 0; container.x = originX; container.y = originY; const bg = new pixi.Graphics(); const text = new pixi.Text({ text: title, style: { fill: 1909543, fontSize: 10, fontFamily: "system-ui, -apple-system, sans-serif" }, resolution: 3, anchor: { x: 0, y: 0 } }); container.addChild(bg, text); postLayer.addChild(container); return { title, anchorKind, anchorId, accentColor, angle, orbit, originX, originY, container, bg, text, spawnedAt }; }; const spawnFakePostsFromTag = (tag) => { clearFakePosts(); const list = POSTS_BY_TAG[tag.id]; if (!list) { return; } const now = performance.now(); const ox = tag.container.x; const oy = tag.container.y; const accent = hslToInt$2(tag.hue, 70, 50); const titles = list.map((p) => p.title); const spread = Math.PI * 1.2; const baseAngle = -Math.PI / 2; const step = titles.length === 1 ? 0 : spread / (titles.length - 1); const start = baseAngle - spread / 2; const orbitR = 56 + Math.min(16, titles.length * 2); titles.forEach((title, i) => { const angle = titles.length === 1 ? baseAngle : start + step * i; fakePosts.push( buildPostChip( title, "tag", tag.id, accent, angle, orbitR + i % 2 * 6, ox, oy, now ) ); }); }; const spawnFakePostsFromCategory = (node) => { clearFakePosts(); const titles = POSTS_BY_CATEGORY[node.id]; if (!titles || titles.length === 0) { return; } const now = performance.now(); const ox = node.gfx.x; const oy = node.gfx.y; const spread = Math.PI * 1.6; const start = -Math.PI / 2 - spread / 2; const step = titles.length === 1 ? 0 : spread / (titles.length - 1); titles.forEach((title, i) => { const angle = titles.length === 1 ? -Math.PI / 2 : start + step * i; fakePosts.push( buildPostChip( title, "node", node.id, node.color, angle, 78 + i % 3 * 8, ox, oy, now ) ); }); }; let dragging = null; let pointerStart = { x: 0, y: 0 }; let nodeStart = { x: 0, y: 0 }; let hoverDrop = null; let dragTag = null; let tagDragStart = { x: 0, y: 0 }; let tagStart = { x: 0, y: 0 }; nodes.forEach((n) => { n.gfx.on("pointerdown", (raw) => { const e = raw; dragging = n; n.dragging = true; pointerStart = { x: e.global.x, y: e.global.y }; nodeStart = { x: n.x, y: n.y }; n.gfx.cursor = "grabbing"; n.gfx.zIndex = 1e3; drawNode(n, true, false); }); n.gfx.on("pointerover", () => { if (dragging || dragTag) { return; } drawNode(n, true, false); spawnFakePostsFromCategory(n); }); n.gfx.on("pointerout", () => { if (dragging !== n) { drawNode(n, false, hoverDrop === n); } clearFakePosts(); }); }); tags.forEach((t) => { t.container.on("pointerdown", (raw) => { const e = raw; dragTag = t; t.dragging = true; tagDragStart = { x: e.global.x, y: e.global.y }; tagStart = { x: t.x, y: t.y }; t.container.cursor = "grabbing"; t.container.zIndex = 5e3; }); t.container.on("pointerover", () => { if (dragTag || dragging) { return; } t.hover = true; paintTagChip(t); spawnFakePostsFromTag(t); }); t.container.on("pointerout", () => { t.hover = false; paintTagChip(t); clearFakePosts(); }); }); const onMove = (e) => { const rect = app.canvas.getBoundingClientRect(); const px = e.clientX - rect.left; const py = e.clientY - rect.top; if (dragTag) { dragTag.x = tagStart.x + (px - tagDragStart.x); dragTag.y = tagStart.y + (py - tagDragStart.y); dragTag.container.x = dragTag.x; dragTag.container.y = dragTag.y; return; } if (!dragging) { return; } const dx = px - pointerStart.x; const dy = py - pointerStart.y; dragging.x = nodeStart.x + dx; dragging.y = nodeStart.y + dy; let hit = null; nodes.forEach((other) => { if (other === dragging) { return; } if (isDescendant(nodes, other.id, dragging.id)) { return; } const ddx = other.x - dragging.x; const ddy = other.y - dragging.y; if (Math.hypot(ddx, ddy) < other.radius + dragging.radius * 0.6) { hit = other; } }); if (hit !== hoverDrop) { if (hoverDrop) { drawNode(hoverDrop, false, false); } hoverDrop = hit; if (hoverDrop) { drawNode(hoverDrop, false, true); } } drawNode(dragging, true, false); }; const onUp = () => { if (dragTag) { dragTag.container.cursor = "grab"; dragTag.container.zIndex = 0; dragTag.dragging = false; dragTag = null; return; } if (!dragging) { return; } const drop = hoverDrop; if (drop && drop.id !== dragging.parent) { dragging.parent = drop.id; layoutTree(nodes, stageW, stageH); } dragging.gfx.cursor = "grab"; dragging.gfx.zIndex = 0; dragging.dragging = false; const dragged = dragging; dragging = null; if (hoverDrop) { drawNode(hoverDrop, false, false); hoverDrop = null; } drawNode(dragged, false, false); }; app.canvas.addEventListener("pointermove", onMove); window.addEventListener("pointerup", onUp); window.addEventListener("pointercancel", onUp); const tick = () => { const now = performance.now(); const REPULSION_K2 = 6500; const SPRING_K2 = 0.05; const SPRING_LEN2 = 110; const ANCHOR_K = 0.012; const DAMPING = 0.82; const MAX_V = 8; const list = Array.from(nodes.values()); const fxArr = new Array(list.length).fill(0); const fyArr = new Array(list.length).fill(0); for (let i = 0; i < list.length; i++) { const a = list[i]; if (a === dragging) { continue; } for (let j = i + 1; j < list.length; j++) { const b = list[j]; if (b === dragging) { continue; } const dx = b.x - a.x; const dy = b.y - a.y; const d2 = dx * dx + dy * dy + 0.01; const d = Math.sqrt(d2); const minD = a.radius + b.radius; if (d > minD * 4) { continue; } const f = REPULSION_K2 / d2; const fx = dx / d * f; const fy = dy / d * f; fxArr[i] -= fx; fyArr[i] -= fy; fxArr[j] += fx; fyArr[j] += fy; } } list.forEach((c, idx) => { if (!c.parent || c.parent === ROOT_ID) { return; } if (c === dragging) { return; } const parent = nodes.get(c.parent); if (!parent || parent === dragging) { return; } const pIdx = list.indexOf(parent); const dx = parent.x - c.x; const dy = parent.y - c.y; const d = Math.max(0.01, Math.sqrt(dx * dx + dy * dy)); const diff = d - SPRING_LEN2; const sx = dx / d * diff * SPRING_K2; const sy = dy / d * diff * SPRING_K2; fxArr[idx] += sx; fyArr[idx] += sy; if (pIdx >= 0) { fxArr[pIdx] -= sx; fyArr[pIdx] -= sy; } }); list.forEach((n, idx) => { fxArr[idx] += (n.tx - n.x) * ANCHOR_K; fyArr[idx] += (n.ty - n.y) * ANCHOR_K; }); list.forEach((n, idx) => { if (n === dragging) { n.vx = 0; n.vy = 0; return; } n.vx = (n.vx + fxArr[idx]) * DAMPING; n.vy = (n.vy + fyArr[idx]) * DAMPING; if (n.vx > MAX_V) { n.vx = MAX_V; } else if (n.vx < -MAX_V) { n.vx = -MAX_V; } if (n.vy > MAX_V) { n.vy = MAX_V; } else if (n.vy < -MAX_V) { n.vy = -MAX_V; } n.x += n.vx; n.y += n.vy; }); drawEdges(); nodes.forEach((n) => { const fx = n === dragging ? n.x : n.x + Math.sin(now * n.freqX + n.phaseX) * n.ampX; const fy = n === dragging ? n.y : n.y + Math.sin(now * n.freqY + n.phaseY) * n.ampY; drawNode(n, false, hoverDrop === n); n.gfx.x = fx; n.gfx.y = fy; }); tags.forEach((t) => { if (t === dragTag) { return; } t.x += (t.tx - t.x) * 0.16; t.y += (t.ty - t.y) * 0.16; const fx = t.x + Math.sin(now * t.freqX + t.phaseX) * t.ampX; const fy = t.y + Math.sin(now * t.freqY + t.phaseY) * t.ampY * 0.6; t.container.x = fx; t.container.y = fy; }); fakePosts.forEach((p, idx) => { let anchorX = 0; let anchorY = 0; if (p.anchorKind === "tag") { const t2 = tags.find((tg) => tg.id === p.anchorId); if (!t2) { return; } anchorX = t2.container.x; anchorY = t2.container.y; } else { const node = nodes.get(p.anchorId); if (!node) { return; } anchorX = node.gfx.x; anchorY = node.gfx.y; } const elapsed = now - p.spawnedAt; const t = Math.min(1, elapsed / 320); p.container.alpha = t; const wobble = Math.sin(now * 15e-4 + idx) * 4; const tx = anchorX + Math.cos(p.angle) * (p.orbit + wobble); const ty = anchorY + Math.sin(p.angle) * (p.orbit + wobble); p.container.x += (tx - p.container.x) * 0.16; p.container.y += (ty - p.container.y) * 0.16; const padX = 7; const padY = 3; const textW = p.text.width; const textH = p.text.height; const w = textW + padX * 2; const h = textH + padY * 2; p.text.x = -w / 2 + padX; p.text.y = -h / 2 + padY; p.bg.clear(); p.bg.roundRect(-w / 2, -h / 2, w, h, h / 2); p.bg.fill({ color: 16777215, alpha: 0.95 }); p.bg.stroke({ color: p.accentColor, width: 1.2, alpha: 0.85 }); }); const FIT_MARGIN = 24; const FIT_EASE = 0.08; let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; nodes.forEach((n) => { const dx = n.gfx.x; const dy = n.gfx.y; const r = n.radius + 8; if (dx - r < minX) { minX = dx - r; } if (dy - r < minY) { minY = dy - r; } if (dx + r > maxX) { maxX = dx + r; } if (dy + r > maxY) { maxY = dy + r; } }); tags.forEach((tg) => { const dx = tg.container.x; const dy = tg.container.y; const w = tg.width / 2 + 4; const h = tg.height / 2 + 4; if (dx - w < minX) { minX = dx - w; } if (dy - h < minY) { minY = dy - h; } if (dx + w > maxX) { maxX = dx + w; } if (dy + h > maxY) { maxY = dy + h; } }); const bw = maxX - minX; const bh = maxY - minY; if (bw > 0 && bh > 0 && Number.isFinite(bw) && Number.isFinite(bh)) { const sx = (stageW - FIT_MARGIN * 2) / bw; const sy = (stageH - FIT_MARGIN * 2) / bh; const targetScale = Math.max(0.55, Math.min(1, sx, sy)); const cx = (minX + maxX) / 2; const cy = (minY + maxY) / 2; const targetX = stageW / 2 - cx * targetScale; const targetY = stageH / 2 - cy * targetScale; world.x += (targetX - world.x) * FIT_EASE; world.y += (targetY - world.y) * FIT_EASE; const curScale = world.scale.x; world.scale.set(curScale + (targetScale - curScale) * FIT_EASE); } }; app.ticker.add(tick); const ro = new ResizeObserver(() => { stageW = stage.clientWidth || stageW; stageH = stage.clientHeight || stageH; layoutTree(nodes, stageW, stageH); layoutTags(tags, stageW, stageH); }); ro.observe(stage); return () => { ro.disconnect(); app.ticker.remove(tick); app.canvas.removeEventListener("pointermove", onMove); window.removeEventListener("pointerup", onUp); window.removeEventListener("pointercancel", onUp); clearFakePosts(); try { app.destroy(true, { children: true }); } catch { } }; } function html(strings, ...values) { return { __wpdHtml: true, strings, values }; } function isTemplateResult$1(v) { return !!v && v.__wpdHtml === true; } const MARKER_PREFIX = "$$wpd$$"; const MARKER_RE = /\$\$wpd\$\$(\d+)\$\$/g; function joinWithMarkers(strings) { let out = strings[0]; for (let i = 1; i < strings.length; i++) { out += `${MARKER_PREFIX}${i - 1}$$` + strings[i]; } return out; } const compiledCache = /* @__PURE__ */ new WeakMap(); function compile(strings) { const cached = compiledCache.get(strings); if (cached) { return cached; } const template = document.createElement("template"); template.innerHTML = joinWithMarkers(strings); const recipes = []; const walk = (node, path) => { if (node.nodeType === Node.ELEMENT_NODE) { const el = node; for (const attr of Array.from(el.attributes)) { const rawName = attr.name; const rawValue = attr.value; const prefix = rawName[0]; if (MARKER_RE.test(rawValue)) { MARKER_RE.lastIndex = 0; if (prefix === "@") { const match = MARKER_RE.exec(rawValue); MARKER_RE.lastIndex = 0; recipes.push({ path, kind: "event", name: rawName.slice(1), valueIndex: match ? Number(match[1]) : 0 }); el.removeAttribute(rawName); } else if (prefix === ".") { const match = MARKER_RE.exec(rawValue); MARKER_RE.lastIndex = 0; recipes.push({ path, kind: "prop", name: rawName.slice(1), valueIndex: match ? Number(match[1]) : 0 }); el.removeAttribute(rawName); } else if (prefix === "?") { const match = MARKER_RE.exec(rawValue); MARKER_RE.lastIndex = 0; recipes.push({ path, kind: "bool", name: rawName.slice(1), valueIndex: match ? Number(match[1]) : 0 }); el.removeAttribute(rawName); } else { const fragments = []; const indices = []; let lastEnd = 0; let m; MARKER_RE.lastIndex = 0; while ((m = MARKER_RE.exec(rawValue)) !== null) { fragments.push(rawValue.slice(lastEnd, m.index)); indices.push(Number(m[1])); lastEnd = m.index + m[0].length; } fragments.push(rawValue.slice(lastEnd)); recipes.push({ path, kind: "attr", name: rawName, template: fragments, valueIndices: indices }); el.setAttribute(rawName, ""); } } } } const children = Array.from(node.childNodes); let shift = 0; for (let i = 0; i < children.length; i++) { const child = children[i]; const liveIndex = i + shift; if (child.nodeType === Node.TEXT_NODE) { const text = child.textContent || ""; if (!MARKER_RE.test(text)) { MARKER_RE.lastIndex = 0; continue; } MARKER_RE.lastIndex = 0; const parent = child.parentNode; let lastEnd = 0; let m; const newNodes = []; const newRecipes = []; MARKER_RE.lastIndex = 0; while ((m = MARKER_RE.exec(text)) !== null) { if (m.index > lastEnd) { newNodes.push(document.createTextNode(text.slice(lastEnd, m.index))); } const placeholder = document.createTextNode(""); newNodes.push(placeholder); newRecipes.push({ path: [...path, liveIndex + newNodes.length - 1], kind: "node", valueIndex: Number(m[1]) }); lastEnd = m.index + m[0].length; } if (lastEnd < text.length) { newNodes.push(document.createTextNode(text.slice(lastEnd))); } for (const nn of newNodes) { parent.insertBefore(nn, child); } parent.removeChild(child); shift += newNodes.length - 1; recipes.push(...newRecipes); } else { walk(child, [...path, liveIndex]); } } }; walk(template.content, []); const buildParts = (fragment) => { const out = []; for (const r of recipes) { let node = fragment; for (const idx of r.path) { node = node.childNodes[idx]; } if (r.kind === "node") { out.push({ kind: "node", valueIndex: r.valueIndex, child: { anchor: node, state: null } }); } else if (r.kind === "attr") { out.push({ kind: "attr", element: node, name: r.name, template: r.template, valueIndices: r.valueIndices }); } else if (r.kind === "event") { out.push({ kind: "event", valueIndex: r.valueIndex, element: node, name: r.name }); } else if (r.kind === "prop") { out.push({ kind: "prop", valueIndex: r.valueIndex, element: node, name: r.name }); } else if (r.kind === "bool") { out.push({ kind: "bool", valueIndex: r.valueIndex, element: node, name: r.name }); } } return out; }; const entry = { template, buildParts }; compiledCache.set(strings, entry); return entry; } const mountState = /* @__PURE__ */ new WeakMap(); function render(result, container) { const existing = mountState.get(container); if (existing && existing.strings === result.strings) { applyValues(existing.parts, result.values); return; } const compiled = compile(result.strings); const fragment = compiled.template.content.cloneNode(true); const parts = compiled.buildParts(fragment); while (container.firstChild) { container.removeChild(container.firstChild); } container.appendChild(fragment); applyValues(parts, result.values); mountState.set(container, { strings: result.strings, parts }); } function applyValues(parts, values) { for (const part of parts) { if (part.kind === "node") { updateChildPart(part.child, values[part.valueIndex]); } else if (part.kind === "attr") { let composed = part.template[0]; for (let i = 0; i < part.valueIndices.length; i++) { composed += formatText(values[part.valueIndices[i]]); composed += part.template[i + 1]; } if (composed !== part.last) { part.last = composed; if (composed === "") { part.element.removeAttribute(part.name); } else { part.element.setAttribute(part.name, composed); } } } else if (part.kind === "event") { const next = values[part.valueIndex]; if (next !== part.current) { if (part.current) { part.element.removeEventListener(part.name, part.current); } if (next) { part.element.addEventListener(part.name, next); } part.current = next; } } else if (part.kind === "prop") { const next = values[part.valueIndex]; if (next !== part.last) { part.last = next; part.element[part.name] = next; } } else if (part.kind === "bool") { const next = !!values[part.valueIndex]; if (next !== part.last) { part.last = next; if (next) { part.element.setAttribute(part.name, ""); } else { part.element.removeAttribute(part.name); } } } } } function updateChildPart(child, value) { if (value === null || value === void 0 || value === false) { if (child.state) { disposeChildState(child.state); child.state = null; } return; } if (Array.isArray(value)) { updateArrayChild(child, value); return; } if (isTemplateResult$1(value)) { updateTemplateChild(child, value); return; } if (value instanceof Node) { updateNodeChild(child, value); return; } updateTextChild(child, formatText(value)); } function updateNodeChild(child, node) { const old = child.state; if (old?.shape === "node" && old.node === node) { return; } if (old) { disposeChildState(old); } insertBeforeAnchor(child, [node]); child.state = { shape: "node", node }; } function updateTextChild(child, text) { const old = child.state; if (old?.shape === "text") { if (old.text !== text) { old.node.textContent = text; old.text = text; } return; } if (old) { disposeChildState(old); } const node = document.createTextNode(text); insertBeforeAnchor(child, [node]); child.state = { shape: "text", node, text }; } function updateTemplateChild(child, result) { const old = child.state; if (old?.shape === "template" && old.strings === result.strings) { applyValues(old.parts, result.values); return; } if (old) { disposeChildState(old); } const compiled = compile(result.strings); const fragment = compiled.template.content.cloneNode(true); const parts = compiled.buildParts(fragment); const topNodes = Array.from(fragment.childNodes); insertBeforeAnchor(child, [fragment]); applyValues(parts, result.values); child.state = { shape: "template", strings: result.strings, parts, nodes: topNodes }; } function updateArrayChild(child, arr) { const old = child.state; if (old?.shape === "array" && old.entries.length === arr.length) { for (let i = 0; i < arr.length; i++) { updateChildPart(old.entries[i], arr[i]); } return; } if (old) { disposeChildState(old); } const entries = []; for (const v of arr) { const entryAnchor = document.createTextNode(""); insertBeforeAnchor(child, [entryAnchor]); const entry = { anchor: entryAnchor, state: null }; updateChildPart(entry, v); entries.push(entry); } child.state = { shape: "array", entries }; } function insertBeforeAnchor(child, nodes) { const parent = child.anchor.parentNode; if (!parent) { return; } for (const node of nodes) { parent.insertBefore(node, child.anchor); } } function disposeChildState(state) { if (state.shape === "text") { state.node.remove(); return; } if (state.shape === "template") { for (const node of state.nodes) { if (node.parentNode) { node.parentNode.removeChild(node); } } return; } if (state.shape === "node") { if (state.node.parentNode) { state.node.parentNode.removeChild(state.node); } return; } for (const entry of state.entries) { if (entry.state) { disposeChildState(entry.state); } entry.anchor.remove(); } } function formatText(v) { if (v === null || v === void 0 || v === false) { return ""; } return String(v); } const _Component = class _Component extends HTMLElement { constructor() { super(); this._renderScheduled = false; this._propValues = {}; const ctor = this.constructor; if (ctor.shadow) { this.attachShadow({ mode: "open" }); this._renderRoot = this.shadowRoot; } else { this._renderRoot = this; } this._installPropAccessors(); } static get observedAttributes() { return this.props.map(kebab); } connectedCallback() { this._adoptStyles(); this.requestUpdate(); } attributeChangedCallback(name, oldValue, newValue) { if (oldValue === newValue) { return; } const prop = camel(name); this._propValues[prop] = newValue; this.requestUpdate(); } /** * Declarative class-name setter. Assign an array (or a * space-separated string) and the host's `class` attribute is * rewritten to match. Intended for programmatic styling — when * a plugin has enqueued its own stylesheet and wants to apply * one of those classes to a shell component: * * ```js * element.classNames = [ 'my-plugin-brand', 'is-active' ]; * // → * ``` * * The plain HTML `class="…"` attribute works just the same and * is always preferred when writing markup by hand — this setter * exists for the JS-API case where the caller has an array of * conditional classes in hand. * * Getter returns the current `classList` as a plain array for * symmetric read/write. * * @since 0.13.0 */ get classNames() { return Array.from(this.classList); } set classNames(next) { if (next === null || next === void 0) { this.removeAttribute("class"); return; } const list = Array.isArray(next) ? next : String(next).split(/\s+/); const cleaned = list.map((s) => String(s).trim()).filter((s) => s !== ""); this.className = cleaned.join(" "); } /** * Request a re-render explicitly. Components rarely need this — * declare state via props + attribute observers and the render * loop picks up changes automatically. */ requestUpdate() { this._scheduleRender(); } /** * Dispatch a `CustomEvent` with a `detail`. Bubbles + composed * by default (matches typical WC UX — events cross shadow * boundaries, parents can listen without knowing about internal * structure). */ emit(name, detail) { return this.dispatchEvent( new CustomEvent(name, { detail, bubbles: true, composed: true }) ); } // ------------------------------------------------------------------ // Internals // ------------------------------------------------------------------ /** * Wire every `static props` entry to a matched property getter + * setter on the element. Setting the property reflects into the * attribute (so downstream observers + CSS selectors see it); * reading the property falls back to the attribute. */ _installPropAccessors() { const ctor = this.constructor; for (const prop of ctor.props) { if (Object.getOwnPropertyDescriptor(this, prop)) { continue; } const attr = kebab(prop); Object.defineProperty(this, prop, { get: () => { if (prop in this._propValues) { return this._propValues[prop]; } return this.getAttribute(attr); }, set: (value) => { let str; if (value === null || value === void 0 || value === false) { str = null; } else if (value === true) { str = ""; } else { str = String(value); } this._propValues[prop] = str; if (str === null) { this.removeAttribute(attr); } else { this.setAttribute(attr, str); } this.requestUpdate(); }, enumerable: true, configurable: true }); } } /** * Schedule a render on the next microtask. Multiple property * assignments in the same tick collapse into a single render. */ _scheduleRender() { if (this._renderScheduled || !this.isConnected) { return; } this._renderScheduled = true; queueMicrotask(() => { this._renderScheduled = false; if (!this.isConnected) { return; } render(this.render(), this._renderRoot); }); } /** * Mount adoptable stylesheets onto the shadow root (via * `adoptedStyleSheets`) or the light DOM (via one `

${escapeHtml$1(title)}

${escapeHtml$1(lede)}

`; } function escapeHtml$1(s) { const t = document.createElement("div"); t.textContent = s; return t.innerHTML; } const pagesIntroDialog = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, showPagesIntroDialog }, Symbol.toStringTag, { value: "Module" })); const REPULSION_K = 5500; const SPRING_K = 0.05; const SPRING_LEN = 130; const MIN_RADIUS = 22; const MAX_RADIUS = 48; const POST_PER_PAGE$1 = 10; const POST_RING_RADIUS$1 = 170; async function mountCategoriesMindmap(host, client) { const api = window.wp?.desktop; if (!api || typeof api.loadModules !== "function") { host.textContent = __("Mindmap unavailable: shell modules API missing."); return () => { }; } try { await api.loadModules(["pixijs"]); } catch { host.textContent = __("Mindmap unavailable."); return () => { }; } const pixiMaybe = window.PIXI; if (!pixiMaybe) { host.textContent = __("Mindmap unavailable."); return () => { }; } const pixi = pixiMaybe; host.replaceChildren(); host.classList.add("wpd-mindmap"); const toolbar = document.createElement("div"); toolbar.className = "wpd-mindmap__toolbar"; const addRootBtn = document.createElement("button"); addRootBtn.type = "button"; addRootBtn.className = "wpd-mindmap__btn wpd-mindmap__btn--primary"; addRootBtn.innerHTML = '' + __("Add root category"); const recenterBtn = document.createElement("button"); recenterBtn.type = "button"; recenterBtn.className = "wpd-mindmap__btn"; recenterBtn.innerHTML = '' + __("Recenter"); const searchWrap = document.createElement("div"); searchWrap.className = "wpd-mindmap__search"; const searchInput = document.createElement("input"); searchInput.type = "search"; searchInput.className = "wpd-mindmap__search-input"; searchInput.placeholder = __("Search categories…"); searchInput.setAttribute( "aria-label", __("Search categories in the mindmap") ); searchWrap.appendChild(searchInput); const searchResults = document.createElement("ul"); searchResults.className = "wpd-mindmap__search-results"; searchResults.hidden = true; searchWrap.appendChild(searchResults); const hint = document.createElement("span"); hint.className = "wpd-mindmap__hint"; hint.textContent = __( "Click a node to focus + edit · drag onto another to reparent · wheel to zoom" ); toolbar.appendChild(addRootBtn); toolbar.appendChild(recenterBtn); toolbar.appendChild(searchWrap); toolbar.appendChild(hint); host.appendChild(toolbar); const layout = document.createElement("div"); layout.className = "wpd-mindmap__layout"; host.appendChild(layout); const stage = document.createElement("div"); stage.className = "wpd-mindmap__stage"; stage.classList.add("is-loading"); layout.appendChild(stage); const sidebar = document.createElement("aside"); sidebar.className = "wpd-mindmap__sidebar"; layout.appendChild(sidebar); const app = new pixi.Application(); await app.init({ resizeTo: stage, backgroundAlpha: 0, antialias: true, autoDensity: true, resolution: Math.min(window.devicePixelRatio || 1, 2) }); stage.appendChild(app.canvas); app.canvas.classList.add("wpd-mindmap__canvas"); const world = new pixi.Container(); world.x = stage.clientWidth / 2; world.y = stage.clientHeight / 2; app.stage.addChild(world); const edgeLayer = new pixi.Container(); const nodeLayer = new pixi.Container(); const postEdgeLayer = new pixi.Container(); const postLayer = new pixi.Container(); const chipLayer = new pixi.Container(); const postChipLayer = new pixi.Container(); world.addChild(edgeLayer); world.addChild(postEdgeLayer); world.addChild(postLayer); world.addChild(nodeLayer); world.addChild(chipLayer); world.addChild(postChipLayer); const edgeGfx = new pixi.Graphics(); edgeLayer.addChild(edgeGfx); const postEdgeGfx = new pixi.Graphics(); postEdgeLayer.addChild(postEdgeGfx); const CHIP_TEXT_RES2 = 4; const pager = new pixi.Container(); pager.eventMode = "passive"; pager.visible = false; postLayer.addChild(pager); const pagerPrev = new pixi.Graphics(); const pagerNext = new pixi.Graphics(); const pagerLabel = new pixi.Text({ text: "1 / 1", style: { fill: 5265246, fontSize: 14, fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', fontWeight: "600" }, resolution: CHIP_TEXT_RES2 }); pagerLabel.anchor.set(0.5); pagerPrev.eventMode = "static"; pagerPrev.cursor = "pointer"; pagerNext.eventMode = "static"; pagerNext.cursor = "pointer"; pagerPrev.hitArea = new pixi.Circle(0, 0, 16); pagerNext.hitArea = new pixi.Circle(0, 0, 16); pager.addChild(pagerPrev); pager.addChild(pagerLabel); pager.addChild(pagerNext); const stopBubble = (e) => { e.stopPropagation?.(); pixiInteractionAt = performance.now(); }; pagerPrev.on("pointerdown", stopBubble); pagerNext.on("pointerdown", stopBubble); pagerPrev.on("pointertap", (e) => { stopBubble(e); lastFocusChange = performance.now(); if (focusPage <= 1) { return; } focusPage--; void loadPostsForFocus(); }); pagerNext.on("pointertap", (e) => { stopBubble(e); lastFocusChange = performance.now(); if (focusPage >= focusTotalPages) { return; } focusPage++; void loadPostsForFocus(); }); const nodes = /* @__PURE__ */ new Map(); const chips = /* @__PURE__ */ new Map(); const postChips = /* @__PURE__ */ new Map(); const postNodes = /* @__PURE__ */ new Map(); let focusId = null; let focusPage = 1; let focusTotalPages = 1; let loadSeq = 0; let pixiInteractionAt = 0; let dragNode = null; let dragHover = null; let panActive = false; let panStart = null; let panMovedDist = 0; let raf = null; let lastTick = performance.now(); let targetScale = world.scale.x; let targetWorldX = world.x; let targetWorldY = world.y; let nudgeAwayFrom = null; const pinnedTargetBackup = /* @__PURE__ */ new Map(); let prevView = null; let draft = null; const themeHue = readAdminThemeHue$1(); const clusterColor = (idx) => hslToInt$1((themeHue + idx * 47) % 360, 55, 52); let terms = []; try { const all = []; let page = 1; while (page <= 5) { const res = await client.fetchTerms("categories", { page, perPage: 100 }); all.push(...res.items); if (page >= res.totalPages) { break; } page++; } terms = all; } catch (err) { showToast$1(__("Couldn’t load categories:"), err); } const showError = (title, err) => showToast$1(title, err); function isUncategorized(term) { if (term.isDefault) { return true; } return term.id === 1 || term.slug === "uncategorized" || term.name.toLowerCase() === "uncategorized"; } function syncEmptyHint() { const existing = stage.querySelector(".wpd-mindmap__empty"); if (terms.length <= 1) { if (!existing) { const empty = document.createElement("div"); empty.className = "wpd-mindmap__empty"; empty.textContent = __( 'No custom categories yet. Click "Add root category" to start branching.' ); stage.appendChild(empty); } } else if (existing) { existing.remove(); } } function buildTree() { const childMap = /* @__PURE__ */ new Map(); for (const t of terms) { const list = childMap.get(t.parent) ?? []; list.push(t); childMap.set(t.parent, list); } const allRoots = childMap.get(0) ?? []; const roots = allRoots.filter((r) => !isUncategorized(r)); const uncategorized = allRoots.find(isUncategorized); const place = (term, depth, rootIdx, angle, angleSpan) => { const rootRingByCount = roots.length > 1 ? 110 + roots.length * 28 : 0; const rootRing = uncategorized ? Math.max(rootRingByCount, 140) : rootRingByCount; const baseRadius = depth === 0 ? rootRing : rootRing + 160 + (depth - 1) * 150; const tx = baseRadius * Math.cos(angle); const ty = baseRadius * Math.sin(angle); const radius = nodeRadius(term.count, terms); const color = depth === 0 ? clusterColor(rootIdx) : nodes.get(term.parent)?.color ?? clusterColor(rootIdx); let node = nodes.get(term.id); if (!node) { const gfx = new pixi.Graphics(); gfx.eventMode = "static"; gfx.cursor = "pointer"; node = { id: term.id, parent: term.parent, name: term.name, description: term.description, count: term.count, x: tx, y: ty, tx, ty, radius, depth, color, gfx, pinned: depth === 0 }; nodeLayer.addChild(gfx); gfx.on("pointerdown", (e) => onNodePointerDown(e, node)); nodes.set(term.id, node); } else { node.parent = term.parent; node.name = term.name; node.description = term.description; node.count = term.count; node.depth = depth; node.color = color; node.radius = radius; node.tx = tx; node.ty = ty; node.pinned = depth === 0; } drawNodeDisc(node, false); const kids = childMap.get(term.id) ?? []; if (kids.length > 0) { const sub = angleSpan / kids.length; kids.forEach((child, i) => { place( child, depth + 1, rootIdx, angle - angleSpan / 2 + sub * (i + 0.5), sub * 0.85 ); }); } }; const liveIds = new Set(terms.map((t) => t.id)); for (const [id, node] of nodes) { if (!liveIds.has(id)) { nodeLayer.removeChild(node.gfx); node.gfx.destroy(); nodes.delete(id); destroyChip(id); } } const rootCount = Math.max(1, roots.length); roots.forEach((root, idx) => { const angle = 2 * Math.PI / rootCount * idx; place(root, 0, idx, angle, 2 * Math.PI / rootCount); }); if (uncategorized) { placeIsolated(uncategorized); } syncEmptyHint(); } function placeIsolated(term) { const tx = 0; const ty = 0; const radius = nodeRadius(term.count, terms); const color = 9211796; let node = nodes.get(term.id); if (!node) { const gfx = new pixi.Graphics(); gfx.eventMode = "static"; gfx.cursor = "pointer"; node = { id: term.id, parent: 0, name: term.name, description: term.description, count: term.count, x: tx, y: ty, tx, ty, radius, depth: 0, color, gfx, pinned: true }; nodeLayer.addChild(gfx); gfx.on("pointerdown", (e) => onNodePointerDown(e, node)); nodes.set(term.id, node); } else { node.parent = 0; node.name = term.name; node.description = term.description; node.count = term.count; node.depth = 0; node.color = color; node.radius = radius; node.tx = tx; node.ty = ty; node.pinned = true; } drawNodeDisc(node, false); } function drawCurvedEdge(g, x1, y1, x2, y2, color, opts = {}) { const dx = x2 - x1; const cp1x = x1 + dx * 0.5; const cp1y = y1; const cp2x = x2 - dx * 0.5; const cp2y = y2; const alpha = opts.alpha ?? 0.5; const width = opts.width ?? 1.5; if (!opts.dashed) { g.moveTo(x1, y1); g.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x2, y2); g.stroke({ color, width, alpha }); return; } const sampleAt = (t) => { const omt = 1 - t; const px = omt * omt * omt * x1 + 3 * omt * omt * t * cp1x + 3 * omt * t * t * cp2x + t * t * t * x2; const py = omt * omt * omt * y1 + 3 * omt * omt * t * cp1y + 3 * omt * t * t * cp2y + t * t * t * y2; return { x: px, y: py }; }; const STEPS = 32; const phase = opts.dashPhase ?? 0; const stride = Math.max(1, opts.dashStride ?? 1); let lastX = x1; let lastY = y1; for (let i = 1; i <= STEPS; i++) { const p = sampleAt(i / STEPS); const groupIdx = Math.floor((i - 1 + phase) / stride); const visible = groupIdx % 2 === 0; if (visible) { g.moveTo(lastX, lastY); g.lineTo(p.x, p.y); g.stroke({ color, width, alpha }); } lastX = p.x; lastY = p.y; } } function drawNodeDisc(node, highlighted) { const g = node.gfx; g.clear(); const r = node.radius; if (!highlighted) { g.circle(0, 5, r); g.fill({ color: 0, alpha: 0.18 }); } if (highlighted) { g.circle(0, 0, r + 10); g.fill({ color: node.color, alpha: 0.22 }); } g.circle(0, 0, r); g.fill(shadeColor(node.color, -0.18)); g.circle(0, -r * 0.06, r * 0.94); g.fill(node.color); g.circle(-r * 0.32, -r * 0.42, r * 0.3); g.fill({ color: 16777215, alpha: 0.32 }); g.circle(0, 0, r); g.stroke({ color: 16777215, width: highlighted ? 3 : 2, alignment: 0 }); g.x = node.x; g.y = node.y; g.zIndex = 10; g.hitArea = new pixi.Circle(0, 0, r + 4); } function drawDropTarget(hover, sourceColor) { drawNodeDisc(hover, false); const g = hover.gfx; const t = performance.now(); const pulse = Math.sin(t / 280) * 0.5 + 0.5; const ringR = hover.radius + 6 + pulse * 5; g.circle(0, 0, ringR); g.stroke({ color: sourceColor, width: 3, alpha: 0.6 + pulse * 0.35 }); g.circle(0, 0, hover.radius * 0.42); g.fill({ color: sourceColor, alpha: 0.85 }); g.hitArea = new pixi.Circle(0, 0, hover.radius + 12); } function drawEdges() { edgeGfx.clear(); for (const node of nodes.values()) { if (!node.parent) { continue; } const parent = nodes.get(node.parent); if (!parent) { continue; } const isOldLink = dragNode !== null && node === dragNode; const isFocusEdge = focusId !== null && (node.id === focusId || node.parent === focusId); const dimMul = focusId !== null && !isFocusEdge ? 0.35 : 1; drawCurvedEdge( edgeGfx, parent.x, parent.y, node.x, node.y, parent.color, isOldLink ? { dashed: true, alpha: 0.28 * dimMul } : { alpha: 0.5 * dimMul } ); } if (dragNode && dragHover) { const x1 = dragNode.x; const y1 = dragNode.y; const x2 = dragHover.x; const y2 = dragHover.y; const targetColor = dragHover.color; drawCurvedEdge(edgeGfx, x1, y1, x2, y2, targetColor, { alpha: 0.22, width: 9 }); const dashPhase = Math.floor(performance.now() / 70); drawCurvedEdge(edgeGfx, x1, y1, x2, y2, targetColor, { alpha: 0.95, width: 2.5, dashed: true, dashStride: 2, dashPhase }); const pt = performance.now() % 1300 / 1300; const omt = 1 - pt; const dx = x2 - x1; const cp1x = x1 + dx * 0.5; const cp1y = y1; const cp2x = x2 - dx * 0.5; const cp2y = y2; const px = omt * omt * omt * x1 + 3 * omt * omt * pt * cp1x + 3 * omt * pt * pt * cp2x + pt * pt * pt * x2; const py = omt * omt * omt * y1 + 3 * omt * omt * pt * cp1y + 3 * omt * pt * pt * cp2y + pt * pt * pt * y2; edgeGfx.circle(px, py, 5); edgeGfx.fill({ color: 16777215, alpha: 0.95 }); edgeGfx.stroke({ color: targetColor, width: 2, alpha: 1 }); } postEdgeGfx.clear(); if (focusId !== null) { const center = nodes.get(focusId); if (center) { for (const post of postNodes.values()) { postEdgeGfx.moveTo(center.x, center.y); postEdgeGfx.lineTo(post.x, post.y); postEdgeGfx.stroke({ color: center.color, width: 1, alpha: 0.35 }); } } } } const FONT_FAMILY2 = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'; const CHIP_NAME_MAX_CHARS2 = 18; const POST_TITLE_MAX_CHARS2 = 22; function truncateChipName2(name) { return name.length > CHIP_NAME_MAX_CHARS2 ? name.slice(0, CHIP_NAME_MAX_CHARS2 - 1) + "…" : name; } function ensureChip(node) { const existing = chips.get(node.id); if (existing) { return existing; } const container = new pixi.Container(); container.eventMode = "static"; container.cursor = "pointer"; const bg = new pixi.Graphics(); container.addChild(bg); const nameText = new pixi.Text({ text: truncateChipName2(node.name), style: { fill: 1909543, fontSize: 14, fontFamily: FONT_FAMILY2, fontWeight: "600" }, resolution: CHIP_TEXT_RES2 }); container.addChild(nameText); const countBg = new pixi.Graphics(); container.addChild(countBg); const countText = new pixi.Text({ text: String(node.count), style: { fill: 16777215, fontSize: 12, fontFamily: FONT_FAMILY2, fontWeight: "700" }, resolution: CHIP_TEXT_RES2 }); container.addChild(countText); const chip = { container, bg, nameText, countBg, countText, width: 0, height: 0, cachedName: "", cachedCount: -1, cachedFocused: false, cachedHover: false, cachedColor: -1 }; chips.set(node.id, chip); chipLayer.addChild(container); container.on("pointerdown", (e) => { e.stopPropagation?.(); pixiInteractionAt = performance.now(); }); container.on("pointertap", () => { void focusNode(node.id); }); container.on("pointerover", () => { chip.cachedHover = true; layoutChip(chip, node); }); container.on("pointerout", () => { chip.cachedHover = false; layoutChip(chip, node); }); return chip; } function layoutChip(chip, node) { const focused = focusId === node.id; const displayName = truncateChipName2(node.name); const countStr = String(node.count); if (chip.nameText.text !== displayName) { chip.nameText.text = displayName; } if (chip.countText.text !== countStr) { chip.countText.text = countStr; } chip.cachedName = displayName; chip.cachedCount = node.count; chip.cachedFocused = focused; chip.cachedColor = node.color; const padX = 9; const padY = 3; const gap = 5; const countPadX = 5; const countPadY = 2; const minBadgeW = 18; const nameW = chip.nameText.width; const nameH = chip.nameText.height; const countW = chip.countText.width; const countH = chip.countText.height; const badgeW = Math.max(minBadgeW, countW + countPadX * 2); const badgeH = countH + countPadY * 2; const totalW = padX + nameW + gap + badgeW + padX; const totalH = Math.max(nameH, badgeH) + padY * 2; chip.width = totalW; chip.height = totalH; const left = -totalW / 2; chip.bg.clear(); chip.bg.roundRect(left, 0, totalW, totalH, totalH / 2); if (focused) { chip.bg.fill(node.color); } else if (chip.cachedHover) { chip.bg.fill({ color: 16777215, alpha: 0.96 }); chip.bg.stroke({ color: node.color, width: 1.5, alpha: 1 }); } else { chip.bg.fill({ color: 16777215, alpha: 0.88 }); chip.bg.stroke({ color: 0, width: 1, alpha: 0.06 }); } chip.nameText.x = left + padX; chip.nameText.y = (totalH - nameH) / 2; chip.nameText.style.fill = focused ? 16777215 : 1909543; const badgeX = left + padX + nameW + gap; const badgeY = (totalH - badgeH) / 2; chip.countBg.clear(); chip.countBg.roundRect( badgeX, badgeY, badgeW, badgeH, badgeH / 2 ); chip.countBg.fill( focused ? { color: 16777215, alpha: 0.25 } : node.color ); chip.countText.x = badgeX + (badgeW - countW) / 2; chip.countText.y = badgeY + (badgeH - countH) / 2; } function destroyChip(id) { const chip = chips.get(id); if (!chip) { return; } chipLayer.removeChild(chip.container); chip.container.destroy({ children: true }); chips.delete(id); } function syncChipPositions() { const activeIds = new Set(nodes.keys()); for (const id of [...chips.keys()]) { if (!activeIds.has(id)) { destroyChip(id); } } const chipCounterScale = 1 / Math.max(0.01, world.scale.x); const anyFocus = focusId !== null; for (const node of nodes.values()) { const chip = ensureChip(node); chip.container.x = node.x; chip.container.y = node.y + node.radius + 6; chip.container.scale.set(chipCounterScale); const focused = focusId === node.id; const targetAlpha = !anyFocus || focused ? 1 : 0.4; if (Math.abs(chip.container.alpha - targetAlpha) > 5e-3) { chip.container.alpha += (targetAlpha - chip.container.alpha) * 0.18; } else { chip.container.alpha = targetAlpha; } if (Math.abs(node.gfx.alpha - targetAlpha) > 5e-3) { node.gfx.alpha += (targetAlpha - node.gfx.alpha) * 0.18; } else { node.gfx.alpha = targetAlpha; } const displayName = truncateChipName2(node.name); if (chip.cachedName !== displayName || chip.cachedCount !== node.count || chip.cachedFocused !== focused || chip.cachedColor !== node.color) { layoutChip(chip, node); } } for (const post of postNodes.values()) { const chip = postChips.get(post.id); if (!chip) { continue; } chip.container.x = post.x; chip.container.y = post.y; chip.container.scale.set(chipCounterScale); if (chip.container.alpha < 1) { chip.container.alpha = Math.min( 1, chip.container.alpha + 0.18 ); } } } function physicsStep(dt) { const list = Array.from(nodes.values()); for (const a of list) { if (a.pinned) { a.x += (a.tx - a.x) * 0.12; a.y += (a.ty - a.y) * 0.12; a.gfx.x = a.x; a.gfx.y = a.y; continue; } let fx = 0; let fy = 0; for (const b of list) { if (a === b) { continue; } const dx = a.x - b.x; const dy = a.y - b.y; const d2 = dx * dx + dy * dy + 1; const f = REPULSION_K / d2; const d = Math.sqrt(d2); fx += dx / d * f; fy += dy / d * f; } const parent = nodes.get(a.parent); if (parent) { const dx = parent.x - a.x; const dy = parent.y - a.y; const d = Math.sqrt(dx * dx + dy * dy) || 1; const stretch = d - SPRING_LEN; fx += dx / d * stretch * SPRING_K; fy += dy / d * stretch * SPRING_K; } else { fx += -a.x * 8e-4; fy += -a.y * 8e-4; } if (nudgeAwayFrom && a.id !== focusId) { const ndx = a.x - nudgeAwayFrom.x; const ndy = a.y - nudgeAwayFrom.y; const nd = Math.sqrt(ndx * ndx + ndy * ndy) || 1; const limit = nudgeAwayFrom.radius + a.radius; if (nd < limit) { const pushK = 18; fx += ndx / nd * pushK * (limit - nd); fy += ndy / nd * pushK * (limit - nd); } } if (a !== dragNode) { a.x += fx * dt * 1e-3 + (a.tx - a.x) * 0.02; a.y += fy * dt * 1e-3 + (a.ty - a.y) * 0.02; } a.gfx.x = a.x; a.gfx.y = a.y; } } function preSettlePhysics(iterations) { for (let i = 0; i < iterations; i++) { physicsStep(16); } for (const n of nodes.values()) { n.tx = n.x; n.ty = n.y; } } function tick() { const now = performance.now(); const dt = Math.min(50, now - lastTick); lastTick = now; const ZOOM_EASE = 0.22; const ds = targetScale - world.scale.x; const dwx = targetWorldX - world.x; const dwy = targetWorldY - world.y; if (Math.abs(ds) > 5e-4 || Math.abs(dwx) > 0.5 || Math.abs(dwy) > 0.5) { world.scale.set(world.scale.x + ds * ZOOM_EASE); world.x += dwx * ZOOM_EASE; world.y += dwy * ZOOM_EASE; } physicsStep(dt); for (const p of postNodes.values()) { p.x += (p.tx - p.x) * 0.18; p.y += (p.ty - p.y) * 0.18; p.gfx.x = p.x; p.gfx.y = p.y; } drawEdges(); if (dragNode && dragHover) { drawDropTarget(dragHover, dragNode.color); } syncChipPositions(); raf = requestAnimationFrame(tick); } let dragStartPos = null; let dragOffset = { x: 0, y: 0 }; function onNodePointerDown(e, node) { const ev = e; ev.stopPropagation?.(); pixiInteractionAt = performance.now(); dragNode = node; node.pinned = true; node.tx = node.x; node.ty = node.y; dragStartPos = { x: ev.global.x, y: ev.global.y }; const local = stageToWorld({ x: ev.global.x, y: ev.global.y }); dragOffset = { x: node.x - local.x, y: node.y - local.y }; } function stageToWorld(global) { return { x: (global.x - world.x) / world.scale.x, y: (global.y - world.y) / world.scale.y }; } function onStagePointerDown(e) { const ev = e; panActive = true; panStart = { x: ev.global.x, y: ev.global.y }; panMovedDist = 0; } function onStagePointerMove(e) { const ev = e; if (dragNode) { const cursorWorld = stageToWorld(ev.global); const nx = cursorWorld.x + dragOffset.x; const ny = cursorWorld.y + dragOffset.y; dragNode.x = nx; dragNode.y = ny; dragNode.tx = nx; dragNode.ty = ny; dragNode.gfx.x = nx; dragNode.gfx.y = ny; let hover = null; for (const c of nodes.values()) { if (c === dragNode) { continue; } const dx = c.x - cursorWorld.x; const dy = c.y - cursorWorld.y; if (dx * dx + dy * dy < c.radius * c.radius) { hover = c; break; } } if (hover !== dragHover) { if (dragHover) { drawNodeDisc(dragHover, focusId === dragHover.id); } dragHover = hover; if (hover && dragNode) { drawDropTarget(hover, dragNode.color); } } return; } if (panActive && panStart) { const dx = ev.global.x - panStart.x; const dy = ev.global.y - panStart.y; world.x += dx; world.y += dy; targetWorldX += dx; targetWorldY += dy; panMovedDist += Math.sqrt(dx * dx + dy * dy); panStart = { x: ev.global.x, y: ev.global.y }; } } async function onStagePointerUp(e) { if (dragNode) { const node = dragNode; const target = dragHover; const startPos = dragStartPos; dragNode = null; dragHover = null; dragStartPos = null; node.pinned = node.depth === 0; let movement = Infinity; const ev = e; if (startPos && ev && ev.global) { const dx = ev.global.x - startPos.x; const dy = ev.global.y - startPos.y; movement = Math.sqrt(dx * dx + dy * dy); } if (!target && movement < 2) { focusNode(node.id); panActive = false; panStart = null; return; } if (target && target.id !== node.parent && !isAncestor(node.id, target.id)) { try { await client.updateTerm("categories", node.id, { parent: target.id }); node.parent = target.id; terms = terms.map( (t) => t.id === node.id ? { ...t, parent: target.id } : t ); buildTree(); } catch (err) { showError(__("Reparent failed:"), err); } } else { drawNodeDisc(node, focusId === node.id); if (target) { drawNodeDisc(target, focusId === target.id); } } } panActive = false; panStart = null; } app.stage.eventMode = "static"; app.stage.hitArea = new pixi.Rectangle( 0, 0, stage.clientWidth, stage.clientHeight ); app.stage.on("pointerdown", onStagePointerDown); app.stage.on("pointermove", onStagePointerMove); app.stage.on("pointerup", (e) => void onStagePointerUp(e)); app.stage.on("pointerupoutside", (e) => void onStagePointerUp(e)); function onWheel(e) { e.preventDefault(); const SENSITIVITY = 8e-4; const factor = Math.exp(-e.deltaY * SENSITIVITY); const prev = targetScale; const next = Math.max(0.3, Math.min(2.5, prev * factor)); if (Math.abs(next - prev) < 5e-4) { return; } const r = stage.getBoundingClientRect(); const sx = e.clientX - r.left; const sy = e.clientY - r.top; const wx = (sx - targetWorldX) / prev; const wy = (sy - targetWorldY) / prev; targetScale = next; targetWorldX = sx - wx * next; targetWorldY = sy - wy * next; } stage.addEventListener("wheel", onWheel, { passive: false }); let firstFitDone = false; let settledW = 0; let settledH = 0; const SETTLE_THRESHOLD_PX = 24; const SETTLE_DEBOUNCE_MS = 80; let settleTimer = null; function onResize() { const r = stage.getBoundingClientRect(); app.renderer.resize(r.width, r.height); app.stage.hitArea = new pixi.Rectangle(0, 0, r.width, r.height); if (!firstFitDone && r.width > 0 && r.height > 0) { firstFitDone = true; settledW = r.width; settledH = r.height; fitToView(); stage.classList.remove("is-loading"); } if (settleTimer !== null) { window.clearTimeout(settleTimer); } settleTimer = window.setTimeout(() => { settleTimer = null; const cur = stage.getBoundingClientRect(); const dw = Math.abs(cur.width - settledW); const dh = Math.abs(cur.height - settledH); if (dw >= SETTLE_THRESHOLD_PX || dh >= SETTLE_THRESHOLD_PX) { settledW = cur.width; settledH = cur.height; recenterCamera(); } }, SETTLE_DEBOUNCE_MS); app.render(); } const ro = new ResizeObserver(onResize); ro.observe(stage); function isAncestor(ancestor, descendant) { let cur = nodes.get(descendant); let safety = 32; while (cur && safety-- > 0) { if (cur.id === ancestor) { return true; } if (!cur.parent) { return false; } cur = nodes.get(cur.parent); } return false; } let lastFocusChange = 0; const SPOTLIGHT_RADIUS2 = POST_RING_RADIUS$1 + 130; async function focusNode(id) { if (focusId === id) { closeFocus(); return; } const wasFocused = focusId !== null; focusId = id; focusPage = 1; lastFocusChange = performance.now(); const focused = nodes.get(id); if (focused) { if (!wasFocused) { prevView = { scale: targetScale, x: targetWorldX, y: targetWorldY }; } const r = stage.getBoundingClientRect(); if (r.width > 0 && r.height > 0) { const half = POST_RING_RADIUS$1 + 70; const sx = r.width * 0.85 / (2 * half); const sy = r.height * 0.85 / (2 * half); const newScale = Math.max( 0.5, Math.min(1.6, Math.min(sx, sy)) ); targetScale = newScale; targetWorldX = r.width / 2 - focused.x * newScale; targetWorldY = r.height / 2 - focused.y * newScale; } nudgeAwayFrom = { x: focused.x, y: focused.y, radius: SPOTLIGHT_RADIUS2 }; pinnedTargetBackup.clear(); for (const n of nodes.values()) { if (n.id === id || !n.pinned) { continue; } const dx = n.x - focused.x; const dy = n.y - focused.y; const d = Math.sqrt(dx * dx + dy * dy) || 1; if (d >= SPOTLIGHT_RADIUS2 + n.radius) { continue; } pinnedTargetBackup.set(n.id, { tx: n.tx, ty: n.ty }); const push = SPOTLIGHT_RADIUS2 + n.radius + 20; n.tx = focused.x + dx / d * push; n.ty = focused.y + dy / d * push; } } for (const n of nodes.values()) { drawNodeDisc(n, focusId === n.id); } paintSidebar(); await loadPostsForFocus(); } function closeFocus() { focusId = null; lastFocusChange = performance.now(); loadSeq++; nudgeAwayFrom = null; for (const [id, t] of pinnedTargetBackup) { const n = nodes.get(id); if (n) { n.tx = t.tx; n.ty = t.ty; } } pinnedTargetBackup.clear(); if (prevView) { targetScale = prevView.scale; targetWorldX = prevView.x; targetWorldY = prevView.y; prevView = null; } paintSidebar(); clearPosts(); for (const n of nodes.values()) { drawNodeDisc(n, false); } } function clearPosts() { for (const post of postNodes.values()) { postLayer.removeChild(post.gfx); post.gfx.destroy(); } postNodes.clear(); for (const chip of postChips.values()) { postChipLayer.removeChild(chip.container); chip.container.destroy({ children: true }); } postChips.clear(); postEdgeGfx.clear(); pager.visible = false; } function ensurePostChip(post) { const existing = postChips.get(post.id); if (existing) { return existing; } const container = new pixi.Container(); container.eventMode = "static"; container.cursor = "pointer"; container.alpha = 0; const bg = new pixi.Graphics(); container.addChild(bg); const dot = new pixi.Graphics(); container.addChild(dot); const titleText = new pixi.Text({ text: post.title, style: { fill: 1909543, // Matches category chip fontSize so the two read at // the same weight when both are deployed. Base size // is the on-screen size since the post chip's // container counter-scales with `1/world.scale.x` // in `syncChipPositions`. fontSize: 14, fontFamily: FONT_FAMILY2, fontWeight: "500" }, resolution: CHIP_TEXT_RES2 }); container.addChild(titleText); const chip = { container, bg, dot, titleText, width: 0, height: 0, cachedTitle: "", cachedHover: false }; postChips.set(post.id, chip); postChipLayer.addChild(container); container.on("pointerdown", (e) => { e.stopPropagation?.(); pixiInteractionAt = performance.now(); }); container.on("pointertap", () => { openInPostsTab(post.id, post.editUrl, post.title); closeFocus(); }); container.on("pointerover", () => { chip.cachedHover = true; layoutPostChip(chip, post); }); container.on("pointerout", () => { chip.cachedHover = false; layoutPostChip(chip, post); }); layoutPostChip(chip, post); return chip; } function layoutPostChip(chip, post) { const displayTitle = post.title.length > POST_TITLE_MAX_CHARS2 ? post.title.slice(0, POST_TITLE_MAX_CHARS2 - 1) + "…" : post.title; if (chip.titleText.text !== displayTitle) { chip.titleText.text = displayTitle; } chip.cachedTitle = displayTitle; const padX = 9; const padY = 3; const dotR = 4; const gap = 6; const titleW = chip.titleText.width; const titleH = chip.titleText.height; const totalW = padX + dotR * 2 + gap + titleW + padX; const totalH = Math.max(titleH, dotR * 2) + padY * 2; chip.width = totalW; chip.height = totalH; const left = -totalW / 2; const top = -totalH / 2; chip.bg.clear(); chip.bg.roundRect(left, top, totalW, totalH, totalH / 2); if (chip.cachedHover) { chip.bg.fill({ color: 16777215, alpha: 1 }); chip.bg.stroke({ color: post.tone, width: 1.5, alpha: 1 }); } else { chip.bg.fill({ color: 16777215, alpha: 0.95 }); chip.bg.stroke({ color: 0, width: 1, alpha: 0.12 }); } chip.dot.clear(); chip.dot.circle(left + padX + dotR, 0, dotR); chip.dot.fill({ color: post.tone, alpha: 0.85 }); chip.dot.stroke({ color: 16777215, width: 1 }); chip.titleText.x = left + padX + dotR * 2 + gap; chip.titleText.y = -titleH / 2; } const POSTS_CACHE_TTL_MS = 6e4; const postsCache = /* @__PURE__ */ new Map(); function applyPostsResult(entry, focusedNodeId) { focusTotalPages = entry.totalPages; if (Number.isFinite(entry.realTotal)) { const node = nodes.get(focusedNodeId); if (node && node.count !== entry.realTotal) { node.count = entry.realTotal; terms = terms.map( (t) => t.id === node.id ? { ...t, count: entry.realTotal } : t ); layoutChip(ensureChip(node), node); } } renderPosts(entry.items); } async function loadPostsForFocus() { if (focusId === null) { return; } const mySeq = ++loadSeq; const myFocusId = focusId; const cacheKey2 = `${focusId}:${focusPage}`; const cached = postsCache.get(cacheKey2); if (cached && performance.now() - cached.fetchedAt < POSTS_CACHE_TTL_MS) { applyPostsResult(cached, myFocusId); return; } const cfg = client.getConfig(); const url = new URL(cfg.postsUrl); url.searchParams.set("categories", String(focusId)); url.searchParams.set("per_page", String(POST_PER_PAGE$1)); url.searchParams.set("page", String(focusPage)); url.searchParams.set("status", "any"); url.searchParams.set("_fields", "id,title,status"); try { const response = await fetchShellJson$1(client, url.toString()); if (mySeq !== loadSeq || focusId !== myFocusId) { return; } const raw = response.json ?? []; const totalPages = Math.max( 1, parseInt(response.headers.get("X-WP-TotalPages") ?? "1", 10) || 1 ); const realTotalParsed = parseInt(response.headers.get("X-WP-Total") ?? "", 10); const realTotal = Number.isFinite(realTotalParsed) ? realTotalParsed : -1; const items = raw.map((p) => ({ id: p.id, title: stripTags$1(p.title?.rendered || `#${p.id}`), editUrl: `${cfg.editPostUrlBase}?post=${p.id}&action=edit` })); const entry = { items, totalPages, realTotal, fetchedAt: performance.now() }; postsCache.set(cacheKey2, entry); applyPostsResult(entry, myFocusId); } catch (err) { showError(__("Couldn’t load posts:"), err); } } function renderPosts(items) { clearPosts(); if (focusId === null) { return; } const center = nodes.get(focusId); if (!center) { return; } const count = items.length; const ringR = POST_RING_RADIUS$1 + Math.max(0, count - 8) * 6; items.forEach((item, idx) => { const angle = 2 * Math.PI / Math.max(1, count) * idx - Math.PI / 2; const tx = center.x + Math.cos(angle) * ringR; const ty = center.y + Math.sin(angle) * ringR; const tone = center.color; const gfx = new pixi.Graphics(); postLayer.addChild(gfx); const post = { id: item.id, title: item.title, editUrl: item.editUrl, angle, r: ringR, x: center.x, y: center.y, tx, ty, gfx, tone }; postNodes.set(item.id, post); ensurePostChip(post); }); repaintPager(); } function repaintPager() { if (focusId === null || focusTotalPages <= 1) { pager.visible = false; return; } pager.visible = true; const center = nodes.get(focusId); if (!center) { pager.visible = false; return; } const prevDisabled = focusPage <= 1; const nextDisabled = focusPage >= focusTotalPages; drawPagerButton(pagerPrev, "◀", prevDisabled); drawPagerButton(pagerNext, "▶", nextDisabled); pagerPrev.cursor = prevDisabled ? "default" : "pointer"; pagerNext.cursor = nextDisabled ? "default" : "pointer"; pagerLabel.text = `${focusPage} / ${focusTotalPages}`; pagerPrev.x = -38; pagerPrev.y = 0; pagerNext.x = 38; pagerNext.y = 0; pagerLabel.x = 0; pagerLabel.y = 0; pager.x = center.x; pager.y = center.y + POST_RING_RADIUS$1 + 60; } function drawPagerButton(gfx, glyph, disabled) { gfx.clear(); gfx.circle(0, 0, 14); gfx.fill({ color: disabled ? 15921906 : 16777215, alpha: disabled ? 0.7 : 1 }); gfx.stroke({ color: 0, width: 1, alpha: 0.12 }); const children = gfx.children; const label = children?.[0] ?? null; if (!label) { const t = new pixi.Text({ text: glyph, style: { fill: disabled ? 11580344 : 5265246, fontSize: 16, fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', fontWeight: "600" }, resolution: CHIP_TEXT_RES2 }); t.anchor.set(0.5); gfx.addChild(t); } else { label.text = glyph; label.style.fill = disabled ? 11580344 : 5265246; } } function openInPostsTab(_id, editUrl, title) { const wm = api?.windowManager; const derive = api?.deriveWindowId; const postsWin = wm && typeof wm.getById === "function" ? wm.getById("desktop-mode-posts") : void 0; if (postsWin && typeof postsWin.isFullscreen === "function" && typeof postsWin.toggleFullscreen === "function" && postsWin.isFullscreen()) { postsWin.toggleFullscreen(); } if (wm && typeof derive === "function") { const id = derive(editUrl); wm.open({ id, baseId: id, url: editUrl, title: title ?? editUrl, icon: "dashicons-admin-post" }); return; } try { window.open(editUrl, "_blank"); } catch { window.location.assign(editUrl); } } function paintDraftSidebar(d) { const parentNode = d.parent !== 0 ? nodes.get(d.parent) : null; const header = document.createElement("div"); header.className = "wpd-mindmap__sidebar-header"; const dot = document.createElement("span"); dot.className = "wpd-mindmap__sidebar-dot"; const color = parentNode ? parentNode.color : clusterColor(terms.length); dot.style.background = `#${color.toString(16).padStart(6, "0")}`; const label = document.createElement("code"); label.className = "wpd-mindmap__sidebar-slug"; label.textContent = parentNode ? sprintf( /* translators: %s: parent category name. */ __("New child of %s"), parentNode.name ) : __("New root category"); header.appendChild(dot); header.appendChild(label); sidebar.appendChild(header); const nameLabel = document.createElement("label"); nameLabel.className = "wpd-mindmap__sidebar-label"; nameLabel.textContent = __("Name"); sidebar.appendChild(nameLabel); const nameInput = document.createElement("input"); nameInput.type = "text"; nameInput.className = "wpd-mindmap__editor-name"; nameInput.placeholder = __("e.g. Recipes"); sidebar.appendChild(nameInput); requestAnimationFrame(() => nameInput.focus()); const slugLabel = document.createElement("label"); slugLabel.className = "wpd-mindmap__sidebar-label"; slugLabel.textContent = __("Slug"); sidebar.appendChild(slugLabel); const slugInput = document.createElement("input"); slugInput.type = "text"; slugInput.className = "wpd-mindmap__editor-name"; slugInput.placeholder = __("auto-from-name"); slugInput.spellcheck = false; slugInput.autocapitalize = "off"; slugInput.addEventListener("input", () => { const v = slugInput.value; const norm = v.toLowerCase().replace(/[^a-z0-9-]+/g, "-"); if (v !== norm) { const sel = slugInput.selectionStart ?? norm.length; slugInput.value = norm; slugInput.setSelectionRange(sel, sel); } }); sidebar.appendChild(slugInput); const descLabel = document.createElement("label"); descLabel.className = "wpd-mindmap__sidebar-label"; descLabel.textContent = __("Description"); sidebar.appendChild(descLabel); const descInput = document.createElement("textarea"); descInput.className = "wpd-mindmap__editor-desc"; descInput.placeholder = __("Description (optional)"); descInput.rows = 4; sidebar.appendChild(descInput); const actions = document.createElement("div"); actions.className = "wpd-mindmap__editor-actions"; const createBtn = document.createElement("button"); createBtn.type = "button"; createBtn.className = "wpd-mindmap__btn wpd-mindmap__btn--primary"; createBtn.textContent = __("Create"); const cancelBtn = document.createElement("button"); cancelBtn.type = "button"; cancelBtn.className = "wpd-mindmap__btn wpd-mindmap__btn--danger"; cancelBtn.textContent = __("Cancel"); const handleCreate = async () => { const name = nameInput.value.trim(); if (!name) { nameInput.focus(); return; } createBtn.disabled = true; try { const created = await client.createCategory(name, d.parent, { slug: slugInput.value.trim() || void 0, description: descInput.value || void 0 }); const next = { id: created.id, name: created.name, slug: created.slug || "", parent: created.parent, count: 0, description: created.description || "", isDefault: false }; if (!terms.some((t) => t.id === next.id)) { terms = terms.concat(next); } draft = null; buildTree(); focusId = created.id; paintSidebar(); await loadPostsForFocus(); } catch (err) { createBtn.disabled = false; showError(__("Couldn’t create:"), err); } }; createBtn.addEventListener("click", () => { void handleCreate(); }); cancelBtn.addEventListener("click", () => { draft = null; paintSidebar(); }); nameInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); void handleCreate(); } else if (e.key === "Escape") { draft = null; paintSidebar(); } }); actions.appendChild(createBtn); actions.appendChild(cancelBtn); sidebar.appendChild(actions); } function paintSidebar() { sidebar.replaceChildren(); if (draft !== null) { paintDraftSidebar(draft); return; } if (focusId === null) { const empty = document.createElement("div"); empty.className = "wpd-mindmap__sidebar-empty"; const icon = document.createElement("span"); icon.className = "dashicons dashicons-admin-tools"; icon.setAttribute("aria-hidden", "true"); empty.appendChild(icon); const title = document.createElement("h3"); title.textContent = __("No category selected"); empty.appendChild(title); const help = document.createElement("p"); help.textContent = __( "Click a node on the mindmap to edit its name, description, and posts." ); empty.appendChild(help); sidebar.appendChild(empty); return; } const node = nodes.get(focusId); if (!node) { focusId = null; paintSidebar(); return; } const id = node.id; const header = document.createElement("div"); header.className = "wpd-mindmap__sidebar-header"; const dot = document.createElement("span"); dot.className = "wpd-mindmap__sidebar-dot"; dot.style.background = `#${node.color.toString(16).padStart(6, "0")}`; const term = terms.find((t) => t.id === id); const idLabel = document.createElement("code"); idLabel.className = "wpd-mindmap__sidebar-slug"; idLabel.textContent = `#${id}`; header.appendChild(dot); header.appendChild(idLabel); sidebar.appendChild(header); const nameLabel = document.createElement("label"); nameLabel.className = "wpd-mindmap__sidebar-label"; nameLabel.textContent = __("Name"); sidebar.appendChild(nameLabel); const nameInput = document.createElement("input"); nameInput.type = "text"; nameInput.className = "wpd-mindmap__editor-name"; nameInput.value = node.name; nameInput.placeholder = __("Name"); sidebar.appendChild(nameInput); const slugLabel = document.createElement("label"); slugLabel.className = "wpd-mindmap__sidebar-label"; slugLabel.textContent = __("Slug"); sidebar.appendChild(slugLabel); const slugInput = document.createElement("input"); slugInput.type = "text"; slugInput.className = "wpd-mindmap__editor-name"; slugInput.value = term?.slug || ""; slugInput.placeholder = __("auto-from-name"); slugInput.spellcheck = false; slugInput.autocapitalize = "off"; slugInput.addEventListener("input", () => { const v = slugInput.value; const norm = v.toLowerCase().replace(/[^a-z0-9-]+/g, "-"); if (v !== norm) { const sel = slugInput.selectionStart ?? norm.length; slugInput.value = norm; slugInput.setSelectionRange(sel, sel); } }); sidebar.appendChild(slugInput); const descLabel = document.createElement("label"); descLabel.className = "wpd-mindmap__sidebar-label"; descLabel.textContent = __("Description"); sidebar.appendChild(descLabel); const descInput = document.createElement("textarea"); descInput.className = "wpd-mindmap__editor-desc"; descInput.value = node.description || ""; descInput.placeholder = __("Description (optional)"); descInput.rows = 4; sidebar.appendChild(descInput); const meta = document.createElement("p"); meta.className = "wpd-mindmap__sidebar-meta"; meta.textContent = sprintf( /* translators: %d: post count. */ __("%d posts in this category."), node.count ); sidebar.appendChild(meta); const actions = document.createElement("div"); actions.className = "wpd-mindmap__editor-actions"; const addChildBtn = document.createElement("button"); addChildBtn.type = "button"; addChildBtn.className = "wpd-mindmap__btn wpd-mindmap__btn--secondary"; addChildBtn.textContent = __("+ Child"); addChildBtn.addEventListener("click", () => { startDraft(id); }); const makeRootBtn = node.parent && node.parent !== 0 ? document.createElement("button") : null; if (makeRootBtn) { makeRootBtn.type = "button"; makeRootBtn.className = "wpd-mindmap__btn wpd-mindmap__btn--secondary"; makeRootBtn.textContent = __("Make root"); makeRootBtn.title = __( "Promote this category to a top-level root (no parent)." ); makeRootBtn.addEventListener("click", async () => { try { await client.updateTerm("categories", node.id, { parent: 0 }); node.parent = 0; terms = terms.map( (t) => t.id === node.id ? { ...t, parent: 0 } : t ); buildTree(); paintSidebar(); } catch (err) { showError(__("Couldn’t reparent:"), err); } }); } const saveBtn = document.createElement("button"); saveBtn.type = "button"; saveBtn.className = "wpd-mindmap__btn wpd-mindmap__btn--primary"; saveBtn.textContent = __("Save"); saveBtn.addEventListener("click", async () => { const name = nameInput.value.trim(); if (!name) { return; } const description = descInput.value; const slugRaw = slugInput.value.trim(); const currentSlug = term?.slug ?? ""; if (name === node.name && description === (node.description || "") && slugRaw === currentSlug) { return; } const patch = { name, description }; if (slugRaw !== currentSlug) { patch.slug = slugRaw; } try { const updated = await client.updateTerm( "categories", node.id, patch ); node.name = updated.name; node.description = updated.description; terms = terms.map( (t) => t.id === node.id ? { ...t, name: updated.name, description: updated.description, slug: updated.slug ?? t.slug } : t ); layoutChip(ensureChip(node), node); paintSidebar(); } catch (err) { showError(__("Couldn’t save:"), err); } }); const delBtn = document.createElement("button"); delBtn.type = "button"; delBtn.className = "wpd-mindmap__btn wpd-mindmap__btn--danger"; delBtn.textContent = __("Delete"); let armResetTimer = null; const armDelete = () => { delBtn.textContent = __("Click again to delete"); delBtn.classList.add("is-armed"); if (armResetTimer !== null) { window.clearTimeout(armResetTimer); } armResetTimer = window.setTimeout(() => { delBtn.textContent = __("Delete"); delBtn.classList.remove("is-armed"); armResetTimer = null; }, 2500); }; delBtn.addEventListener("click", async () => { if (!delBtn.classList.contains("is-armed")) { armDelete(); return; } if (armResetTimer !== null) { window.clearTimeout(armResetTimer); armResetTimer = null; } try { await client.deleteTerm("categories", node.id); terms = terms.filter((t) => t.id !== node.id); focusId = null; clearPosts(); buildTree(); paintSidebar(); } catch (err) { showError(__("Couldn’t delete:"), err); } }); actions.appendChild(addChildBtn); if (makeRootBtn) { actions.appendChild(makeRootBtn); } actions.appendChild(saveBtn); actions.appendChild(delBtn); sidebar.appendChild(actions); } function startDraft(parent) { if (parent !== 0 && !nodes.get(parent)) { return; } draft = { parent }; paintSidebar(); } addRootBtn.addEventListener("click", () => { startDraft(0); }); function fitToView(opts = {}) { const padding = opts.padding ?? 90; const animate = opts.animate ?? false; const r = stage.getBoundingClientRect(); if (nodes.size === 0 || r.width === 0 || r.height === 0) { const cx2 = r.width / 2; const cy2 = r.height / 2; targetScale = 1; targetWorldX = cx2; targetWorldY = cy2; if (!animate) { world.x = cx2; world.y = cy2; world.scale.set(1); } return; } let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; const LABEL_OVERHANG = 30; for (const n of nodes.values()) { const rad = n.radius; minX = Math.min(minX, n.tx - rad); minY = Math.min(minY, n.ty - rad); maxX = Math.max(maxX, n.tx + rad); maxY = Math.max(maxY, n.ty + rad + LABEL_OVERHANG); } const w = Math.max(1, maxX - minX); const h = Math.max(1, maxY - minY); const sx = (r.width - padding * 2) / w; const sy = (r.height - padding * 2) / h; const scale = Math.max(0.2, Math.min(1.5, Math.min(sx, sy))); const cx = (minX + maxX) / 2; const cy = (minY + maxY) / 2; const newWorldX = r.width / 2 - cx * scale; const newWorldY = r.height / 2 - cy * scale; targetScale = scale; targetWorldX = newWorldX; targetWorldY = newWorldY; if (!animate) { world.scale.set(scale); world.x = newWorldX; world.y = newWorldY; } } function recenterCamera() { if (focusId !== null) { const focused = nodes.get(focusId); const r = stage.getBoundingClientRect(); if (focused && r.width > 0 && r.height > 0) { const half = POST_RING_RADIUS$1 + 70; const sx = r.width * 0.85 / (2 * half); const sy = r.height * 0.85 / (2 * half); const newScale = Math.max( 0.5, Math.min(1.6, Math.min(sx, sy)) ); targetScale = newScale; targetWorldX = r.width / 2 - focused.x * newScale; targetWorldY = r.height / 2 - focused.y * newScale; return; } } fitToView({ animate: true }); } recenterBtn.addEventListener("click", () => recenterCamera()); app.canvas.addEventListener("click", (e) => { const now = performance.now(); if (now - lastFocusChange < 250 || now - pixiInteractionAt < 250) { return; } if (panMovedDist > 4) { return; } const target = e.target; if (target === app.canvas && !dragNode && focusId !== null) { closeFocus(); } }); async function refreshCountsViaBulk() { if (terms.length === 0) { return; } const cfg = client.getConfig(); const url = new URL( joinRestUrl(cfg.restRoot, "desktop-mode/v1/term-counts") ); url.searchParams.set("taxonomy", "category"); url.searchParams.set( "ids", terms.map((t) => t.id).join(",") ); try { const response = await fetchShellJson$1(client, url.toString()); const map = response.json; let dirty = false; terms = terms.map((t) => { const fresh = map[String(t.id)]; if (typeof fresh === "number" && fresh !== t.count) { dirty = true; const node = nodes.get(t.id); if (node) { node.count = fresh; layoutChip(ensureChip(node), node); } return { ...t, count: fresh }; } return t; }); if (dirty) { buildTree(); fitToView({ animate: true }); } } catch { } } buildTree(); paintSidebar(); preSettlePhysics(80); raf = requestAnimationFrame(tick); void refreshCountsViaBulk(); let currentMatches = []; let selectedIndex = 0; const repaintHighlight = () => { const items = searchResults.querySelectorAll( ".wpd-mindmap__search-result" ); items.forEach((el, i) => { const active = i === selectedIndex; el.classList.toggle("is-active", active); if (active) { el.scrollIntoView({ block: "nearest" }); } }); }; const selectMatch = (n) => { searchInput.value = ""; searchResults.hidden = true; searchResults.replaceChildren(); currentMatches = []; selectedIndex = 0; void focusNode(n.id); }; const renderSearchResults = () => { const q = searchInput.value.trim().toLowerCase(); if (q.length === 0) { searchResults.hidden = true; searchResults.replaceChildren(); currentMatches = []; selectedIndex = 0; return; } currentMatches = Array.from(nodes.values()).filter((n) => n.name.toLowerCase().includes(q)).sort((a, b) => b.count - a.count).slice(0, 10); selectedIndex = 0; searchResults.replaceChildren(); currentMatches.forEach((n, i) => { const li = document.createElement("li"); const btn = document.createElement("button"); btn.type = "button"; btn.className = "wpd-mindmap__search-result"; if (i === 0) { btn.classList.add("is-active"); } const nameEl = document.createElement("span"); nameEl.className = "wpd-mindmap__search-title"; nameEl.textContent = n.name || `#${n.id}`; const countEl = document.createElement("span"); countEl.className = "wpd-mindmap__search-meta"; countEl.textContent = sprintf( /* translators: %d: number of posts assigned to a category. */ __("%d posts"), n.count ); btn.appendChild(nameEl); btn.appendChild(countEl); btn.addEventListener("mousedown", (ev) => { ev.preventDefault(); selectMatch(n); }); btn.addEventListener("mouseenter", () => { selectedIndex = i; repaintHighlight(); }); li.appendChild(btn); searchResults.appendChild(li); }); searchResults.hidden = currentMatches.length === 0; }; searchInput.addEventListener("input", renderSearchResults); searchInput.addEventListener("focus", renderSearchResults); searchInput.addEventListener("keydown", (ev) => { if (ev.key === "ArrowDown") { if (currentMatches.length === 0) { return; } ev.preventDefault(); selectedIndex = Math.min( selectedIndex + 1, currentMatches.length - 1 ); repaintHighlight(); } else if (ev.key === "ArrowUp") { if (currentMatches.length === 0) { return; } ev.preventDefault(); selectedIndex = Math.max(selectedIndex - 1, 0); repaintHighlight(); } else if (ev.key === "Enter") { if (currentMatches.length === 0) { return; } ev.preventDefault(); selectMatch(currentMatches[selectedIndex]); } else if (ev.key === "Escape") { searchInput.value = ""; searchResults.hidden = true; searchResults.replaceChildren(); currentMatches = []; selectedIndex = 0; } }); searchInput.addEventListener("blur", () => { setTimeout(() => { searchResults.hidden = true; }, 120); }); const onDocClickSearch = (ev) => { if (!searchWrap.contains(ev.target)) { searchResults.hidden = true; } }; document.addEventListener("click", onDocClickSearch); return () => { if (raf !== null) { cancelAnimationFrame(raf); raf = null; } if (settleTimer !== null) { window.clearTimeout(settleTimer); settleTimer = null; } ro.disconnect(); stage.removeEventListener("wheel", onWheel); document.removeEventListener("click", onDocClickSearch); try { app.ticker?.stop(); } catch { } try { app.canvas?.remove(); } catch { } host.replaceChildren(); host.classList.remove("wpd-mindmap"); }; } function nodeRadius(count, all) { const max = Math.max(1, ...all.map((t) => t.count)); const ratio = Math.sqrt(count / max); return MIN_RADIUS + (MAX_RADIUS - MIN_RADIUS) * ratio; } function readAdminThemeHue$1() { try { const value = getComputedStyle(document.documentElement).getPropertyValue("--wp-admin-theme-color").trim(); if (!value) { return 210; } const c = document.createElement("span"); c.style.color = value; document.body.appendChild(c); const rgb = getComputedStyle(c).color; c.remove(); const m = rgb.match(/\d+/g); if (!m || m.length < 3) { return 210; } return rgbToHue$1( parseInt(m[0], 10), parseInt(m[1], 10), parseInt(m[2], 10) ); } catch { return 210; } } function rgbToHue$1(r, g, b) { const rn = r / 255; const gn = g / 255; const bn = b / 255; const max = Math.max(rn, gn, bn); const min = Math.min(rn, gn, bn); const d = max - min; if (d === 0) { return 210; } let h; switch (max) { case rn: h = (gn - bn) / d + (gn < bn ? 6 : 0); break; case gn: h = (bn - rn) / d + 2; break; default: h = (rn - gn) / d + 4; break; } return Math.round(h * 60); } function hslToInt$1(h, s, l) { const sn = s / 100; const ln = l / 100; const c = (1 - Math.abs(2 * ln - 1)) * sn; const hp = h / 60; const x = c * (1 - Math.abs(hp % 2 - 1)); let r = 0; let g = 0; let b = 0; if (hp < 1) { r = c; g = x; } else if (hp < 2) { r = x; g = c; } else if (hp < 3) { g = c; b = x; } else if (hp < 4) { g = x; b = c; } else if (hp < 5) { r = x; b = c; } else { r = c; b = x; } const m = ln - c / 2; const ri = Math.round((r + m) * 255); const gi = Math.round((g + m) * 255); const bi = Math.round((b + m) * 255); return ri * 65536 + gi * 256 + bi; } function shadeColor(color, delta) { const r = Math.floor(color / 65536) % 256; const g = Math.floor(color / 256) % 256; const b = color % 256; const adj = (ch) => { return Math.round(ch * (1 + delta)); }; return adj(r) * 65536 + adj(g) * 256 + adj(b); } function stripTags$1(html2) { const tmp = document.createElement("div"); tmp.innerHTML = html2; return tmp.textContent || tmp.innerText || ""; } function showToast$1(title, err) { const reason = err instanceof Error ? err.message : String(err); const api = window.wp?.desktop; if (api && typeof api.showToast === "function") { api.showToast({ message: `${title} ${reason}`.trim(), duration: 6e3 }); return; } console.error(title, err); } async function fetchShellJson$1(client, url) { const cfg = client.getConfig(); const init = { method: "GET", credentials: "same-origin", headers: { "X-WP-Nonce": cfg.restNonce, Accept: "application/json" } }; const response = await trackedFetch(url, init, { windowId: "desktop-mode-posts" }); if (!response.ok) { throw new Error(`${response.status} ${response.statusText}`); } const json = await response.json(); return { json, headers: response.headers }; } const categoriesMindmap = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, mountCategoriesMindmap }, Symbol.toStringTag, { value: "Module" })); const POST_PER_PAGE = 10; const POST_RING_RADIUS = 170; const MIN_FONT_SIZE = 11; const MAX_FONT_SIZE = 28; const FONT_FAMILY = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'; const CHIP_TEXT_RES = 3; const CHIP_NAME_MAX_CHARS = 22; const POST_TITLE_MAX_CHARS = 22; const CHIP_PAD_X = 11; const CHIP_PAD_Y = 6; const CHIP_GAP_HASH = 4; const CHIP_GAP_COUNT = 8; const SPIRAL_PADDING = 14; const SPOTLIGHT_RADIUS = POST_RING_RADIUS + 130; async function mountTagsCloud(host, client) { const api = window.wp?.desktop; if (!api || typeof api.loadModules !== "function") { host.textContent = __("Tag cloud unavailable: shell modules API missing."); return () => { }; } try { await api.loadModules(["pixijs"]); } catch { host.textContent = __("Tag cloud unavailable."); return () => { }; } const pixiMaybe = window.PIXI; if (!pixiMaybe) { host.textContent = __("Tag cloud unavailable."); return () => { }; } const pixi = pixiMaybe; host.replaceChildren(); host.classList.add("wpd-tagcloud"); const toolbar = document.createElement("div"); toolbar.className = "wpd-tagcloud__toolbar"; const addTagBtn = document.createElement("button"); addTagBtn.type = "button"; addTagBtn.className = "wpd-tagcloud__btn wpd-tagcloud__btn--primary"; addTagBtn.innerHTML = '' + __("Add tag"); const recenterBtn = document.createElement("button"); recenterBtn.type = "button"; recenterBtn.className = "wpd-tagcloud__btn"; recenterBtn.innerHTML = '' + __("Recenter"); const reflowBtn = document.createElement("button"); reflowBtn.type = "button"; reflowBtn.className = "wpd-tagcloud__btn"; reflowBtn.innerHTML = '' + __("Reflow"); reflowBtn.title = __( "Recompute the chip layout from scratch — discards manual repositioning." ); const searchWrap = document.createElement("div"); searchWrap.className = "wpd-tagcloud__search"; const searchInput = document.createElement("input"); searchInput.type = "search"; searchInput.className = "wpd-tagcloud__search-input"; searchInput.placeholder = __("Search tags…"); searchInput.setAttribute( "aria-label", __("Search tags in the cloud") ); searchWrap.appendChild(searchInput); const searchResults = document.createElement("ul"); searchResults.className = "wpd-tagcloud__search-results"; searchResults.hidden = true; searchWrap.appendChild(searchResults); const hint = document.createElement("span"); hint.className = "wpd-tagcloud__hint"; hint.textContent = __( "Click a tag to focus + edit · drag to reposition · wheel to zoom" ); toolbar.appendChild(addTagBtn); toolbar.appendChild(recenterBtn); toolbar.appendChild(reflowBtn); toolbar.appendChild(searchWrap); toolbar.appendChild(hint); host.appendChild(toolbar); const layout = document.createElement("div"); layout.className = "wpd-tagcloud__layout"; host.appendChild(layout); const stage = document.createElement("div"); stage.className = "wpd-tagcloud__stage"; stage.classList.add("is-loading"); layout.appendChild(stage); const sidebar = document.createElement("aside"); sidebar.className = "wpd-tagcloud__sidebar"; layout.appendChild(sidebar); const app = new pixi.Application(); await app.init({ resizeTo: stage, backgroundAlpha: 0, antialias: true, autoDensity: true, resolution: Math.min(window.devicePixelRatio || 1, 2) }); stage.appendChild(app.canvas); app.canvas.classList.add("wpd-tagcloud__canvas"); const world = new pixi.Container(); world.x = stage.clientWidth / 2; world.y = stage.clientHeight / 2; app.stage.addChild(world); const chipLayer = new pixi.Container(); const postEdgeLayer = new pixi.Container(); const postLayer = new pixi.Container(); const postChipLayer = new pixi.Container(); world.addChild(postEdgeLayer); world.addChild(chipLayer); world.addChild(postLayer); world.addChild(postChipLayer); const postEdgeGfx = new pixi.Graphics(); postEdgeLayer.addChild(postEdgeGfx); const pager = new pixi.Container(); pager.eventMode = "passive"; pager.visible = false; postLayer.addChild(pager); const pagerPrev = new pixi.Graphics(); const pagerNext = new pixi.Graphics(); const pagerLabel = new pixi.Text({ text: "1 / 1", style: { fill: 5265246, fontSize: 12, fontFamily: FONT_FAMILY, fontWeight: "600" } }); pagerLabel.anchor.set(0.5); pagerPrev.eventMode = "static"; pagerPrev.cursor = "pointer"; pagerNext.eventMode = "static"; pagerNext.cursor = "pointer"; pagerPrev.hitArea = new pixi.Circle(0, 0, 16); pagerNext.hitArea = new pixi.Circle(0, 0, 16); pager.addChild(pagerPrev); pager.addChild(pagerLabel); pager.addChild(pagerNext); const stopBubble = (e) => { e.stopPropagation?.(); pixiInteractionAt = performance.now(); }; pagerPrev.on("pointerdown", stopBubble); pagerNext.on("pointerdown", stopBubble); pagerPrev.on("pointertap", (e) => { stopBubble(e); lastFocusChange = performance.now(); if (focusPage <= 1) { return; } focusPage--; void loadPostsForFocus(); }); pagerNext.on("pointertap", (e) => { stopBubble(e); lastFocusChange = performance.now(); if (focusPage >= focusTotalPages) { return; } focusPage++; void loadPostsForFocus(); }); const tags = /* @__PURE__ */ new Map(); const postChips = /* @__PURE__ */ new Map(); const postNodes = /* @__PURE__ */ new Map(); let focusId = null; let focusPage = 1; let focusTotalPages = 1; let loadSeq = 0; let pixiInteractionAt = 0; let dragChip = null; let dragOffset = { x: 0, y: 0 }; let dragStart = null; let panActive = false; let panStart = null; let panMovedDist = 0; let raf = null; let lastTick = performance.now(); let targetScale = world.scale.x; let targetWorldX = world.x; let targetWorldY = world.y; let nudgeAwayFrom = null; let prevView = null; let lastFocusChange = 0; let draft = null; let terms = []; const positionsKey = computePositionsKey(); const persistedPositions = readPersistedPositions(positionsKey); let cooccurrenceMap = /* @__PURE__ */ new Map(); const themeHue = readAdminThemeHue(); try { const all = []; let page = 1; while (page <= 5) { const res = await client.fetchTerms("tags", { page, perPage: 100 }); all.push(...res.items); if (page >= res.totalPages) { break; } page++; } terms = all; } catch (err) { showToast(__("Couldn’t load tags:"), err); } const showError = (title, err) => showToast(title, err); function buildCloud() { const liveIds = new Set(terms.map((t) => t.id)); for (const [id, box] of tags) { if (!liveIds.has(id)) { chipLayer.removeChild(box.chip.container); box.chip.container.destroy({ children: true }); tags.delete(id); } } const maxCount = Math.max(1, ...terms.map((t) => t.count)); const fresh = []; for (const term of terms) { const fontSize = fontSizeFor(term.count, maxCount); const hue = tagHue(term.slug || term.name, themeHue); const rotation = tagRotation(term.slug || term.name); const existing = tags.get(term.id); if (existing) { existing.name = term.name; existing.slug = term.slug; existing.description = term.description; existing.count = term.count; existing.fontSize = fontSize; existing.hue = hue; existing.rotation = rotation; layoutChip(existing); } else { const chip = createTagChip(pixi, chipLayer, term, fontSize, hue); const persisted = persistedPositions.get(term.id); const box = { id: term.id, name: term.name, slug: term.slug, description: term.description, count: term.count, fontSize, hue, rotation, x: persisted ? persisted.x : 0, y: persisted ? persisted.y : 0, tx: persisted ? persisted.x : 0, ty: persisted ? persisted.y : 0, width: 0, height: 0, chip }; tags.set(term.id, box); layoutChip(box); wireChipPointer(box); if (!persisted) { fresh.push(box); } } } const placed = []; const placedById = /* @__PURE__ */ new Map(); for (const box of tags.values()) { if (!fresh.includes(box)) { placed.push({ x: box.tx - box.width / 2, y: box.ty - box.height / 2, w: box.width, h: box.height }); placedById.set(box.id, { x: box.tx, y: box.ty }); } } fresh.sort((a, b) => b.count - a.count); packBoxesWithClusters(fresh, placed, placedById, cooccurrenceMap); for (const box of fresh) { box.x = box.tx; box.y = box.ty; } } function wireChipPointer(box) { const c = box.chip.container; c.on("pointerdown", (e) => { const ev = e; ev.stopPropagation?.(); pixiInteractionAt = performance.now(); dragChip = box; dragStart = { x: ev.global.x, y: ev.global.y }; const local = stageToWorld({ x: ev.global.x, y: ev.global.y }); dragOffset = { x: box.x - local.x, y: box.y - local.y }; }); c.on("pointerover", () => { box.chip.cachedHover = true; paintChip(box); }); c.on("pointerout", () => { box.chip.cachedHover = false; paintChip(box); }); } function layoutChip(box) { const chip = box.chip; const displayName = truncateChipName(box.name); const countStr = String(box.count); if (chip.nameText.text !== displayName) { chip.nameText.text = displayName; } if (chip.countText.text !== countStr) { chip.countText.text = countStr; } chip.nameText.style.fontSize = box.fontSize; chip.hashText.style.fontSize = box.fontSize; chip.countText.style.fontSize = Math.max( 10, Math.round(box.fontSize * 0.55) ); chip.cachedName = displayName; chip.cachedCount = box.count; chip.cachedHue = box.hue; const hashW = chip.hashText.width; const nameW = chip.nameText.width; const nameH = chip.nameText.height; const countW = chip.countText.width; const countH = chip.countText.height; const countBadgeW = Math.max(18, countW + 10); const countBadgeH = Math.max(14, countH + 4); const totalW = CHIP_PAD_X + hashW + CHIP_GAP_HASH + nameW + CHIP_GAP_COUNT + countBadgeW + CHIP_PAD_X; const totalH = Math.max(nameH, countBadgeH) + CHIP_PAD_Y * 2; box.width = totalW; box.height = totalH; paintChip(box); } function paintChip(box) { const chip = box.chip; const focused = focusId === box.id; chip.cachedFocused = focused; const totalW = box.width; const totalH = box.height; const left = -totalW / 2; const top = -totalH / 2; const radius = totalH / 2; let fillBg; if (focused) { fillBg = hslToInt(box.hue, 70, 48); } else if (chip.cachedHover) { fillBg = hslToInt(box.hue, 70, 92); } else { fillBg = hslToInt(box.hue, 60, 95); } const borderColor = focused ? hslToInt(box.hue, 70, 38) : hslToInt(box.hue, 50, 70); const textColor = focused ? 16777215 : 1909543; const hashColor = focused ? 16777215 : hslToInt(box.hue, 65, 42); const countBg = focused ? hslToInt(box.hue, 80, 30) : hslToInt(box.hue, 70, 50); chip.shadow.clear(); chip.shadow.roundRect( left - 1, top + 3, totalW + 2, totalH + 2, radius + 1 ); let shadowAlpha = 0.1; if (focused) { shadowAlpha = 0.18; } else if (chip.cachedHover) { shadowAlpha = 0.16; } chip.shadow.fill({ color: 0, alpha: shadowAlpha }); chip.bg.clear(); chip.bg.roundRect(left, top, totalW, totalH, radius); chip.bg.fill(fillBg); chip.bg.stroke({ color: borderColor, width: focused ? 2 : 1.25, alpha: focused ? 1 : 0.85 }); const hashW = chip.hashText.width; const nameW = chip.nameText.width; const nameH = chip.nameText.height; const countW = chip.countText.width; const countH = chip.countText.height; const countBadgeW = Math.max(18, countW + 10); const countBadgeH = Math.max(14, countH + 4); chip.hashText.x = left + CHIP_PAD_X; chip.hashText.y = (totalH - nameH) / 2 + top; chip.hashText.style.fill = hashColor; chip.nameText.x = left + CHIP_PAD_X + hashW + CHIP_GAP_HASH; chip.nameText.y = (totalH - nameH) / 2 + top; chip.nameText.style.fill = textColor; const badgeX = left + CHIP_PAD_X + hashW + CHIP_GAP_HASH + nameW + CHIP_GAP_COUNT; const badgeY = (totalH - countBadgeH) / 2 + top; chip.bg.roundRect( badgeX, badgeY, countBadgeW, countBadgeH, countBadgeH / 2 ); chip.bg.fill(countBg); chip.countText.x = badgeX + (countBadgeW - countW) / 2; chip.countText.y = badgeY + (countBadgeH - countH) / 2; chip.countText.style.fill = 16777215; } function findSpiralSlot(w, h, placed, anchorX = 0, anchorY = 0) { if (placed.length === 0) { return { x: anchorX, y: anchorY }; } const padding = SPIRAL_PADDING; { const aabb = { x: anchorX - w / 2 - padding, y: anchorY - h / 2 - padding, w: w + padding * 2, h: h + padding * 2 }; let overlap = false; for (const p of placed) { if (aabbIntersect(aabb, p)) { overlap = true; break; } } if (!overlap) { return { x: anchorX, y: anchorY }; } } let theta = 0; const maxIter = 1e4; for (let i = 0; i < maxIter; i++) { theta += 0.18; const r = theta * 5; const cx = anchorX + r * Math.cos(theta); const cy = anchorY + r * Math.sin(theta) * 0.7; const aabb = { x: cx - w / 2 - padding, y: cy - h / 2 - padding, w: w + padding * 2, h: h + padding * 2 }; let overlap = false; for (const p of placed) { if (aabbIntersect(aabb, p)) { overlap = true; break; } } if (!overlap) { return { x: cx, y: cy }; } } return { x: anchorX, y: anchorY + (placed.length + 1) * (h + padding) }; } function packBoxesWithClusters(boxesInOrder, placed, placedById, cooccurrence) { let clusterCounter = 0; const allocateClusterAnchor = () => { const idx = clusterCounter++; if (idx === 0) { return { x: 0, y: 0 }; } const theta = idx * 2.4; const radius = 120 + idx * 70; return { x: radius * Math.cos(theta), y: radius * Math.sin(theta) * 0.8 }; }; for (const box of boxesInOrder) { let anchorX = 0; let anchorY = 0; let usedCentroid = false; const neighbors = cooccurrence.get(box.id); if (neighbors && neighbors.length > 0) { let sumX = 0; let sumY = 0; let sumW = 0; for (const n of neighbors) { const pos = placedById.get(n.id); if (!pos) { continue; } sumX += pos.x * n.shared; sumY += pos.y * n.shared; sumW += n.shared; } if (sumW > 0) { anchorX = sumX / sumW; anchorY = sumY / sumW; usedCentroid = true; } } if (!usedCentroid) { const anchor = allocateClusterAnchor(); anchorX = anchor.x; anchorY = anchor.y; } const slot = findSpiralSlot( box.width, box.height, placed, anchorX, anchorY ); box.tx = slot.x; box.ty = slot.y; placedById.set(box.id, { x: slot.x, y: slot.y }); placed.push({ x: slot.x - box.width / 2, y: slot.y - box.height / 2, w: box.width, h: box.height }); } } function syncChipPositions() { const chipCounterScale = 1 / Math.max(0.01, world.scale.x); const anyFocus = focusId !== null; for (const box of tags.values()) { const c = box.chip.container; c.x = box.x; c.y = box.y; const counter = Math.max(1, chipCounterScale); c.scale.set(counter); c.rotation = box.rotation; const focused = focusId === box.id; const targetAlpha = !anyFocus || focused ? 1 : 0.32; if (Math.abs(c.alpha - targetAlpha) > 5e-3) { c.alpha += (targetAlpha - c.alpha) * 0.18; } else { c.alpha = targetAlpha; } } for (const post of postNodes.values()) { const chip = postChips.get(post.id); if (!chip) { continue; } chip.container.x = post.x; chip.container.y = post.y; chip.container.scale.set(chipCounterScale); if (chip.container.alpha < 1) { chip.container.alpha = Math.min( 1, chip.container.alpha + 0.18 ); } } } function tick() { const now = performance.now(); const dt = Math.min(50, now - lastTick); lastTick = now; const ZOOM_EASE = 0.22; const ds = targetScale - world.scale.x; const dwx = targetWorldX - world.x; const dwy = targetWorldY - world.y; if (Math.abs(ds) > 5e-4 || Math.abs(dwx) > 0.5 || Math.abs(dwy) > 0.5) { world.scale.set(world.scale.x + ds * ZOOM_EASE); world.x += dwx * ZOOM_EASE; world.y += dwy * ZOOM_EASE; } for (const box of tags.values()) { if (box === dragChip) { continue; } let tx = box.tx; let ty = box.ty; if (nudgeAwayFrom && box.id !== focusId) { const dx = box.tx - nudgeAwayFrom.x; const dy = box.ty - nudgeAwayFrom.y; const d = Math.sqrt(dx * dx + dy * dy) || 1; const limit = nudgeAwayFrom.radius + Math.max(box.width, box.height) / 2; if (d < limit) { const push = limit + 12; tx = nudgeAwayFrom.x + dx / d * push; ty = nudgeAwayFrom.y + dy / d * push; } } const ease = 1 - Math.exp(-dt * 0.012); box.x += (tx - box.x) * ease; box.y += (ty - box.y) * ease; } for (const p of postNodes.values()) { p.x += (p.tx - p.x) * 0.18; p.y += (p.ty - p.y) * 0.18; p.gfx.x = p.x; p.gfx.y = p.y; } drawPostEdges(); syncChipPositions(); raf = requestAnimationFrame(tick); } function drawPostEdges() { postEdgeGfx.clear(); if (focusId === null) { return; } const center = tags.get(focusId); if (!center) { return; } for (const post of postNodes.values()) { postEdgeGfx.moveTo(center.x, center.y); postEdgeGfx.lineTo(post.x, post.y); postEdgeGfx.stroke({ color: hslToInt(center.hue, 60, 50), width: 1, alpha: 0.35 }); } } function stageToWorld(global) { return { x: (global.x - world.x) / world.scale.x, y: (global.y - world.y) / world.scale.y }; } function onStagePointerDown(e) { const ev = e; panActive = true; panStart = { x: ev.global.x, y: ev.global.y }; panMovedDist = 0; } function onStagePointerMove(e) { const ev = e; if (dragChip) { const cursorWorld = stageToWorld(ev.global); const nx = cursorWorld.x + dragOffset.x; const ny = cursorWorld.y + dragOffset.y; dragChip.x = nx; dragChip.y = ny; dragChip.tx = nx; dragChip.ty = ny; return; } if (panActive && panStart) { const dx = ev.global.x - panStart.x; const dy = ev.global.y - panStart.y; world.x += dx; world.y += dy; targetWorldX += dx; targetWorldY += dy; panMovedDist += Math.sqrt(dx * dx + dy * dy); panStart = { x: ev.global.x, y: ev.global.y }; } } function onStagePointerUp(e) { if (dragChip) { const box = dragChip; const startPos = dragStart; dragChip = null; dragStart = null; let movement = Infinity; const ev = e; if (startPos && ev && ev.global) { const dx = ev.global.x - startPos.x; const dy = ev.global.y - startPos.y; movement = Math.sqrt(dx * dx + dy * dy); } if (movement < 3) { void focusTag(box.id); } else { persistedPositions.set(box.id, { x: box.tx, y: box.ty }); writePersistedPositions(positionsKey, persistedPositions); } } panActive = false; panStart = null; } app.stage.eventMode = "static"; app.stage.hitArea = new pixi.Rectangle( 0, 0, stage.clientWidth, stage.clientHeight ); app.stage.on("pointerdown", onStagePointerDown); app.stage.on("pointermove", onStagePointerMove); app.stage.on("pointerup", (e) => onStagePointerUp(e)); app.stage.on("pointerupoutside", (e) => onStagePointerUp(e)); function onWheel(e) { e.preventDefault(); const SENSITIVITY = 8e-4; const factor = Math.exp(-e.deltaY * SENSITIVITY); const prev = targetScale; const next = Math.max(0.3, Math.min(2.5, prev * factor)); if (Math.abs(next - prev) < 5e-4) { return; } const r = stage.getBoundingClientRect(); const sx = e.clientX - r.left; const sy = e.clientY - r.top; const wx = (sx - targetWorldX) / prev; const wy = (sy - targetWorldY) / prev; targetScale = next; targetWorldX = sx - wx * next; targetWorldY = sy - wy * next; } stage.addEventListener("wheel", onWheel, { passive: false }); let firstFitDone = false; let settledW = 0; let settledH = 0; const SETTLE_THRESHOLD_PX = 24; const SETTLE_DEBOUNCE_MS = 80; let settleTimer = null; function onResize() { const r = stage.getBoundingClientRect(); app.renderer.resize(r.width, r.height); app.stage.hitArea = new pixi.Rectangle(0, 0, r.width, r.height); if (!firstFitDone && r.width > 0 && r.height > 0) { firstFitDone = true; settledW = r.width; settledH = r.height; fitToView(); stage.classList.remove("is-loading"); } if (settleTimer !== null) { window.clearTimeout(settleTimer); } settleTimer = window.setTimeout(() => { settleTimer = null; const cur = stage.getBoundingClientRect(); const dw = Math.abs(cur.width - settledW); const dh = Math.abs(cur.height - settledH); if (dw >= SETTLE_THRESHOLD_PX || dh >= SETTLE_THRESHOLD_PX) { settledW = cur.width; settledH = cur.height; recenterCamera(); } }, SETTLE_DEBOUNCE_MS); app.render(); } const ro = new ResizeObserver(onResize); ro.observe(stage); async function focusTag(id) { if (focusId === id) { closeFocus(); return; } const wasFocused = focusId !== null; focusId = id; focusPage = 1; lastFocusChange = performance.now(); const focused = tags.get(id); if (focused) { if (!wasFocused) { prevView = { scale: targetScale, x: targetWorldX, y: targetWorldY }; } const r = stage.getBoundingClientRect(); if (r.width > 0 && r.height > 0) { const half = POST_RING_RADIUS + 70; const sx = r.width * 0.85 / (2 * half); const sy = r.height * 0.85 / (2 * half); const newScale = Math.max( 0.5, Math.min(1.6, Math.min(sx, sy)) ); targetScale = newScale; targetWorldX = r.width / 2 - focused.x * newScale; targetWorldY = r.height / 2 - focused.y * newScale; } nudgeAwayFrom = { x: focused.x, y: focused.y, radius: SPOTLIGHT_RADIUS }; } for (const box of tags.values()) { paintChip(box); } paintSidebar(); await loadPostsForFocus(); } function closeFocus() { focusId = null; lastFocusChange = performance.now(); loadSeq++; nudgeAwayFrom = null; if (prevView) { targetScale = prevView.scale; targetWorldX = prevView.x; targetWorldY = prevView.y; prevView = null; } paintSidebar(); clearPosts(); for (const box of tags.values()) { paintChip(box); } } function clearPosts() { for (const post of postNodes.values()) { postLayer.removeChild(post.gfx); post.gfx.destroy(); } postNodes.clear(); for (const chip of postChips.values()) { postChipLayer.removeChild(chip.container); chip.container.destroy({ children: true }); } postChips.clear(); postEdgeGfx.clear(); pager.visible = false; } function ensurePostChip(post) { const existing = postChips.get(post.id); if (existing) { return existing; } const container = new pixi.Container(); container.eventMode = "static"; container.cursor = "pointer"; container.alpha = 0; const bg = new pixi.Graphics(); container.addChild(bg); const dot = new pixi.Graphics(); container.addChild(dot); const titleText = new pixi.Text({ text: post.title, style: { fill: 1909543, fontSize: 12, fontFamily: FONT_FAMILY, fontWeight: "500" }, resolution: CHIP_TEXT_RES }); container.addChild(titleText); const chip = { container, bg, dot, titleText, width: 0, height: 0, cachedTitle: "", cachedHover: false }; postChips.set(post.id, chip); postChipLayer.addChild(container); container.on("pointerdown", (e) => { e.stopPropagation?.(); pixiInteractionAt = performance.now(); }); container.on("pointertap", () => { openInPostsTab(post.id, post.editUrl, post.title); closeFocus(); }); container.on("pointerover", () => { chip.cachedHover = true; layoutPostChip(chip, post); }); container.on("pointerout", () => { chip.cachedHover = false; layoutPostChip(chip, post); }); layoutPostChip(chip, post); return chip; } function layoutPostChip(chip, post) { const displayTitle = post.title.length > POST_TITLE_MAX_CHARS ? post.title.slice(0, POST_TITLE_MAX_CHARS - 1) + "…" : post.title; if (chip.titleText.text !== displayTitle) { chip.titleText.text = displayTitle; } chip.cachedTitle = displayTitle; const padX = 9; const padY = 3; const dotR = 4; const gap = 6; const titleW = chip.titleText.width; const titleH = chip.titleText.height; const totalW = padX + dotR * 2 + gap + titleW + padX; const totalH = Math.max(titleH, dotR * 2) + padY * 2; chip.width = totalW; chip.height = totalH; const left = -totalW / 2; const top = -totalH / 2; chip.bg.clear(); chip.bg.roundRect(left, top, totalW, totalH, totalH / 2); if (chip.cachedHover) { chip.bg.fill({ color: 16777215, alpha: 1 }); chip.bg.stroke({ color: post.tone, width: 1.5, alpha: 1 }); } else { chip.bg.fill({ color: 16777215, alpha: 0.95 }); chip.bg.stroke({ color: 0, width: 1, alpha: 0.12 }); } chip.dot.clear(); chip.dot.circle(left + padX + dotR, 0, dotR); chip.dot.fill({ color: post.tone, alpha: 0.85 }); chip.dot.stroke({ color: 16777215, width: 1 }); chip.titleText.x = left + padX + dotR * 2 + gap; chip.titleText.y = -titleH / 2; } const POSTS_CACHE_TTL_MS = 6e4; const postsCache = /* @__PURE__ */ new Map(); function applyPostsResult(entry, focusedTagId) { focusTotalPages = entry.totalPages; if (Number.isFinite(entry.realTotal)) { const box = tags.get(focusedTagId); if (box && box.count !== entry.realTotal) { box.count = entry.realTotal; terms = terms.map( (t) => t.id === box.id ? { ...t, count: entry.realTotal } : t ); layoutChip(box); } } renderPosts(entry.items); } async function loadPostsForFocus() { if (focusId === null) { return; } const mySeq = ++loadSeq; const myFocusId = focusId; const cacheKey2 = `${focusId}:${focusPage}`; const cached = postsCache.get(cacheKey2); if (cached && performance.now() - cached.fetchedAt < POSTS_CACHE_TTL_MS) { applyPostsResult(cached, myFocusId); return; } const cfg = client.getConfig(); const url = new URL(cfg.postsUrl); url.searchParams.set("tags", String(focusId)); url.searchParams.set("per_page", String(POST_PER_PAGE)); url.searchParams.set("page", String(focusPage)); url.searchParams.set("status", "any"); url.searchParams.set("_fields", "id,title,status"); try { const response = await fetchShellJson(client, url.toString()); if (mySeq !== loadSeq || focusId !== myFocusId) { return; } const raw = response.json ?? []; const totalPages = Math.max( 1, parseInt(response.headers.get("X-WP-TotalPages") ?? "1", 10) || 1 ); const realTotalParsed = parseInt(response.headers.get("X-WP-Total") ?? "", 10); const realTotal = Number.isFinite(realTotalParsed) ? realTotalParsed : -1; const items = raw.map((p) => ({ id: p.id, title: stripTags(p.title?.rendered || `#${p.id}`), editUrl: `${cfg.editPostUrlBase}?post=${p.id}&action=edit` })); const entry = { items, totalPages, realTotal, fetchedAt: performance.now() }; postsCache.set(cacheKey2, entry); applyPostsResult(entry, myFocusId); } catch (err) { showError(__("Couldn’t load posts:"), err); } } function renderPosts(items) { clearPosts(); if (focusId === null) { return; } const center = tags.get(focusId); if (!center) { return; } const count = items.length; const ringR = POST_RING_RADIUS + Math.max(0, count - 8) * 6; const tone = hslToInt(center.hue, 70, 48); items.forEach((item, idx) => { const angle = 2 * Math.PI / Math.max(1, count) * idx - Math.PI / 2; const tx = center.x + Math.cos(angle) * ringR; const ty = center.y + Math.sin(angle) * ringR; const gfx = new pixi.Graphics(); postLayer.addChild(gfx); const post = { id: item.id, title: item.title, editUrl: item.editUrl, angle, r: ringR, x: center.x, y: center.y, tx, ty, gfx, tone }; postNodes.set(item.id, post); ensurePostChip(post); }); repaintPager(); } function repaintPager() { if (focusId === null || focusTotalPages <= 1) { pager.visible = false; return; } pager.visible = true; const center = tags.get(focusId); if (!center) { pager.visible = false; return; } const prevDisabled = focusPage <= 1; const nextDisabled = focusPage >= focusTotalPages; drawPagerButton(pagerPrev, "◀", prevDisabled); drawPagerButton(pagerNext, "▶", nextDisabled); pagerPrev.cursor = prevDisabled ? "default" : "pointer"; pagerNext.cursor = nextDisabled ? "default" : "pointer"; pagerLabel.text = `${focusPage} / ${focusTotalPages}`; pagerPrev.x = -38; pagerPrev.y = 0; pagerNext.x = 38; pagerNext.y = 0; pagerLabel.x = 0; pagerLabel.y = 0; pager.x = center.x; pager.y = center.y + POST_RING_RADIUS + 60; } function drawPagerButton(gfx, glyph, disabled) { gfx.clear(); gfx.circle(0, 0, 14); gfx.fill({ color: disabled ? 15921906 : 16777215, alpha: disabled ? 0.7 : 1 }); gfx.stroke({ color: 0, width: 1, alpha: 0.12 }); const children = gfx.children; const label = children?.[0] ?? null; if (!label) { const t = new pixi.Text({ text: glyph, style: { fill: disabled ? 11580344 : 5265246, fontSize: 14, fontFamily: FONT_FAMILY, fontWeight: "600" } }); t.anchor.set(0.5); gfx.addChild(t); } else { label.text = glyph; label.style.fill = disabled ? 11580344 : 5265246; } } function openInPostsTab(_id, editUrl, title) { const wm = api?.windowManager; const derive = api?.deriveWindowId; const postsWin = wm && typeof wm.getById === "function" ? wm.getById("desktop-mode-posts") : void 0; if (postsWin && typeof postsWin.isFullscreen === "function" && typeof postsWin.toggleFullscreen === "function" && postsWin.isFullscreen()) { postsWin.toggleFullscreen(); } if (wm && typeof derive === "function") { const id = derive(editUrl); wm.open({ id, baseId: id, url: editUrl, title: title ?? editUrl, icon: "dashicons-admin-post" }); return; } try { window.open(editUrl, "_blank"); } catch { window.location.assign(editUrl); } } function paintDraftSidebar() { const header = document.createElement("div"); header.className = "wpd-tagcloud__sidebar-header"; const dot = document.createElement("span"); dot.className = "wpd-tagcloud__sidebar-dot"; dot.style.background = `hsl( ${themeHue}deg 60% 55% )`; const label = document.createElement("code"); label.className = "wpd-tagcloud__sidebar-slug"; label.textContent = __("New tag"); header.appendChild(dot); header.appendChild(label); sidebar.appendChild(header); const nameLabel = document.createElement("label"); nameLabel.className = "wpd-tagcloud__sidebar-label"; nameLabel.textContent = __("Name"); sidebar.appendChild(nameLabel); const nameInput = document.createElement("input"); nameInput.type = "text"; nameInput.className = "wpd-tagcloud__editor-name"; nameInput.placeholder = __("e.g. featured"); sidebar.appendChild(nameInput); requestAnimationFrame(() => nameInput.focus()); const descLabel = document.createElement("label"); descLabel.className = "wpd-tagcloud__sidebar-label"; descLabel.textContent = __("Description"); sidebar.appendChild(descLabel); const descInput = document.createElement("textarea"); descInput.className = "wpd-tagcloud__editor-desc"; descInput.placeholder = __("Description (optional)"); descInput.rows = 4; sidebar.appendChild(descInput); const actions = document.createElement("div"); actions.className = "wpd-tagcloud__editor-actions"; const createBtn = document.createElement("button"); createBtn.type = "button"; createBtn.className = "wpd-tagcloud__btn wpd-tagcloud__btn--primary"; createBtn.textContent = __("Create"); const cancelBtn = document.createElement("button"); cancelBtn.type = "button"; cancelBtn.className = "wpd-tagcloud__btn wpd-tagcloud__btn--danger"; cancelBtn.textContent = __("Cancel"); const handleCreate = async () => { const name = nameInput.value.trim(); if (!name) { nameInput.focus(); return; } createBtn.disabled = true; try { const created = await client.createTag(name); const next = { id: created.id, name: created.name, slug: created.slug || "", parent: 0, count: 0, description: created.description || "", isDefault: false }; if (!terms.some((t) => t.id === next.id)) { terms = terms.concat(next); } const desc = descInput.value.trim(); if (desc) { try { const updated = await client.updateTerm( "tags", created.id, { description: desc } ); terms = terms.map( (t) => t.id === updated.id ? { ...t, description: updated.description ?? desc } : t ); } catch { showError( __("Tag created but description failed:"), null ); } } draft = null; buildCloud(); focusId = created.id; paintSidebar(); await loadPostsForFocus(); } catch (err) { createBtn.disabled = false; showError(__("Couldn’t create:"), err); } }; createBtn.addEventListener("click", () => { void handleCreate(); }); cancelBtn.addEventListener("click", () => { draft = null; paintSidebar(); }); nameInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); void handleCreate(); } else if (e.key === "Escape") { draft = null; paintSidebar(); } }); actions.appendChild(createBtn); actions.appendChild(cancelBtn); sidebar.appendChild(actions); } function paintSidebar() { sidebar.replaceChildren(); if (draft !== null) { paintDraftSidebar(); return; } if (focusId === null) { const empty = document.createElement("div"); empty.className = "wpd-tagcloud__sidebar-empty"; const icon = document.createElement("span"); icon.className = "dashicons dashicons-tag"; icon.setAttribute("aria-hidden", "true"); empty.appendChild(icon); const title = document.createElement("h3"); title.className = "wpd-tagcloud__sidebar-empty-title"; title.textContent = __("No tag selected"); empty.appendChild(title); const help = document.createElement("p"); help.className = "wpd-tagcloud__sidebar-empty-hint"; help.textContent = __( "Click a tag on the cloud to edit it, or click + Add tag to create a new one." ); empty.appendChild(help); sidebar.appendChild(empty); return; } const box = tags.get(focusId); if (!box) { focusId = null; paintSidebar(); return; } const id = box.id; const header = document.createElement("div"); header.className = "wpd-tagcloud__sidebar-header"; const dot = document.createElement("span"); dot.className = "wpd-tagcloud__sidebar-dot"; dot.style.background = `hsl( ${box.hue}deg 60% 55% )`; const term = terms.find((t) => t.id === id); const idLabel = document.createElement("code"); idLabel.className = "wpd-tagcloud__sidebar-slug"; idLabel.textContent = `#${id}`; header.appendChild(dot); header.appendChild(idLabel); sidebar.appendChild(header); const nameLabel = document.createElement("label"); nameLabel.className = "wpd-tagcloud__sidebar-label"; nameLabel.textContent = __("Name"); sidebar.appendChild(nameLabel); const nameInput = document.createElement("input"); nameInput.type = "text"; nameInput.className = "wpd-tagcloud__editor-name"; nameInput.value = box.name; nameInput.placeholder = __("Name"); sidebar.appendChild(nameInput); const slugLabel = document.createElement("label"); slugLabel.className = "wpd-tagcloud__sidebar-label"; slugLabel.textContent = __("Slug"); sidebar.appendChild(slugLabel); const slugInput = document.createElement("input"); slugInput.type = "text"; slugInput.className = "wpd-tagcloud__editor-name"; slugInput.value = term?.slug || ""; slugInput.placeholder = __("auto-from-name"); slugInput.spellcheck = false; slugInput.autocapitalize = "off"; slugInput.addEventListener("input", () => { const v = slugInput.value; const norm = v.toLowerCase().replace(/[^a-z0-9-]+/g, "-"); if (v !== norm) { const sel = slugInput.selectionStart ?? norm.length; slugInput.value = norm; slugInput.setSelectionRange(sel, sel); } }); sidebar.appendChild(slugInput); const descLabel = document.createElement("label"); descLabel.className = "wpd-tagcloud__sidebar-label"; descLabel.textContent = __("Description"); sidebar.appendChild(descLabel); const descInput = document.createElement("textarea"); descInput.className = "wpd-tagcloud__editor-desc"; descInput.value = box.description || ""; descInput.placeholder = __("Description (optional)"); descInput.rows = 4; sidebar.appendChild(descInput); const meta = document.createElement("p"); meta.className = "wpd-tagcloud__sidebar-meta"; meta.textContent = sprintf( /* translators: %d: post count. */ __("%d posts tagged with this."), box.count ); sidebar.appendChild(meta); const actions = document.createElement("div"); actions.className = "wpd-tagcloud__editor-actions"; const saveBtn = document.createElement("button"); saveBtn.type = "button"; saveBtn.className = "wpd-tagcloud__btn wpd-tagcloud__btn--primary"; saveBtn.textContent = __("Save"); saveBtn.addEventListener("click", async () => { const name = nameInput.value.trim(); if (!name) { return; } const description = descInput.value; const slugRaw = slugInput.value.trim(); const currentSlug = term?.slug ?? ""; if (name === box.name && description === (box.description || "") && slugRaw === currentSlug) { return; } const patch = { name, description }; if (slugRaw !== currentSlug) { patch.slug = slugRaw; } try { const updated = await client.updateTerm("tags", box.id, patch); box.name = updated.name; box.description = updated.description; box.slug = updated.slug ?? box.slug; box.hue = tagHue(box.slug || box.name, themeHue); box.rotation = tagRotation(box.slug || box.name); terms = terms.map( (t) => t.id === box.id ? { ...t, name: updated.name, description: updated.description, slug: updated.slug ?? t.slug } : t ); layoutChip(box); paintSidebar(); } catch (err) { showError(__("Couldn’t save:"), err); } }); const delBtn = document.createElement("button"); delBtn.type = "button"; delBtn.className = "wpd-tagcloud__btn wpd-tagcloud__btn--danger"; delBtn.textContent = __("Delete"); let armResetTimer = null; const armDelete = () => { delBtn.textContent = __("Click again to delete"); delBtn.classList.add("is-armed"); if (armResetTimer !== null) { window.clearTimeout(armResetTimer); } armResetTimer = window.setTimeout(() => { delBtn.textContent = __("Delete"); delBtn.classList.remove("is-armed"); armResetTimer = null; }, 2500); }; delBtn.addEventListener("click", async () => { if (!delBtn.classList.contains("is-armed")) { armDelete(); return; } if (armResetTimer !== null) { window.clearTimeout(armResetTimer); armResetTimer = null; } try { await client.deleteTerm("tags", box.id); terms = terms.filter((t) => t.id !== box.id); persistedPositions.delete(box.id); writePersistedPositions(positionsKey, persistedPositions); focusId = null; clearPosts(); buildCloud(); paintSidebar(); } catch (err) { showError(__("Couldn’t delete:"), err); } }); actions.appendChild(saveBtn); actions.appendChild(delBtn); sidebar.appendChild(actions); } function startDraft() { draft = true; paintSidebar(); } addTagBtn.addEventListener("click", () => { startDraft(); }); function fitToView(opts = {}) { const padding = opts.padding ?? 90; const animate = opts.animate ?? false; const r = stage.getBoundingClientRect(); if (tags.size === 0 || r.width === 0 || r.height === 0) { const cx2 = r.width / 2; const cy2 = r.height / 2; targetScale = 1; targetWorldX = cx2; targetWorldY = cy2; if (!animate) { world.x = cx2; world.y = cy2; world.scale.set(1); } return; } let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; for (const box of tags.values()) { minX = Math.min(minX, box.tx - box.width / 2); minY = Math.min(minY, box.ty - box.height / 2); maxX = Math.max(maxX, box.tx + box.width / 2); maxY = Math.max(maxY, box.ty + box.height / 2); } const w = Math.max(1, maxX - minX); const h = Math.max(1, maxY - minY); const sx = (r.width - padding * 2) / w; const sy = (r.height - padding * 2) / h; const scale = Math.max(0.2, Math.min(1.5, Math.min(sx, sy))); const cx = (minX + maxX) / 2; const cy = (minY + maxY) / 2; const newWorldX = r.width / 2 - cx * scale; const newWorldY = r.height / 2 - cy * scale; targetScale = scale; targetWorldX = newWorldX; targetWorldY = newWorldY; if (!animate) { world.scale.set(scale); world.x = newWorldX; world.y = newWorldY; } } function recenterCamera() { if (focusId !== null) { const focused = tags.get(focusId); const r = stage.getBoundingClientRect(); if (focused && r.width > 0 && r.height > 0) { const half = POST_RING_RADIUS + 70; const sx = r.width * 0.85 / (2 * half); const sy = r.height * 0.85 / (2 * half); const newScale = Math.max( 0.5, Math.min(1.6, Math.min(sx, sy)) ); targetScale = newScale; targetWorldX = r.width / 2 - focused.x * newScale; targetWorldY = r.height / 2 - focused.y * newScale; return; } } fitToView({ animate: true }); } recenterBtn.addEventListener("click", () => recenterCamera()); reflowBtn.addEventListener("click", () => { persistedPositions.clear(); writePersistedPositions(positionsKey, persistedPositions); for (const box of tags.values()) { box.tx = 0; box.ty = 0; } const allBoxes = Array.from(tags.values()); allBoxes.sort((a, b) => b.count - a.count); packBoxesWithClusters( allBoxes, [], /* @__PURE__ */ new Map(), cooccurrenceMap ); fitToView({ animate: true }); void refreshCooccurrence(); }); app.canvas.addEventListener("click", (e) => { const now = performance.now(); if (now - lastFocusChange < 250 || now - pixiInteractionAt < 250) { return; } if (panMovedDist > 4) { return; } const target = e.target; if (target === app.canvas && !dragChip && focusId !== null) { closeFocus(); } }); async function refreshCountsViaBulk() { if (terms.length === 0) { return; } const cfg = client.getConfig(); const url = new URL( joinRestUrl(cfg.restRoot, "desktop-mode/v1/term-counts") ); url.searchParams.set("taxonomy", "post_tag"); url.searchParams.set( "ids", terms.map((t) => t.id).join(",") ); try { const response = await fetchShellJson(client, url.toString()); const map = response.json; let dirty = false; terms = terms.map((t) => { const fresh = map[String(t.id)]; if (typeof fresh === "number" && fresh !== t.count) { dirty = true; const box = tags.get(t.id); if (box) { box.count = fresh; } return { ...t, count: fresh }; } return t; }); if (dirty) { const maxCount = Math.max( 1, ...terms.map((t) => t.count) ); for (const t of terms) { const box = tags.get(t.id); if (!box) { continue; } box.count = t.count; box.fontSize = fontSizeFor(t.count, maxCount); layoutChip(box); } if (focusId !== null) { paintSidebar(); } } } catch { } } function relayoutWithCooccurrence() { const placed = []; const placedById = /* @__PURE__ */ new Map(); const toRepack = []; for (const box of tags.values()) { if (persistedPositions.has(box.id)) { placed.push({ x: box.tx - box.width / 2, y: box.ty - box.height / 2, w: box.width, h: box.height }); placedById.set(box.id, { x: box.tx, y: box.ty }); } else { toRepack.push(box); } } toRepack.sort((a, b) => b.count - a.count); packBoxesWithClusters(toRepack, placed, placedById, cooccurrenceMap); } async function refreshCooccurrence() { try { const fetched = await client.fetchTagCooccurrence("tags", 8); cooccurrenceMap = fetched; if (cooccurrenceMap.size > 0) { relayoutWithCooccurrence(); } } catch { } } buildCloud(); paintSidebar(); raf = requestAnimationFrame(tick); void refreshCountsViaBulk(); void refreshCooccurrence(); if (terms.length === 0) { const empty = document.createElement("div"); empty.className = "wpd-tagcloud__empty"; empty.textContent = __( 'No tags yet. Click "Add tag" to start building the cloud.' ); stage.appendChild(empty); } let currentMatches = []; let selectedIndex = 0; const repaintHighlight = () => { const items = searchResults.querySelectorAll( ".wpd-tagcloud__search-result" ); items.forEach((el, i) => { const active = i === selectedIndex; el.classList.toggle("is-active", active); if (active) { el.scrollIntoView({ block: "nearest" }); } }); }; const selectMatch = (t) => { searchInput.value = ""; searchResults.hidden = true; searchResults.replaceChildren(); currentMatches = []; selectedIndex = 0; void focusTag(t.id); }; const renderSearchResults = () => { const q = searchInput.value.trim().toLowerCase(); if (q.length === 0) { searchResults.hidden = true; searchResults.replaceChildren(); currentMatches = []; selectedIndex = 0; return; } currentMatches = Array.from(tags.values()).filter( (t) => t.name.toLowerCase().includes(q) || t.slug.toLowerCase().includes(q) ).sort((a, b) => b.count - a.count).slice(0, 10); selectedIndex = 0; searchResults.replaceChildren(); currentMatches.forEach((t, i) => { const li = document.createElement("li"); const btn = document.createElement("button"); btn.type = "button"; btn.className = "wpd-tagcloud__search-result"; if (i === 0) { btn.classList.add("is-active"); } const nameEl = document.createElement("span"); nameEl.className = "wpd-tagcloud__search-title"; nameEl.textContent = t.name || `#${t.id}`; const countEl = document.createElement("span"); countEl.className = "wpd-tagcloud__search-meta"; countEl.textContent = sprintf( /* translators: %d: number of posts assigned to a tag. */ __("%d posts"), t.count ); btn.appendChild(nameEl); btn.appendChild(countEl); btn.addEventListener("mousedown", (ev) => { ev.preventDefault(); selectMatch(t); }); btn.addEventListener("mouseenter", () => { selectedIndex = i; repaintHighlight(); }); li.appendChild(btn); searchResults.appendChild(li); }); searchResults.hidden = currentMatches.length === 0; }; searchInput.addEventListener("input", renderSearchResults); searchInput.addEventListener("focus", renderSearchResults); searchInput.addEventListener("keydown", (ev) => { if (ev.key === "ArrowDown") { if (currentMatches.length === 0) { return; } ev.preventDefault(); selectedIndex = Math.min( selectedIndex + 1, currentMatches.length - 1 ); repaintHighlight(); } else if (ev.key === "ArrowUp") { if (currentMatches.length === 0) { return; } ev.preventDefault(); selectedIndex = Math.max(selectedIndex - 1, 0); repaintHighlight(); } else if (ev.key === "Enter") { if (currentMatches.length === 0) { return; } ev.preventDefault(); selectMatch(currentMatches[selectedIndex]); } else if (ev.key === "Escape") { searchInput.value = ""; searchResults.hidden = true; searchResults.replaceChildren(); currentMatches = []; selectedIndex = 0; } }); searchInput.addEventListener("blur", () => { setTimeout(() => { searchResults.hidden = true; }, 120); }); const onDocClickSearch = (ev) => { if (!searchWrap.contains(ev.target)) { searchResults.hidden = true; } }; document.addEventListener("click", onDocClickSearch); return () => { if (raf !== null) { cancelAnimationFrame(raf); raf = null; } if (settleTimer !== null) { window.clearTimeout(settleTimer); settleTimer = null; } ro.disconnect(); stage.removeEventListener("wheel", onWheel); document.removeEventListener("click", onDocClickSearch); try { app.ticker?.stop(); } catch { } try { app.canvas?.remove(); } catch { } host.replaceChildren(); host.classList.remove("wpd-tagcloud"); }; } function fontSizeFor(count, max) { const ratio = Math.sqrt(count / Math.max(1, max)); return Math.round( MIN_FONT_SIZE + (MAX_FONT_SIZE - MIN_FONT_SIZE) * ratio ); } function truncateChipName(name) { return name.length > CHIP_NAME_MAX_CHARS ? name.slice(0, CHIP_NAME_MAX_CHARS - 1) + "…" : name; } function aabbIntersect(a, b) { return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y; } function slugHash(slug) { let h = 0; for (let i = 0; i < slug.length; i++) { h = (h * 31 + slug.charCodeAt(i)) % 2147483647; } return h; } function tagHue(slug, baseHue) { const h = slugHash(slug); return ((baseHue + h % 256 * 1.4) % 360 + 360) % 360; } function tagRotation(slug) { const h = slugHash(slug); const sign = h % 2 === 0 ? -1 : 1; const mag = Math.floor(h / 2) % 4 * 0.011; return sign * mag; } function readAdminThemeHue() { try { const value = getComputedStyle(document.documentElement).getPropertyValue("--wp-admin-theme-color").trim(); if (!value) { return 210; } const c = document.createElement("span"); c.style.color = value; document.body.appendChild(c); const rgb = getComputedStyle(c).color; c.remove(); const m = rgb.match(/\d+/g); if (!m || m.length < 3) { return 210; } return rgbToHue( parseInt(m[0], 10), parseInt(m[1], 10), parseInt(m[2], 10) ); } catch { return 210; } } function rgbToHue(r, g, b) { const rn = r / 255; const gn = g / 255; const bn = b / 255; const max = Math.max(rn, gn, bn); const min = Math.min(rn, gn, bn); const d = max - min; if (d === 0) { return 210; } let h; switch (max) { case rn: h = (gn - bn) / d + (gn < bn ? 6 : 0); break; case gn: h = (bn - rn) / d + 2; break; default: h = (rn - gn) / d + 4; break; } return Math.round(h * 60); } function hslToInt(h, s, l) { const sn = s / 100; const ln = l / 100; const c = (1 - Math.abs(2 * ln - 1)) * sn; const hp = h / 60; const x = c * (1 - Math.abs(hp % 2 - 1)); let r = 0; let g = 0; let b = 0; if (hp < 1) { r = c; g = x; } else if (hp < 2) { r = x; g = c; } else if (hp < 3) { g = c; b = x; } else if (hp < 4) { g = x; b = c; } else if (hp < 5) { r = x; b = c; } else { r = c; b = x; } const m = ln - c / 2; const ri = Math.round((r + m) * 255); const gi = Math.round((g + m) * 255); const bi = Math.round((b + m) * 255); return ri * 65536 + gi * 256 + bi; } function stripTags(html2) { const tmp = document.createElement("div"); tmp.innerHTML = html2; return tmp.textContent || tmp.innerText || ""; } function showToast(title, err) { const reason = err instanceof Error ? err.message : String(err); const api = window.wp?.desktop; if (api && typeof api.showToast === "function") { api.showToast({ message: `${title} ${reason}`.trim(), duration: 6e3 }); return; } console.error(title, err); } async function fetchShellJson(client, url) { const cfg = client.getConfig(); const init = { method: "GET", credentials: "same-origin", headers: { "X-WP-Nonce": cfg.restNonce, Accept: "application/json" } }; const response = await trackedFetch(url, init, { windowId: "desktop-mode-posts" }); if (!response.ok) { throw new Error(`${response.status} ${response.statusText}`); } const json = await response.json(); return { json, headers: response.headers }; } function computePositionsKey() { try { const host = window.location.host || "unknown"; const path = window.location.pathname.replace(/\/?wp-admin\/?.*$/, ""); return `wpd-tagcloud-positions:${host}${path}`; } catch { return "wpd-tagcloud-positions:fallback"; } } function readPersistedPositions(key) { try { const raw = window.localStorage.getItem(key); if (!raw) { return /* @__PURE__ */ new Map(); } const parsed = JSON.parse(raw); if (!parsed || typeof parsed !== "object") { return /* @__PURE__ */ new Map(); } const out = /* @__PURE__ */ new Map(); for (const [k, v] of Object.entries( parsed )) { const id = parseInt(k, 10); if (!Number.isFinite(id)) { continue; } const pos = v; if (typeof pos?.x === "number" && typeof pos?.y === "number") { out.set(id, { x: pos.x, y: pos.y }); } } return out; } catch { return /* @__PURE__ */ new Map(); } } function writePersistedPositions(key, positions) { try { const obj = {}; for (const [id, pos] of positions) { obj[String(id)] = pos; } window.localStorage.setItem(key, JSON.stringify(obj)); } catch { } } function createTagChip(pixi, chipLayer, term, fontSize, hue) { const container = new pixi.Container(); container.eventMode = "static"; container.cursor = "pointer"; const shadow = new pixi.Graphics(); container.addChild(shadow); const bg = new pixi.Graphics(); container.addChild(bg); const hashText = new pixi.Text({ text: "#", style: { fill: hslToInt(hue, 65, 42), fontSize, fontFamily: FONT_FAMILY, fontWeight: "700" }, resolution: CHIP_TEXT_RES }); container.addChild(hashText); const nameText = new pixi.Text({ text: truncateChipName(term.name), style: { fill: 1909543, fontSize, fontFamily: FONT_FAMILY, fontWeight: "600" }, resolution: CHIP_TEXT_RES }); container.addChild(nameText); const countText = new pixi.Text({ text: String(term.count), style: { fill: 16777215, fontSize: Math.max(10, Math.round(fontSize * 0.55)), fontFamily: FONT_FAMILY, fontWeight: "700" }, resolution: CHIP_TEXT_RES }); container.addChild(countText); chipLayer.addChild(container); return { container, shadow, bg, hashText, nameText, countText, width: 0, height: 0, cachedName: "", cachedCount: -1, cachedFocused: false, cachedHover: false, cachedHue: -1 }; } const tagsCloud = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, mountTagsCloud }, Symbol.toStringTag, { value: "Module" })); async function showUsersIntroDialog() { return new Promise((resolve) => { const backdrop = document.createElement("div"); backdrop.className = "desktop-mode-users-intro__backdrop"; backdrop.setAttribute("role", "presentation"); Object.assign(backdrop.style, { position: "fixed", inset: "0", background: "color-mix(in srgb, var(--wp-admin-theme-color, #1d2327) 60%, transparent)", backdropFilter: "blur(2px)", zIndex: "100000", display: "flex", alignItems: "center", justifyContent: "center", padding: "24px" }); const dialog = document.createElement("div"); dialog.setAttribute("role", "dialog"); dialog.setAttribute("aria-modal", "true"); dialog.setAttribute( "aria-labelledby", "desktop-mode-users-intro-title" ); dialog.className = "desktop-mode-users-intro"; Object.assign(dialog.style, { background: "var(--wp-admin-theme-bg, #fff)", color: "var(--wp-admin-theme-fg, #1d2327)", borderRadius: "14px", boxShadow: "0 24px 60px rgba(0,0,0,.28)", maxWidth: "520px", width: "100%", maxHeight: "90vh", overflow: "auto", padding: "28px 32px 24px", fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif' }); dialog.innerHTML = renderDialogMarkup(); backdrop.appendChild(dialog); document.body.appendChild(backdrop); const primaryBtn = dialog.querySelector( '[data-action="confirm"]' ); const settingsBtn = dialog.querySelector( '[data-action="settings"]' ); primaryBtn?.focus(); let resolved = false; const cleanup = (result) => { if (resolved) { return; } resolved = true; document.removeEventListener("keydown", onKey, true); backdrop.remove(); resolve(result); }; const onKey = (e) => { if (e.key === "Escape") { e.preventDefault(); cleanup("cancel"); } }; document.addEventListener("keydown", onKey, true); backdrop.addEventListener("click", (e) => { if (e.target === backdrop) { cleanup("cancel"); } }); primaryBtn?.addEventListener("click", () => cleanup("confirm")); settingsBtn?.addEventListener("click", () => cleanup("settings")); }); } function renderDialogMarkup() { const title = __("Welcome to the new Users window"); const lede = __( "Same data you already manage, with the polish the Users list has been waiting for." ); const highlights = [ __("Live online indicator on every row — see who is around right now."), __("Last-login column so you finally know who is actually using the site."), __("Bulk role change with strict role-permission enforcement — never accidentally promote anyone above your own level."), __("One-click password reset and resend-welcome buttons, with sensible rate-limiting."), __("Click-to-copy email and a long-overdue search that matches name, username, AND email."), __("Per-user content stats: posts, pages, comments at a glance.") ]; const li = (arr) => arr.map( (s) => `
  • ${escapeHtml(s)}
  • ` ).join(""); return `

    ${escapeHtml(title)}

    ${escapeHtml(lede)}

    `; } function escapeHtml(s) { const t = document.createElement("div"); t.textContent = s; return t.innerHTML; } const _initial = { userId: null, requestedAt: 0, tabRequested: false }; let _store = null; function getStore() { if (_store) { return _store; } const w = window; const factory = w.wp?.desktop?.createSharedStore; if (typeof factory !== "function") { return null; } _store = factory( "desktop-mode/user-edit/target", () => ({ ..._initial }) ); return _store; } function setUserEditTarget(userId) { const store = getStore(); if (store) { store.state.userId = userId; store.state.requestedAt = Date.now(); store.state.tabRequested = true; store.notify(); return; } const w = window; w._wpdUserEditTarget = { userId, requestedAt: Date.now(), tabRequested: true }; } function readUserEditTarget() { const store = getStore(); if (store) { return { ...store.state }; } const w = window; return w._wpdUserEditTarget ?? { ..._initial }; } function clearUserEditTarget() { const store = getStore(); if (store) { store.state.userId = null; store.state.requestedAt = 0; store.state.tabRequested = false; store.notify(); } const w = window; if (w._wpdUserEditTarget) { w._wpdUserEditTarget = { userId: null, requestedAt: 0, tabRequested: false }; } } function setUserEditTabRequested(requested) { const store = getStore(); if (store) { store.state.tabRequested = requested; store.notify(); return; } const w = window; const prev = w._wpdUserEditTarget ?? { ..._initial }; w._wpdUserEditTarget = { ...prev, tabRequested: requested }; } function subscribeUserEditTarget(cb) { const store = getStore(); if (!store) { return () => { }; } return store.subscribe((state) => cb({ ...state })); } const userEditTarget = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, clearUserEditTarget, readUserEditTarget, setUserEditTabRequested, setUserEditTarget, subscribeUserEditTarget }, Symbol.toStringTag, { value: "Module" })); function wpdConfirmGlobal(options) { const w = window; const fn = w.wp?.desktop?.confirm; if (typeof fn !== "function") { return Promise.resolve(window.confirm(options.message)); } return fn(options); } function notifyToast(body, opts = {}) { const w = window; const api = w.wp?.desktop; if (api?.notify) { api.notify({ body, kind: opts.kind }); return; } console.info("[users-window]", body); } function openUserEditWindow(userId) { if (!Number.isFinite(userId) || userId <= 0) { return; } setUserEditTarget(userId); console.info( "[users-window] opening user-edit window for user", userId ); const w = window; const fn = w.wp?.desktop?.openWindow; if (typeof fn !== "function") { console.error( "[users-window] wp.desktop.openWindow is missing — desktop shell may not be ready." ); notifyToast( __("Could not open profile window — desktop shell unavailable."), { kind: "error" } ); return; } const opened = fn("desktop-mode-user-edit", { source: "users-window/row-click" }); if (!opened) { console.error( '[users-window] openWindow("desktop-mode-user-edit") returned false — window not registered server-side. Check includes/user-edit-window/window.php.' ); notifyToast( __("Profile window not registered — see console."), { kind: "error" } ); } } const ROOT = "[data-desktop-mode-posts-root]"; const STATUS = "[data-desktop-mode-posts-status]"; const SEARCH = "[data-desktop-mode-posts-search]"; const REFRESH = "[data-desktop-mode-posts-refresh]"; const NEW_BTN = "[data-desktop-mode-posts-new]"; const TABLE = "[data-desktop-mode-posts-table]"; const BULK = "[data-desktop-mode-posts-bulk]"; const COUNT = "[data-desktop-mode-posts-count]"; const PAGE_INDICATOR = "[data-desktop-mode-posts-page-indicator]"; const PREV = "[data-desktop-mode-posts-prev]"; const NEXT = "[data-desktop-mode-posts-next]"; const PER_PAGE = "[data-desktop-mode-posts-per-page]"; const BULK_ACTIONS_HOST = "[data-desktop-mode-posts-bulk-actions]"; const SEARCH_DEBOUNCE_MS = 250; function userCellKey(id, key) { return `${id}::${key}`; } function memoUserCell(cache, id, key, build) { const k = userCellKey(id, key); const cached = cache.get(k); if (cached) { return cached; } const node = build(); cache.set(k, node); return node; } const _usersIntroShown = { v: false }; function maybeShowUsersIntro(client) { if (_usersIntroShown.v) { return; } let cfg; try { cfg = client.getConfig(); } catch { return; } if (cfg.introSeen) { return; } _usersIntroShown.v = true; void showUsersIntroDialog().then((result) => { if (result === "cancel") { _usersIntroShown.v = false; return; } void markUsersIntroSeen(client, cfg); if (result === "settings") { const w = window; w.wp?.desktop?.openOsSettings?.(); } }).catch(() => { _usersIntroShown.v = false; }); } async function markUsersIntroSeen(client, cfg) { if (!cfg.introUrl) { return; } try { await trackedFetch( cfg.introUrl, { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json", "X-WP-Nonce": cfg.restNonce }, body: JSON.stringify({ slug: "users" }) }, { windowId: client.windowId, source: "users-window/intro" } ); cfg.introSeen = true; } catch { } } function buildIdentityCell(row, cfg) { const cell = document.createElement("span"); cell.style.cssText = "display:flex;align-items:center;gap:10px;min-width:0;"; const avatar = document.createElement("wpd-avatar"); avatar.setAttribute("size", "32"); if (row.name) { avatar.setAttribute("name", row.name); } const presence = row.desktop_mode_presence ?? "offline"; avatar.setAttribute("presence", presence); const avatars = row.avatar_urls ?? {}; const rawAvatar = avatars["48"] ?? avatars["96"] ?? avatars["24"] ?? ""; if (rawAvatar) { applyAvatarSrc(avatar, rawAvatar); } cell.appendChild(avatar); const text = document.createElement("span"); text.style.cssText = "display:flex;flex-direction:column;min-width:0;line-height:1.25;"; const nameRow = document.createElement("span"); const name = document.createElement("a"); name.href = `${cfg.editPostUrlBase}?user_id=${row.id}`; name.textContent = row.name || `#${row.id}`; name.title = name.textContent; name.setAttribute("data-noclick", ""); name.style.cssText = "font-weight:600;color:inherit;text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:240px;"; name.addEventListener("mouseenter", () => { name.style.textDecoration = "underline"; }); name.addEventListener("mouseleave", () => { name.style.textDecoration = "none"; }); name.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); void openUserEditWindow(row.id); }); nameRow.appendChild(name); text.appendChild(nameRow); if (row.slug) { const sub = document.createElement("span"); sub.textContent = `@${row.slug}`; sub.style.cssText = "font-size:11px;color:var(--wp-admin-theme-fg-muted, #8c8f94);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:240px;"; text.appendChild(sub); } cell.appendChild(text); return cell; } function buildEmailCell(row) { const cell = document.createElement("button"); cell.type = "button"; const email = typeof row.email === "string" ? row.email : ""; cell.textContent = email || "—"; cell.disabled = email === ""; cell.title = email ? __("Click to copy email") : ""; Object.assign(cell.style, { appearance: "none", background: "transparent", border: "none", padding: "2px 6px", font: "inherit", color: "inherit", cursor: email ? "copy" : "default", textAlign: "left", fontSize: "13px", borderRadius: "4px", maxWidth: "100%", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }); cell.addEventListener("click", (e) => { e.stopPropagation(); if (!email) { return; } void navigator.clipboard?.writeText(email).then(() => { const orig = cell.textContent; cell.textContent = __("Copied!"); cell.style.color = "var(--wp-admin-theme-color, #2271b1)"; setTimeout(() => { cell.textContent = orig; cell.style.color = ""; }, 1200); }).catch(() => { }); }); return cell; } function buildRoleCell(row, cfg) { const cell = document.createElement("span"); cell.style.cssText = "display:inline-flex;flex-wrap:wrap;gap:4px;min-width:0;"; const roles = Array.isArray(row.roles) ? row.roles : []; const labels = cfg.allRoles ?? {}; if (roles.length === 0) { const none = document.createElement("span"); none.textContent = __("No role"); none.style.cssText = "color:var(--wp-admin-theme-fg-muted, #8c8f94);font-style:italic;"; cell.appendChild(none); return cell; } for (const slug of roles) { const chip = document.createElement("span"); chip.textContent = labels[slug] ?? slug; chip.style.cssText = [ "display:inline-flex", "align-items:center", "padding:2px 8px", "border-radius:10px", "font-size:11px", "font-weight:600", "background:rgba(34,113,177,0.10)", "color:#0a4b78", "white-space:nowrap" ].join(";"); cell.appendChild(chip); } return cell; } function buildStatsCell(row) { const stats = row.desktop_mode_user_stats ?? { posts: 0, pages: 0, comments: 0 }; const cell = document.createElement("span"); cell.style.cssText = "display:inline-flex;align-items:center;gap:10px;font-size:12px;font-variant-numeric:tabular-nums;"; const mk = (dashicon, count, label) => { const span = document.createElement("span"); span.style.cssText = "display:inline-flex;align-items:center;gap:3px;"; span.title = label; const ic = document.createElement("wpd-icon"); ic.setAttribute("name", dashicon); ic.setAttribute("size", "14"); ic.style.color = "var(--wp-admin-theme-fg-muted, #8c8f94)"; span.appendChild(ic); const txt = document.createElement("span"); txt.textContent = String(count); if (count === 0) { txt.style.color = "var(--wp-admin-theme-fg-muted, #8c8f94)"; } span.appendChild(txt); return span; }; cell.appendChild(mk("admin-post", stats.posts, __("Posts"))); cell.appendChild(mk("admin-page", stats.pages, __("Pages"))); cell.appendChild( mk("admin-comments", stats.comments, __("Comments")) ); return cell; } function relativeTime(ts) { const now = Math.floor(Date.now() / 1e3); const delta = now - ts; if (delta < 60) { return __("just now"); } if (delta < 3600) { const m = Math.floor(delta / 60); return sprintf(__("%d min ago"), m); } if (delta < 86400) { const h = Math.floor(delta / 3600); return sprintf(__("%d h ago"), h); } if (delta < 86400 * 30) { const d = Math.floor(delta / 86400); return sprintf(__("%d d ago"), d); } if (delta < 86400 * 365) { const mo = Math.floor(delta / (86400 * 30)); return sprintf(__("%d mo ago"), mo); } const y = Math.floor(delta / (86400 * 365)); return sprintf(__("%d y ago"), y); } function buildLastLoginCell(row) { const cell = document.createElement("span"); cell.style.cssText = "font-size:13px;font-variant-numeric:tabular-nums;"; const ts = row.desktop_mode_last_login; if (!ts || typeof ts !== "number") { cell.textContent = __("Never"); cell.style.color = "var(--wp-admin-theme-fg-muted, #8c8f94)"; return cell; } cell.textContent = relativeTime(ts); const dt = new Date(ts * 1e3); cell.title = dt.toLocaleString(); return cell; } function buildRegisteredCell(row) { const cell = document.createElement("span"); cell.style.cssText = "font-size:13px;font-variant-numeric:tabular-nums;"; const raw = typeof row.registered_date === "string" ? row.registered_date : ""; if (!raw) { cell.textContent = "—"; cell.style.color = "var(--wp-admin-theme-fg-muted, #8c8f94)"; return cell; } const hasTz = /[Zz]|[+-]\d{2}:?\d{2}$/.test(raw); const ts = Math.floor(Date.parse(hasTz ? raw : raw + "Z") / 1e3); if (!Number.isFinite(ts)) { cell.textContent = raw; return cell; } cell.textContent = relativeTime(ts); cell.title = new Date(ts * 1e3).toLocaleString(); return cell; } function buildActionsCell(row, cfg, client) { const cell = document.createElement("span"); cell.style.cssText = "display:inline-flex;gap:4px;align-items:center;"; const canEditViewer = cfg.canEdit === true; const canEditRow = row.desktop_mode_can_edit === true; if (!canEditViewer || !canEditRow) { cell.textContent = "—"; cell.style.color = "var(--wp-admin-theme-fg-muted, #8c8f94)"; return cell; } const mk = (label, dashicon, fn) => { const btn = document.createElement("button"); btn.type = "button"; btn.title = label; btn.setAttribute("aria-label", label); Object.assign(btn.style, { appearance: "none", border: "1px solid var(--wp-admin-theme-border, #dcdcde)", background: "var(--wp-admin-theme-bg, #fff)", color: "inherit", padding: "4px 6px", borderRadius: "4px", cursor: "pointer", lineHeight: "1" }); const ic = document.createElement("wpd-icon"); ic.setAttribute("name", dashicon); ic.setAttribute("size", "14"); btn.appendChild(ic); btn.addEventListener("click", (e) => { e.stopPropagation(); fn(); }); return btn; }; cell.appendChild( mk( __("Send password reset"), "email-alt", async () => { const ok = await wpdConfirmGlobal({ title: __("Send password reset email?"), message: sprintf( // translators: %s is a user name. __("WordPress will email %s a password-reset link."), row.name ), confirmLabel: __("Send reset email") }); if (!ok) { return; } const result = await client.sendPasswordReset(row.id); if (result.ok) { notifyToast( sprintf( // translators: %s is the user's email address. __("Reset email sent to %s."), result.email ?? row.email ?? "" ), { kind: "success" } ); } else { notifyToast( sprintf( // translators: %s is an error code. __("Could not send reset email (%s)."), result.error ?? "unknown" ), { kind: "error" } ); } } ) ); cell.appendChild( mk( __("Resend welcome email"), "megaphone", async () => { const ok = await wpdConfirmGlobal({ title: __("Resend welcome email?"), message: sprintf( // translators: %s is a user name. __( "WordPress will resend the original welcome email to %s." ), row.name ), confirmLabel: __("Resend") }); if (!ok) { return; } const result = await client.resendWelcome(row.id); if (result.ok) { notifyToast( sprintf( // translators: %s is the user's email address. __("Welcome email resent to %s."), result.email ?? row.email ?? "" ), { kind: "success" } ); } else { notifyToast( sprintf( // translators: %s is an error code. __("Could not resend welcome (%s)."), result.error ?? "unknown" ), { kind: "error" } ); } } ) ); return cell; } function buildColumns(cache, cfg, client) { const cols = [ { key: "identity", label: __("Name"), sortable: false, sticky: true, minWidth: "260px", render: (_v, row) => memoUserCell( cache, row.id, "identity", () => buildIdentityCell(row, cfg) ) }, { key: "email", label: __("Email"), minWidth: "220px", render: (_v, row) => memoUserCell(cache, row.id, "email", () => buildEmailCell(row)) }, { key: "role", label: __("Role"), width: "180px", render: (_v, row) => memoUserCell( cache, row.id, "role", () => buildRoleCell(row, cfg) ) }, { key: "stats", label: __("Content"), width: "160px", sortValue: (row) => { const s = row.desktop_mode_user_stats; return s ? s.posts + s.pages + s.comments : 0; }, render: (_v, row) => memoUserCell(cache, row.id, "stats", () => buildStatsCell(row)) }, { key: "last_login", label: __("Last login"), width: "140px", sortable: false, sortValue: (row) => typeof row.desktop_mode_last_login === "number" ? row.desktop_mode_last_login : 0, render: (_v, row) => memoUserCell( cache, row.id, "last_login", () => buildLastLoginCell(row) ) }, { key: "registered", label: __("Registered"), width: "140px", sortable: true, render: (_v, row) => memoUserCell( cache, row.id, "registered", () => buildRegisteredCell(row) ) } ]; if (cfg.canEdit === true) { cols.push({ key: "actions", label: __("Actions"), width: "110px", sortable: false, render: (_v, row) => ( // Actions cell is intentionally NOT memoized — its closure // captures `row` and the row payload changes between // fetches. Cheap to rebuild, fewer surprises. buildActionsCell(row, cfg, client) ) }); } return cols; } function defaultStatusSegments() { return [ { value: "", label: __("All") }, { value: "online", label: __("Online") }, { value: "recent", label: __("Active 30d") }, { value: "never", label: __("Never logged in") } ]; } function applyClientStatusFilter(rows, status) { if (!status) { return rows; } if (status === "online") { return rows.filter((r) => r.desktop_mode_presence === "online"); } if (status === "recent") { const now = Math.floor(Date.now() / 1e3); return rows.filter((r) => { const ts = r.desktop_mode_last_login; return typeof ts === "number" && ts > 0 && now - ts < 86400 * 30; }); } if (status === "never") { return rows.filter( (r) => !r.desktop_mode_last_login || typeof r.desktop_mode_last_login !== "number" ); } return rows; } async function renderUsersWindow(body, client) { const root = body.querySelector(ROOT); const table = body.querySelector(TABLE); if (!root || !table) { return; } table.addEventListener("wpd-table-row-click", (e) => { const detail = e.detail; const id = detail?.row?.id; if (typeof id !== "number" || id <= 0) { return; } void openUserEditWindow(id); }); maybeShowUsersIntro(client); const cfg = client.getConfig(); const view = { page: 1, perPage: Math.max(1, cfg.defaultPerPage || 20), search: "", status: "", orderby: "name", order: "asc", roles: [], searchDebounce: null }; const cellCache = /* @__PURE__ */ new Map(); table.columns = buildColumns(cellCache, cfg, client); table.getRowId = (row) => row.id; table.sort = { key: "name", direction: "asc" }; if (!cfg.canEdit && !cfg.canPromote && !cfg.canDelete) { table.removeAttribute("selectable"); } let totalPages = 0; let totalRows = 0; let refreshSeq = 0; const perPageEl = root.querySelector(PER_PAGE); if (perPageEl) { perPageEl.value = String(view.perPage); } const indicator = root.querySelector(PAGE_INDICATOR); const prevBtn = root.querySelector(PREV); const nextBtn = root.querySelector(NEXT); const bulkBar = root.querySelector(BULK); const countEl = root.querySelector(COUNT); const bulkActionsHost = root.querySelector(BULK_ACTIONS_HOST); const statusHost = root.querySelector(STATUS); if (statusHost) { statusHost.replaceChildren(); for (const seg of defaultStatusSegments()) { const el = document.createElement("wpd-segment"); el.setAttribute("value", seg.value); el.textContent = seg.label; statusHost.appendChild(el); } statusHost.addEventListener("wpd-pick", (e) => { const detail = e.detail; view.status = detail?.value ?? ""; view.page = 1; void refresh(); }); } const searchEl = root.querySelector(SEARCH); if (searchEl) { searchEl.addEventListener("input", () => { if (view.searchDebounce !== null) { clearTimeout(view.searchDebounce); } view.searchDebounce = window.setTimeout(() => { view.search = searchEl.value.trim(); view.page = 1; void refresh(); }, SEARCH_DEBOUNCE_MS); }); } const refreshBtn = root.querySelector(REFRESH); refreshBtn?.addEventListener("click", () => { void refresh(); }); const newBtn = root.querySelector(NEW_BTN); if (newBtn) { if (!cfg.canCreate) { newBtn.style.display = "none"; } else { newBtn.addEventListener("click", (e) => { e.preventDefault(); const tabs = body.querySelector( "[data-desktop-mode-users-tabs]" ); if (!tabs) { return; } tabs.value = "add-new"; tabs.setAttribute("value", "add-new"); }); } } perPageEl?.addEventListener("change", () => { const n = parseInt(perPageEl.value, 10); if (Number.isFinite(n) && n > 0) { view.perPage = n; view.page = 1; void refresh(); } }); const renderBulkBar = () => { if (!bulkBar || !bulkActionsHost) { return; } const sel = table.selection; const ids = sel ? Array.from(sel) : []; if (ids.length === 0) { bulkBar.hidden = true; return; } bulkBar.hidden = false; if (countEl) { countEl.textContent = sprintf( // translators: %d is a count of selected users. __("%d selected"), ids.length ); } bulkActionsHost.replaceChildren(); const assignable = cfg.assignableRoles ?? {}; const assignableKeys = Object.keys(assignable); if (cfg.canPromote && assignableKeys.length > 0) { const wrap = document.createElement("span"); wrap.style.cssText = "display:inline-flex;align-items:center;gap:6px;"; const roleDropdown = document.createElement("select"); Object.assign(roleDropdown.style, { padding: "4px 8px", borderRadius: "4px", border: "1px solid var(--wp-admin-theme-border, #dcdcde)", background: "var(--wp-admin-theme-bg, #fff)", color: "inherit", font: "inherit", fontSize: "13px" }); const placeholder = document.createElement("option"); placeholder.value = ""; placeholder.textContent = __("Set role to…"); roleDropdown.appendChild(placeholder); for (const slug of assignableKeys) { const opt = document.createElement("option"); opt.value = slug; opt.textContent = assignable[slug]; roleDropdown.appendChild(opt); } const apply = document.createElement("wpd-button"); apply.setAttribute("variant", "primary"); apply.textContent = __("Apply"); apply.addEventListener("click", async (e) => { e.preventDefault(); const role = roleDropdown.value; if (!role) { return; } const ok = await wpdConfirmGlobal({ title: __("Change role for selected users?"), message: sprintf( // translators: %1$d is a user count, %2$s is a role label. __("Set %1$d user(s)' role to %2$s?"), ids.length, assignable[role] ), confirmLabel: __("Set role") }); if (!ok) { return; } const out = await client.bulkSetRole(ids, role).catch((err) => { notifyToast( String(err.message ?? err), { kind: "error" } ); return null; }); if (!out) { return; } const successes = Object.values(out.results).filter( (r) => r.ok ).length; const failures = ids.length - successes; if (successes > 0) { notifyToast( sprintf( // translators: %1$d users updated, %2$d failed. __("Role updated for %1$d user(s) (%2$d skipped)."), successes, failures ), { kind: failures > 0 ? "info" : "success" } ); } else { notifyToast(__("No users updated."), { kind: "error" }); } void refresh(); }); wrap.appendChild(roleDropdown); wrap.appendChild(apply); bulkActionsHost.appendChild(wrap); } }; table.addEventListener("wpd-table-selection-change", renderBulkBar); prevBtn?.addEventListener("click", () => { if (view.page > 1) { view.page -= 1; void refresh(); } }); nextBtn?.addEventListener("click", () => { if (view.page < totalPages) { view.page += 1; void refresh(); } }); const updatePager = () => { if (indicator) { indicator.textContent = sprintf( // translators: %1$d current page, %2$d total pages, %3$d total rows. __("Page %1$d of %2$d · %3$d users"), view.page, Math.max(1, totalPages), totalRows ); } if (prevBtn) { prevBtn.disabled = view.page <= 1; } if (nextBtn) { nextBtn.disabled = view.page >= totalPages; } }; const buildParams = () => { return { page: view.page, perPage: view.perPage, search: view.search || void 0, roles: view.roles.length > 0 ? view.roles : void 0, orderby: view.orderby, order: view.order }; }; const refresh = async () => { const mySeq = ++refreshSeq; table.toggleAttribute("loading", true); try { const result = await client.fetchUsers(buildParams()); if (mySeq !== refreshSeq) { return; } if (result.items.length === 0 && view.page > 1 && result.totalPages > 0 && view.page > result.totalPages) { view.page = 1; await refresh(); return; } cellCache.clear(); const filtered = applyClientStatusFilter(result.items, view.status); table.data = filtered; totalRows = result.total; totalPages = result.totalPages; updatePager(); renderBulkBar(); } catch (err) { console.error("[users-window] fetch failed:", err); notifyToast( __("Could not load users. Try Refresh."), { kind: "error" } ); } finally { table.toggleAttribute("loading", false); } }; mountAddUserForm(body, client, cfg, { afterCreate: () => { const tabs = body.querySelector( "[data-desktop-mode-users-tabs]" ); if (tabs) { tabs.value = "all"; tabs.setAttribute("value", "all"); } view.page = 1; void refresh(); } }); wireProfileSubTab(body, cfg); const patchUserRow = async (id) => { try { const updated = await client.fetchOneUser(id); const list = table.data; const idx = list.findIndex((r) => r.id === id); if (idx < 0) { return; } if (!updated) { const next2 = list.slice(); next2.splice(idx, 1); table.data = next2; return; } for (const k of Array.from(cellCache.keys())) { if (k.startsWith(`${id}::`)) { cellCache.delete(k); } } const next = list.slice(); next[idx] = updated; table.data = applyClientStatusFilter(next, view.status); } catch (err) { console.warn("[users-window] row patch failed, falling back to refresh", err); void refresh(); } }; const subscribeApi = window.wp?.desktop; const unsubscribe = subscribeApi?.subscribe?.( "desktop-mode.user.changed", (payload) => { const ids = payload?.ids; if (!Array.isArray(ids)) { return; } for (const raw of ids) { const id = typeof raw === "number" ? raw : Number(raw); if (Number.isFinite(id) && id > 0) { void patchUserRow(id); } } } ); if (unsubscribe) { document.addEventListener( "desktop-mode-window-closed", (e) => { const detail = e.detail; if (detail?.windowId === "desktop-mode-users") { unsubscribe(); } }, { once: false } ); } void refresh(); } function wireProfileSubTab(body, cfg) { const profile = body.querySelector( "wpd-user-profile[data-wpd-user-profile-self]" ); if (!profile) { return; } const viewerId = cfg.currentUserId; if (typeof viewerId === "number" && viewerId > 0) { profile.setAttribute("user-id", String(viewerId)); } } function mountAddUserForm(body, client, cfg, opts) { const formNullable = body.querySelector( "[data-desktop-mode-users-add-form]" ); if (!formNullable) { return; } const form = formNullable; const defaultRole = cfg.defaultRole ?? "subscriber"; const assignableRoles = cfg.assignableRoles && Object.keys(cfg.assignableRoles).length > 0 ? cfg.assignableRoles : { [defaultRole]: defaultRole }; mountSelect(form, "role", __("Role"), assignableRoles, defaultRole); mountSelect( form, "locale", __("Language"), cfg.locales ?? { "": __("Site default") }, "" ); const generateBtn = form.querySelector( '[data-action="generate-password"]' ); generateBtn?.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); const pwd = generateStrongPassword(18); const pwdField = form.querySelector( 'wpd-text-field[name="password"]' ); if (pwdField) { pwdField.value = pwd; pwdField.setAttribute("value", pwd); } void navigator.clipboard?.writeText(pwd).catch(() => { }); notifyToast(__("Generated password copied to clipboard."), { kind: "success" }); }); let pending = false; form.addEventListener("wpd-form-submit", (e) => { const detail = e.detail; void onSubmit(detail.values); }); async function onSubmit(values) { if (pending) { return; } pending = true; form.setBusy(true); form.clearErrors(); const payload = { username: String(values.username ?? "").trim(), email: String(values.email ?? "").trim(), first_name: optionalString(values.first_name), last_name: optionalString(values.last_name), url: optionalString(values.url), locale: String(values.locale ?? ""), password: optionalString(values.password), role: optionalString(values.role), send_notification: Boolean(values.send_notification) }; const result = await client.createUser(payload); pending = false; form.setBusy(false); if (!result.ok) { handleCreateError(form, result.error, result.message, payload); return; } notifyToast( sprintf( // translators: %s is the user's email address. __("User created — welcome email sent to %s."), result.email ?? payload.email ), { kind: "success" } ); opts.afterCreate(); } } function mountSelect(form, name, _label, optionsMap, initialValue) { const select = form.querySelector( `wpd-select[name="${name}"]` ); if (!select) { return; } const items = Object.entries(optionsMap).map(([value, label]) => ({ value, label })); select.items = items; if (initialValue && optionsMap[initialValue] !== void 0) { select.value = initialValue; select.setAttribute("value", initialValue); } } function handleCreateError(form, code, message, payload) { let summary = message; if (!summary) { switch (code) { case "desktop_mode_users_username_exists": case "existing_user_login": summary = __("That username is already in use."); break; case "desktop_mode_users_email_exists": case "existing_user_email": summary = __("That email is already in use."); break; case "desktop_mode_users_username_invalid": summary = __("Username is not valid."); break; case "desktop_mode_users_email_invalid": summary = __("A valid email address is required."); break; case "desktop_mode_users_role_forbidden": summary = __("You are not allowed to assign that role."); break; default: summary = __("Could not create the user."); } } form.setError(summary); if (code === "desktop_mode_users_username_exists" || code === "existing_user_login" || code === "desktop_mode_users_username_invalid") { form.setFieldInvalid("username"); } if (code === "desktop_mode_users_email_exists" || code === "existing_user_email" || code === "desktop_mode_users_email_invalid") { form.setFieldInvalid("email"); } if (code === "desktop_mode_users_role_forbidden") { form.setFieldInvalid("role"); } notifyToast(summary, { kind: "error" }); console.warn("[users-window] create failed", { code, payload }); } function optionalString(value) { if (typeof value !== "string") { return void 0; } const trimmed = value.trim(); return trimmed === "" ? void 0 : trimmed; } function generateStrongPassword(length) { const upper = "ABCDEFGHJKLMNPQRSTUVWXYZ"; const lower = "abcdefghjkmnpqrstuvwxyz"; const digits = "23456789"; const symbols = "!@#$%^&*-_=+"; const all = upper + lower + digits + symbols; const buf = new Uint32Array(length); crypto.getRandomValues(buf); let out = ""; for (let i = 0; i < length; i += 1) { out += all[buf[i] % all.length]; } return out; } const usersRender = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, renderUsersWindow }, Symbol.toStringTag, { value: "Module" })); exports.renderPostsWindow = renderPostsWindow; Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" }); return exports; }({});