diff --git a/README.md b/README.md index c2028c8a..23c76408 100644 --- a/README.md +++ b/README.md @@ -171,7 +171,7 @@ npm install The plugin uses **[Vite](https://vitejs.dev/)** in library mode. esbuild handles transpile and minify, so builds finish in ~70 ms per bundle. -**Full build** — produces both bundles: +**Full build** — produces every bundle (`npm run build:desktop`, `:iframe-bridge`, `:recycle-bin`, `:posts-window`): ```bash npm run build @@ -179,8 +179,10 @@ npm run build Writes: -- `assets/js/desktop.js` — unminified IIFE, loaded when `SCRIPT_DEBUG` is `true`. -- `assets/js/desktop.min.js` — esbuild-minified IIFE, loaded otherwise. +- `assets/js/desktop.js` / `.min.js` — main shell bundle (loaded based on `SCRIPT_DEBUG`). +- `assets/js/iframe-bridge.js` / `.min.js` — opt-in bridge that gives any same-origin iframe access to `wp.desktop.iframe.*`. +- `assets/js/recycle-bin.js` / `.min.js` — Recycle Bin native window. +- `assets/js/posts-window.js` / `.min.js` — Native Posts window (the `` replacement for the `edit.php` iframe; opt-in per user via OS Settings → Features). **Development watch** — auto-recompiles the unminified bundle on save: diff --git a/assets/css/os-settings.css b/assets/css/os-settings.css index f70627bf..510ae22d 100644 --- a/assets/css/os-settings.css +++ b/assets/css/os-settings.css @@ -854,3 +854,100 @@ max-height: 100%; overflow-y: auto; } + +/* + * Features section — per-user opt-in toggles. + * + * Save status pill renders the live OS-settings save lifecycle + * (`pending` → `saving` → `saved` / `failed`) so toggling a + * per-user feature flag gets immediate, honest feedback instead + * of an optimistic flicker. + */ +.desktop-mode-features__status-row { + min-height: 22px; +} + +.desktop-mode-features__hint { + margin: 0; + font-size: 12px; + color: var( --wpd-text-muted, #50575e ); + line-height: 1.5; +} + +.desktop-mode-features__status { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 500; + padding: 2px 10px; + border-radius: 999px; + white-space: nowrap; + transition: opacity 200ms ease; +} + +.desktop-mode-features__status--saving { + background: var( --wpd-surface-elevated, #f0f0f1 ); + color: var( --wpd-text-muted, #50575e ); +} + +.desktop-mode-features__status--saved { + background: rgba( 30, 132, 73, 0.12 ); + color: #1d6f42; +} + +.desktop-mode-features__status--saved .dashicons { + font-size: 14px; + width: 14px; + height: 14px; +} + +.desktop-mode-features__status--failed { + background: rgba( 214, 54, 56, 0.12 ); + color: #a02622; +} + +.desktop-mode-features__status--failed .dashicons { + font-size: 14px; + width: 14px; + height: 14px; +} + +.desktop-mode-features__status-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: currentColor; + animation: desktop-mode-features-pulse 1.2s ease-in-out infinite; +} + +@keyframes desktop-mode-features-pulse { + 0%, 100% { opacity: 0.35; transform: scale( 0.85 ); } + 50% { opacity: 1; transform: scale( 1 ); } +} + +/* + * OS Settings panel header — hosts the global tabs strip and a single + * `` indicator that auto-listens to the save + * lifecycle. The save status sits on the trailing edge of the header + * so it's visible across every tab without competing with section + * content. + */ +.desktop-mode-os-settings__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 12px; +} + +.desktop-mode-os-settings__header wpd-tabs { + flex: 1 1 auto; + min-width: 0; +} + +.desktop-mode-os-settings__header wpd-save-status { + flex: 0 0 auto; + margin-inline-start: auto; +} diff --git a/assets/css/posts-window.css b/assets/css/posts-window.css new file mode 100644 index 00000000..e885c46f --- /dev/null +++ b/assets/css/posts-window.css @@ -0,0 +1,1058 @@ +/** + * Desktop Mode — Native Posts window styles. + * + * Scoped to `.desktop-mode-posts` so the rules can't leak into other + * windows. Layout: a toolbar across the top, a flex-1 body that hosts + * a ``, and a footer pager at the bottom. The table is + * told to size to the body via `--wpd-table-max-height: 100%` so + * sticky-header engages without an outer scrollbar. + * + * Cell-content styles are NOT here — they're inlined in + * `src/posts-window/index.ts` because `` renders into its + * own shadow DOM and document stylesheets do not cross that boundary. + * + * @since 0.8.0 + */ + +.desktop-mode-posts { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + background: var( --wpd-surface, #fff ); + color: var( --wpd-text, #1d2327 ); +} + +/* --- Toolbar --------------------------------------------------------- */ + +.desktop-mode-posts__toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + padding: 10px 14px; + border-bottom: 1px solid var( --wpd-border, rgba( 0, 0, 0, 0.08 ) ); + background: var( --wpd-surface-elevated, #f6f7f7 ); +} + +.desktop-mode-posts__toolbar-left, +.desktop-mode-posts__toolbar-right, +.desktop-mode-posts__toolbar-trailing { + display: flex; + align-items: center; + gap: 8px; +} + +/* + * Honour the `hidden` attribute. UA stylesheet sets + * `[hidden] { display: none }` but our explicit `display: flex` + * above wins on specificity, so we restore the hide behaviour + * for `bulkBar.hidden = true` from JS. + */ +.desktop-mode-posts__toolbar-right[hidden] { + display: none; +} + +.desktop-mode-posts__toolbar-trailing { + margin-inline-start: auto; +} + +.desktop-mode-posts__count { + font-size: 12px; + font-weight: 600; + color: var( --wpd-text-muted, #50575e ); + font-variant-numeric: tabular-nums; + padding-inline-end: 4px; +} + +/* --- Body ------------------------------------------------------------ */ + +.desktop-mode-posts__body { + flex: 1 1 auto; + /* + * `min-width: 0` is the load-bearing piece — without it, the + * flex item's intrinsic width (the table's column-sum) becomes + * its hard minimum, which pushes the table past the window's + * right edge and turns the WHOLE WINDOW into a horizontal + * scroller. With `min-width: 0`, the flex item shrinks to the + * available space and the table's own `.scroll` container + * picks up the horizontal overflow internally. + */ + min-width: 0; + min-height: 0; + display: flex; + padding: 8px 12px; +} + +.desktop-mode-posts__body wpd-table { + flex: 1 1 auto; + min-width: 0; + --wpd-table-max-height: 100%; +} + +/* --- Empty state ----------------------------------------------------- */ + +.desktop-mode-posts__empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 48px 24px; + color: var( --wpd-text-muted, #50575e ); + text-align: center; +} + +.desktop-mode-posts__empty .dashicons { + font-size: 40px; + width: 40px; + height: 40px; + color: var( --wpd-text-muted, #a7aaad ); +} + +.desktop-mode-posts__empty p { + margin: 0; +} + +.desktop-mode-posts__empty-hint { + font-size: 13px; + color: var( --wpd-text-muted, #646970 ); +} + +/* --- Footer / pager -------------------------------------------------- */ + +.desktop-mode-posts__pager { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 14px; + border-top: 1px solid var( --wpd-border, rgba( 0, 0, 0, 0.08 ) ); + background: var( --wpd-surface-elevated, #f6f7f7 ); + font-size: 13px; +} + +.desktop-mode-posts__pager-meta { + color: var( --wpd-text-muted, #50575e ); + font-variant-numeric: tabular-nums; +} + +.desktop-mode-posts__pager-nav { + display: flex; + align-items: center; + gap: 8px; +} + +.desktop-mode-posts__pager-perpage { + display: inline-flex; + align-items: center; + gap: 6px; + color: var( --wpd-text-muted, #50575e ); + font-size: 12px; + margin-inline-start: 8px; +} + +.desktop-mode-posts__pager-perpage select { + padding: 4px 6px; + border: 1px solid var( --wpd-border, #c3c4c7 ); + border-radius: 4px; + background: var( --wpd-surface, #fff ); + color: var( --wpd-text, #1d2327 ); + font: inherit; +} + +/* Title-bar ⋯ menu — "Show columns" sub-section. + * Section label is a small caps separator above the column toggles. + * The toggles are real nodes, + * so they pick up the menu's existing styling automatically. */ +.desktop-mode-posts-window__menu-columns { + margin-top: 4px; + padding: 6px 12px 2px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var( --wpd-text-muted, #50575e ); + border-top: 1px solid var( --wpd-border, #dcdcde ); + pointer-events: none; +} + +/* Filter cell uses the framework's `` — no + * post-specific styles needed here. The component lives inside the + * and renders its popover into so the dropdown escapes + * the table's overflow chain. */ + +/* --------------------------------------------------------------- + * Categories + Tags tabs. + * + * Reimagined replacement for legacy `edit-tags.php`. Layout (top to + * bottom): horizontal stats strip → search + add-row toolbar → + * `` → pager. The stats strip is the value-add over the + * legacy view — at-a-glance counts the user previously had to hunt + * for. */ +/* Tabs strip — auto-height; only the active panel below grows. */ +.desktop-mode-posts__tabs { + flex: 0 0 auto; +} + +/* Sibling panels under the same flex column. The hidden ones are + * UA-display:none via the `[hidden]` attribute; the visible one + * stretches to fill the remaining height. */ +.desktop-mode-posts__panel { + flex: 1 1 auto; + min-height: 0; + min-width: 0; + display: flex; + flex-direction: column; +} + +.desktop-mode-posts__panel[ hidden ] { + display: none; +} + +.desktop-mode-posts__terms-host, +.desktop-mode-posts__terms { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + min-width: 0; +} + +.desktop-mode-posts__stats { + display: grid; + grid-template-columns: repeat( auto-fit, minmax( 140px, 1fr ) ); + gap: 8px; + padding: 12px 14px 0; +} + +.desktop-mode-posts__stat { + display: flex; + flex-direction: column; + gap: 2px; + padding: 10px 14px; + background: var( --wpd-surface-elevated, #f6f7f7 ); + border: 1px solid var( --wpd-border, rgba( 0, 0, 0, 0.08 ) ); + border-radius: 8px; +} + +.desktop-mode-posts__stat[ data-tone='accent' ] { + border-color: color-mix( + in srgb, + var( --wp-admin-theme-color, #2271b1 ) 45%, + transparent + ); +} + +.desktop-mode-posts__stat[ data-tone='warning' ] { + border-color: color-mix( in srgb, #d63638 45%, transparent ); +} + +.desktop-mode-posts__stat-value { + font-size: 18px; + font-weight: 700; + color: var( --wpd-text, #1d2327 ); + line-height: 1.2; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.desktop-mode-posts__stat[ data-tone='accent' ] .desktop-mode-posts__stat-value { + color: var( --wp-admin-theme-color, #2271b1 ); +} + +.desktop-mode-posts__stat[ data-tone='warning' ] .desktop-mode-posts__stat-value { + color: #d63638; +} + +.desktop-mode-posts__stat-label { + font-size: 11px; + font-weight: 500; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var( --wpd-text-muted, #50575e ); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.desktop-mode-posts__terms-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + padding: 12px 14px; + border-bottom: 1px solid var( --wpd-border, rgba( 0, 0, 0, 0.08 ) ); +} + +.desktop-mode-posts__terms-search { + flex: 1 1 220px; + min-width: 0; +} + +.desktop-mode-posts__terms-search wpd-text-field { + width: 100%; +} + +.desktop-mode-posts__terms-add { + display: flex; + align-items: center; + gap: 8px; + flex: 1 1 auto; + min-width: 0; + justify-content: flex-end; +} + +.desktop-mode-posts__terms-add wpd-text-field { + flex: 1 1 200px; + min-width: 0; +} + +.desktop-mode-posts__terms-parent { + padding: 6px 8px; + border: 1px solid var( --wpd-border, #c3c4c7 ); + border-radius: 4px; + background: var( --wpd-surface, #fff ); + color: var( --wpd-text, #1d2327 ); + font: inherit; + font-size: 13px; + max-width: 180px; +} + +.desktop-mode-posts__terms wpd-table { + flex: 1 1 auto; + min-width: 0; + min-height: 0; + margin: 0 14px; + --wpd-table-max-height: 100%; +} + +.desktop-mode-posts__term-name { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.desktop-mode-posts__term-indent { + display: inline-block; + flex-shrink: 0; +} + +.desktop-mode-posts__term-tee { + color: var( --wpd-text-muted, #8c8f94 ); + font-size: 13px; + flex-shrink: 0; +} + +.desktop-mode-posts__term-label { + font-weight: 500; + color: var( --wpd-text, #1d2327 ); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.desktop-mode-posts__term-slug { + display: inline-block; + padding: 2px 6px; + border-radius: 4px; + background: rgba( 0, 0, 0, 0.05 ); + color: var( --wpd-text-muted, #50575e ); + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 11px; +} + +.desktop-mode-posts__term-count { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.desktop-mode-posts__term-bar { + flex: 1 1 auto; + height: 4px; + min-width: 24px; + background: rgba( 0, 0, 0, 0.06 ); + border-radius: 999px; + position: relative; + overflow: hidden; +} + +.desktop-mode-posts__term-bar::before { + content: ''; + position: absolute; + inset-inline-start: 0; + top: 0; + bottom: 0; + width: var( --wpd-term-bar, 0% ); + background: var( --wp-admin-theme-color, #2271b1 ); + border-radius: 999px; + transition: width 0.2s ease; +} + +.desktop-mode-posts__term-count-num { + font-variant-numeric: tabular-nums; + font-weight: 600; + color: var( --wpd-text, #1d2327 ); + min-width: 2ch; + text-align: end; +} + +.desktop-mode-posts__term-desc { + color: var( --wpd-text-muted, #50575e ); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: inline-block; + max-width: 100%; +} + +.desktop-mode-posts__term-desc.is-empty { + color: var( --wpd-text-disabled, #c3c4c7 ); + font-style: italic; +} + +.desktop-mode-posts__term-actions { + display: inline-flex; + gap: 4px; +} + +.desktop-mode-posts__term-action { + appearance: none; + padding: 3px 8px; + font: inherit; + font-size: 11px; + font-weight: 500; + border: 1px solid transparent; + border-radius: 4px; + background: transparent; + color: var( --wp-admin-theme-color, #2271b1 ); + cursor: pointer; + transition: background-color 0.12s ease, border-color 0.12s ease; +} + +.desktop-mode-posts__term-action:hover { + background: color-mix( + in srgb, + var( --wp-admin-theme-color, #2271b1 ) 10%, + transparent + ); + border-color: color-mix( + in srgb, + var( --wp-admin-theme-color, #2271b1 ) 35%, + transparent + ); +} + +.desktop-mode-posts__term-action--danger { + color: #d63638; +} + +.desktop-mode-posts__term-action--danger:hover { + background: color-mix( in srgb, #d63638 10%, transparent ); + border-color: color-mix( in srgb, #d63638 35%, transparent ); +} + +/* --------------------------------------------------------------- + * Categories tab — Pixi mindmap (the only Categories view). + * + * Hybrid render: Pixi canvas paints circles + edges, an HTML overlay + * paints labels + count badges + the inline editor + post mini-cards. + * The overlay keeps text crisp at any zoom; the canvas keeps the + * graph hardware-accelerated. */ +.wpd-mindmap { + flex: 1 1 auto; + min-height: 0; + min-width: 0; + display: flex; + flex-direction: column; + /* Mindmap canvas — dot grid + theme-tinted vignettes. The dot + * grid is the canonical "infinite thinking surface" texture + * (Miro / Obsidian Canvas / Excalidraw all use it); pairs nicely + * with radial trees because the dots give the user a sense of + * pan/zoom motion without dominating the node colors. The two + * offset vignettes add light, atmospheric depth — a touch of + * ambient lighting in the admin theme hue. */ + background: + radial-gradient( + circle, + rgba( 0, 0, 0, 0.06 ) 1px, + transparent 1.5px + ) 0 0 / 24px 24px, + radial-gradient( + ellipse at 28% 22%, + color-mix( + in srgb, + var( --wp-admin-theme-color, #2271b1 ) 8%, + transparent + ), + transparent 60% + ), + radial-gradient( + ellipse at 78% 80%, + color-mix( + in srgb, + var( --wp-admin-theme-color, #2271b1 ) 5%, + transparent + ), + transparent 58% + ), + var( --wpd-surface, #fff ); +} + +.wpd-mindmap__toolbar { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + border-bottom: 1px solid var( --wpd-border, rgba( 0, 0, 0, 0.08 ) ); + background: rgba( 255, 255, 255, 0.6 ); + backdrop-filter: blur( 8px ); +} + +.wpd-mindmap__btn { + appearance: none; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 6px 10px; + font: inherit; + font-size: 12px; + font-weight: 500; + border: 1px solid var( --wpd-border, #c3c4c7 ); + border-radius: 6px; + background: transparent; + color: var( --wpd-text, #1d2327 ); + cursor: pointer; + transition: background-color 0.12s ease, border-color 0.12s ease, + color 0.12s ease; +} + +.wpd-mindmap__btn:hover { + background: rgba( 0, 0, 0, 0.04 ); + border-color: var( --wp-admin-theme-color, #2271b1 ); +} + +.wpd-mindmap__btn--primary { + background: var( --wp-admin-theme-color, #2271b1 ); + color: #fff; + border-color: transparent; +} + +.wpd-mindmap__btn--primary:hover { + filter: brightness( 1.05 ); + background: var( --wp-admin-theme-color, #2271b1 ); + color: #fff; +} + +.wpd-mindmap__btn--secondary { + background: rgba( 0, 0, 0, 0.04 ); +} + +.wpd-mindmap__btn--danger { + color: #d63638; + border-color: transparent; +} + +.wpd-mindmap__btn--danger:hover { + background: color-mix( in srgb, #d63638 10%, transparent ); + border-color: color-mix( in srgb, #d63638 35%, transparent ); +} + +.wpd-mindmap__btn--danger.is-armed { + background: #d63638; + color: #fff; + border-color: transparent; +} + +.wpd-mindmap__hint { + flex: 1 1 auto; + font-size: 12px; + font-style: italic; + color: var( --wpd-text-muted, #50575e ); + text-align: end; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Two-column layout: Pixi stage on the left, fixed sidebar on the + * right. The sidebar always renders — empty-state copy when no node + * is focused, full editor form otherwise — so the editing surface + * never covers the post nodes. */ +.wpd-mindmap__layout { + display: flex; + flex: 1 1 auto; + min-height: 0; + min-width: 0; +} + +.wpd-mindmap__stage { + position: relative; + flex: 1 1 auto; + min-height: 0; + min-width: 0; + overflow: hidden; + /* Fade in once the very first `fitToView()` has run — without + * this the canvas briefly paints its tree at the unfitted + * default transform on the frame between mount and the first + * rAF, which the user saw as a flash. */ + transition: opacity 200ms ease; +} + +.wpd-mindmap__stage.is-loading { + opacity: 0; +} + +.wpd-mindmap__sidebar { + flex: 0 0 320px; + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px; + border-inline-start: 1px solid var( --wpd-border, rgba( 0, 0, 0, 0.08 ) ); + background: var( --wpd-surface, #fff ); + color: var( --wpd-text, #1d2327 ); + overflow-y: auto; + min-height: 0; +} + +.wpd-mindmap__sidebar-empty { + flex: 1 1 auto; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + color: var( --wpd-text-muted, #50575e ); + text-align: center; + padding: 24px 8px; +} + +.wpd-mindmap__sidebar-empty .dashicons { + font-size: 36px; + width: 36px; + height: 36px; + opacity: 0.5; +} + +.wpd-mindmap__sidebar-empty-title { + font-size: 13px; + font-weight: 600; + color: var( --wpd-text, #1d2327 ); +} + +.wpd-mindmap__sidebar-empty-hint { + font-size: 12px; + font-style: italic; + max-width: 220px; +} + +.wpd-mindmap__sidebar-header { + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var( --wpd-text-muted, #50575e ); +} + +.wpd-mindmap__sidebar-dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex: 0 0 10px; +} + +.wpd-mindmap__sidebar-slug { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.wpd-mindmap__sidebar-label { + font-size: 11px; + font-weight: 500; + color: var( --wpd-text-muted, #50575e ); + margin-block-start: 4px; +} + +.wpd-mindmap__sidebar-meta { + font-size: 12px; + color: var( --wpd-text-muted, #50575e ); + padding-block: 4px; +} + +.wpd-mindmap__canvas { + display: block; + position: absolute; + inset: 0; + width: 100%; + height: 100%; + touch-action: none; +} + +/* Spotlight: dim the world container while a node is deployed so + * the focused branch reads as the foreground. Implemented as a + * filter on the canvas itself because category and post chips are + * Pixi nodes inside the canvas — there is no longer an HTML overlay + * we can selectively dim. The canvas keeps full alpha so the + * focused-node + post fan stay vivid; only the discs/chips outside + * the focus area look muted via the slight desaturation in JS + * (drawEdges + chip-color logic dim non-focused branches). */ + +/* Editor lives in the sidebar — no longer a floating popover, so no + * positioning, shadow or backdrop. Just a vertical stack of inputs + + * actions. (Kept the class so the form's children still hit the + * existing input/button styles below.) */ +.wpd-mindmap__editor { + display: contents; +} + +.wpd-mindmap__editor-name { + appearance: none; + font: inherit; + font-size: 14px; + font-weight: 600; + padding: 6px 8px; + border: 1px solid var( --wpd-border, #c3c4c7 ); + border-radius: 6px; + background: var( --wpd-surface, #fff ); + color: inherit; +} + +.wpd-mindmap__editor-name:focus, +.wpd-mindmap__editor-desc:focus { + outline: none; + border-color: var( --wp-admin-theme-color, #2271b1 ); + box-shadow: 0 0 0 2px + color-mix( + in srgb, + var( --wp-admin-theme-color, #2271b1 ) 25%, + transparent + ); +} + +.wpd-mindmap__editor-desc { + appearance: none; + font: inherit; + font-size: 12px; + padding: 6px 8px; + border: 1px solid var( --wpd-border, #c3c4c7 ); + border-radius: 6px; + background: var( --wpd-surface, #fff ); + color: inherit; + resize: vertical; + min-height: 46px; +} + +.wpd-mindmap__editor-actions { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.wpd-mindmap__editor-actions .wpd-mindmap__btn { + flex: 1 1 auto; + justify-content: center; +} + +.wpd-mindmap__empty { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + color: var( --wpd-text-muted, #50575e ); + font-size: 13px; + font-style: italic; + text-align: center; + padding: 0 24px; +} + +/* --------------------------------------------------------------------- + * Tag cloud (Tags tab) + * + * Same surface shape as the Categories mindmap — toolbar across the + * top, Pixi stage on the left, fixed-width sidebar editor on the right + * — but with a hashtag-pill metaphor instead of circles. The Pixi-side + * code (`tags-cloud.ts`) paints chips, post satellites, and the pager + * directly into the canvas; this stylesheet only owns the chrome + * around the canvas (toolbar, sidebar form, empty states). + * ------------------------------------------------------------------ */ +.wpd-tagcloud { + flex: 1 1 auto; + min-height: 0; + min-width: 0; + display: flex; + flex-direction: column; + background: + repeating-linear-gradient( + 135deg, + transparent 0, + transparent 22px, + color-mix( in srgb, var( --wp-admin-theme-color, #2271b1 ) 4%, transparent ) 22px, + color-mix( in srgb, var( --wp-admin-theme-color, #2271b1 ) 4%, transparent ) 23px + ), + radial-gradient( + circle at 30% 20%, + color-mix( + in srgb, + var( --wp-admin-theme-color, #2271b1 ) 6%, + transparent + ), + transparent 65% + ), + var( --wpd-surface, #fff ); +} + +.wpd-tagcloud__toolbar { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + border-bottom: 1px solid var( --wpd-border, rgba( 0, 0, 0, 0.08 ) ); + background: rgba( 255, 255, 255, 0.6 ); + backdrop-filter: blur( 8px ); +} + +.wpd-tagcloud__btn { + appearance: none; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 6px 10px; + font: inherit; + font-size: 12px; + font-weight: 500; + border: 1px solid var( --wpd-border, #c3c4c7 ); + border-radius: 6px; + background: transparent; + color: var( --wpd-text, #1d2327 ); + cursor: pointer; + transition: background-color 0.12s ease, border-color 0.12s ease, + color 0.12s ease; +} + +.wpd-tagcloud__btn:hover { + background: rgba( 0, 0, 0, 0.04 ); + border-color: var( --wp-admin-theme-color, #2271b1 ); +} + +.wpd-tagcloud__btn--primary { + background: var( --wp-admin-theme-color, #2271b1 ); + color: #fff; + border-color: transparent; +} + +.wpd-tagcloud__btn--primary:hover { + filter: brightness( 1.05 ); + background: var( --wp-admin-theme-color, #2271b1 ); + color: #fff; +} + +.wpd-tagcloud__btn--danger { + color: #d63638; + border-color: transparent; +} + +.wpd-tagcloud__btn--danger:hover { + background: color-mix( in srgb, #d63638 10%, transparent ); + border-color: color-mix( in srgb, #d63638 35%, transparent ); +} + +.wpd-tagcloud__btn--danger.is-armed { + background: #d63638; + color: #fff; + border-color: transparent; +} + +.wpd-tagcloud__hint { + flex: 1 1 auto; + font-size: 12px; + font-style: italic; + color: var( --wpd-text-muted, #50575e ); + text-align: end; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.wpd-tagcloud__layout { + display: flex; + flex: 1 1 auto; + min-height: 0; + min-width: 0; +} + +.wpd-tagcloud__stage { + position: relative; + flex: 1 1 auto; + min-height: 0; + min-width: 0; + overflow: hidden; + transition: opacity 200ms ease; +} + +.wpd-tagcloud__stage.is-loading { + opacity: 0; +} + +.wpd-tagcloud__sidebar { + flex: 0 0 320px; + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px; + border-inline-start: 1px solid var( --wpd-border, rgba( 0, 0, 0, 0.08 ) ); + background: var( --wpd-surface, #fff ); + color: var( --wpd-text, #1d2327 ); + overflow-y: auto; + min-height: 0; +} + +.wpd-tagcloud__sidebar-empty { + flex: 1 1 auto; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + color: var( --wpd-text-muted, #50575e ); + text-align: center; + padding: 24px 8px; +} + +.wpd-tagcloud__sidebar-empty .dashicons { + font-size: 36px; + width: 36px; + height: 36px; + opacity: 0.5; +} + +.wpd-tagcloud__sidebar-empty-title { + font-size: 13px; + font-weight: 600; + color: var( --wpd-text, #1d2327 ); + margin: 0; +} + +.wpd-tagcloud__sidebar-empty-hint { + font-size: 12px; + font-style: italic; + max-width: 240px; + margin: 0; +} + +.wpd-tagcloud__sidebar-header { + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var( --wpd-text-muted, #50575e ); +} + +.wpd-tagcloud__sidebar-dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex: 0 0 10px; +} + +.wpd-tagcloud__sidebar-slug { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.wpd-tagcloud__sidebar-label { + font-size: 11px; + font-weight: 500; + color: var( --wpd-text-muted, #50575e ); + margin-block-start: 4px; +} + +.wpd-tagcloud__sidebar-meta { + font-size: 12px; + color: var( --wpd-text-muted, #50575e ); + padding-block: 4px; +} + +.wpd-tagcloud__canvas { + display: block; + position: absolute; + inset: 0; + width: 100%; + height: 100%; + touch-action: none; +} + +.wpd-tagcloud__editor-name { + appearance: none; + font: inherit; + font-size: 14px; + font-weight: 600; + padding: 6px 8px; + border: 1px solid var( --wpd-border, #c3c4c7 ); + border-radius: 6px; + background: var( --wpd-surface, #fff ); + color: inherit; +} + +.wpd-tagcloud__editor-name:focus, +.wpd-tagcloud__editor-desc:focus { + outline: none; + border-color: var( --wp-admin-theme-color, #2271b1 ); + box-shadow: 0 0 0 2px + color-mix( + in srgb, + var( --wp-admin-theme-color, #2271b1 ) 25%, + transparent + ); +} + +.wpd-tagcloud__editor-desc { + appearance: none; + font: inherit; + font-size: 12px; + padding: 6px 8px; + border: 1px solid var( --wpd-border, #c3c4c7 ); + border-radius: 6px; + background: var( --wpd-surface, #fff ); + color: inherit; + resize: vertical; + min-height: 46px; +} + +.wpd-tagcloud__editor-actions { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.wpd-tagcloud__editor-actions .wpd-tagcloud__btn { + flex: 1 1 auto; + justify-content: center; +} + +.wpd-tagcloud__empty { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + color: var( --wpd-text-muted, #50575e ); + font-size: 13px; + font-style: italic; + text-align: center; + padding: 0 24px; +} diff --git a/assets/css/window-chrome.css b/assets/css/window-chrome.css index 13332be8..36658f68 100644 --- a/assets/css/window-chrome.css +++ b/assets/css/window-chrome.css @@ -95,6 +95,13 @@ /* Title bar. */ .desktop-mode-window__titlebar { position: relative; + /* Promote the titlebar into its own stacking context above the + * body. Without this, the body (which comes later in source order + * and inherits z-auto) wins document-order overlap battles — + * notably any sticky-positioned content inside the body (e.g. the + * native Posts table's sticky header at z-index 20–40) would + * paint over the titlebar's ⋯ menu popover. */ + z-index: 21; display: flex; align-items: center; height: var(--desktop-mode-titlebar-height); @@ -120,6 +127,26 @@ flex-shrink: 0; } +/* Activity indicator slot — sits between the icon and the title. + * Reserves a fixed width so the indicator's blink animation can't + * shift the title text horizontally. The inner `` + * paints a 12px modem-style dot (always visible, accent-colored). + * + * `--wp-admin-theme-color` is forwarded as `color` on the host so + * the inner shadow-DOM stylesheet's `currentColor` references + * (used in the box-shadow glow) resolve to the live accent. */ +.desktop-mode-window__activity { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + flex-shrink: 0; + margin-inline-start: 6px; + margin-inline-end: 4px; + color: var(--wp-admin-theme-color, #2271b1); +} + /* Window title text. */ .desktop-mode-window__title { flex: 1; diff --git a/assets/js/posts-window.js b/assets/js/posts-window.js new file mode 100644 index 00000000..a1d3eb79 --- /dev/null +++ b/assets/js/posts-window.js @@ -0,0 +1,5629 @@ +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 WINDOW_ID = "desktop-mode-posts"; + function getConfig() { + const store = window.desktopModeWindowConfig; + const cfg = store ? store[WINDOW_ID] : void 0; + if (!cfg) { + throw new Error( + "[desktop-mode-posts] config blob is missing — was the window opened without registration? See `desktop_mode_register_window()` in `includes/posts-window/window.php`." + ); + } + return cfg; + } + function shellFetch(input, init) { + const api = window.wp?.desktop; + if (api && typeof api.fetch === "function") { + return api.fetch(input, init, { windowId: "desktop-mode-posts" }); + } + return fetch(input, init); + } + async function request(url, init = {}) { + const cfg = getConfig(); + const response = await shellFetch(url, { + ...init, + credentials: "same-origin", + headers: { + "X-WP-Nonce": cfg.restNonce, + Accept: "application/json", + ...init.body ? { "Content-Type": "application/json" } : {}, + ...init.headers ?? {} + } + }); + if (!response.ok) { + let message = `${response.status} ${response.statusText}`; + try { + const json = await response.json(); + if (json && typeof json.message === "string") { + message = json.message; + } + } catch { + } + throw new Error(message); + } + const data = init.expectJson === false ? null : await response.json(); + return { data, headers: response.headers }; + } + async function fetchPosts(params = {}) { + const cfg = getConfig(); + const url = new URL(cfg.postsUrl); + for (const [key, value] of Object.entries(cfg.queryArgs ?? {})) { + if (typeof value === "string" && value !== "") { + url.searchParams.set(key, value); + } + } + if (params.page) { + url.searchParams.set("page", String(params.page)); + } + if (params.perPage) { + url.searchParams.set("per_page", String(params.perPage)); + } + if (params.search) { + url.searchParams.set("search", params.search); + } + if (params.status) { + url.searchParams.set("status", params.status); + } else { + url.searchParams.set("status", "any"); + } + if (params.orderby) { + url.searchParams.set("orderby", params.orderby); + } + if (params.order) { + url.searchParams.set("order", params.order); + } + const appendIds = (key, v) => { + const list = Array.isArray(v) ? v : [v]; + for (const id of list) { + if (Number.isFinite(id) && id > 0) { + url.searchParams.append(`${key}[]`, String(id)); + } + } + }; + if (params.author) { + appendIds("author", params.author); + } + if (params.tag) { + appendIds("tags", params.tag); + } + const { data, headers } = await request(url.toString(), { + method: "GET" + }); + return { + items: Array.isArray(data) ? data : [], + total: parseInt(headers.get("X-WP-Total") ?? "0", 10) || 0, + totalPages: parseInt(headers.get("X-WP-TotalPages") ?? "0", 10) || 0 + }; + } + async function trashPost(id) { + const cfg = getConfig(); + try { + await request(`${cfg.postsUrl}/${id}`, { + method: "DELETE" + }); + return { id, ok: true }; + } catch (err) { + return { + id, + ok: false, + error: err instanceof Error ? err.message : String(err) + }; + } + } + function buildEditPostUrl(id) { + const cfg = getConfig(); + const sep = cfg.editPostUrlBase.includes("?") ? "&" : "?"; + return `${cfg.editPostUrlBase}${sep}post=${id}&action=edit`; + } + async function searchTags(query, signal) { + const cfg = getConfig(); + const url = new URL(`${cfg.restRoot.replace(/\/$/, "")}/wp/v2/tags`); + url.searchParams.set("per_page", "20"); + url.searchParams.set("_fields", "id,name,slug,count"); + url.searchParams.set("orderby", "count"); + url.searchParams.set("order", "desc"); + if (query) { + url.searchParams.set("search", query); + url.searchParams.set("orderby", "name"); + url.searchParams.set("order", "asc"); + } + const { data } = await request(url.toString(), { + method: "GET", + signal + }); + return Array.isArray(data) ? data : []; + } + async function createTag(name) { + const cfg = getConfig(); + const url = `${cfg.restRoot.replace(/\/$/, "")}/wp/v2/tags`; + try { + const { data } = await request(url, { + method: "POST", + body: JSON.stringify({ name }) + }); + broadcastTermChange("post_tag", "created", data.id); + return data; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (/term[\s_]?exists/i.test(message)) { + const matches = await searchTags(name); + const exact = matches.find( + (t) => t.name.toLowerCase() === name.toLowerCase() + ); + if (exact) { + return exact; + } + } + throw err; + } + } + async function updatePostTags(postId, tagIds) { + const cfg = getConfig(); + const url = `${cfg.postsUrl}/${postId}`; + const { data } = await request(url, { + method: "POST", + body: JSON.stringify({ tags: tagIds }) + }); + return data; + } + async function fetchAllCategories(signal) { + const cfg = getConfig(); + const url = new URL(`${cfg.restRoot.replace(/\/$/, "")}/wp/v2/categories`); + url.searchParams.set("per_page", "100"); + url.searchParams.set("_fields", "id,name,slug,parent"); + url.searchParams.set("orderby", "name"); + url.searchParams.set("order", "asc"); + const { data } = await request(url.toString(), { + method: "GET", + signal + }); + return Array.isArray(data) ? data : []; + } + async function fetchAuthorOptions(signal) { + const cfg = getConfig(); + const url = new URL(`${cfg.restRoot.replace(/\/$/, "")}/wp/v2/users`); + url.searchParams.set("per_page", "100"); + url.searchParams.set("who", "authors"); + url.searchParams.set("_fields", "id,name"); + url.searchParams.set("orderby", "name"); + url.searchParams.set("order", "asc"); + try { + const { data } = await request(url.toString(), { + method: "GET", + signal + }); + return Array.isArray(data) ? data : []; + } catch { + return []; + } + } + async function fetchTagOptions(page = 1, perPage = 50, signal) { + const cfg = getConfig(); + const url = new URL(`${cfg.restRoot.replace(/\/$/, "")}/wp/v2/tags`); + url.searchParams.set("per_page", String(Math.max(1, perPage))); + url.searchParams.set("page", String(Math.max(1, page))); + url.searchParams.set("_fields", "id,name,count"); + url.searchParams.set("orderby", "count"); + url.searchParams.set("order", "desc"); + try { + const { data, headers } = await request( + url.toString(), + { method: "GET", signal } + ); + return { + items: Array.isArray(data) ? data : [], + totalPages: parseInt(headers.get("X-WP-TotalPages") ?? "0", 10) || 0 + }; + } catch { + return { items: [], totalPages: 0 }; + } + } + async function createCategory(name, parent = 0, opts = {}) { + const cfg = getConfig(); + const url = `${cfg.restRoot.replace(/\/$/, "")}/wp/v2/categories`; + const body = { name, parent }; + if (opts.slug) { + body.slug = opts.slug; + } + if (opts.description) { + body.description = opts.description; + } + try { + const { data } = await request(url, { + method: "POST", + body: JSON.stringify(body) + }); + broadcastTermChange("category", "created", data.id); + return data; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (/term[\s_]?exists/i.test(message)) { + const matches = await fetchAllCategories(); + const exact = matches.find( + (t) => t.name.toLowerCase() === name.toLowerCase() && t.parent === parent + ); + if (exact) { + return exact; + } + } + throw err; + } + } + function broadcastTermChange(taxonomy, action, id) { + const api = window.wp?.desktop; + if (api && typeof api.broadcast === "function") { + api.broadcast("desktop-mode.term.changed", { + source: "posts-window", + taxonomy, + action, + id + }); + } + } + async function updatePostCategories(postId, categoryIds) { + const cfg = getConfig(); + const url = `${cfg.postsUrl}/${postId}`; + const { data } = await request(url, { + method: "POST", + body: JSON.stringify({ categories: categoryIds }) + }); + return data; + } + async function fetchTerms(taxonomy, params = {}) { + const cfg = getConfig(); + const url = new URL( + `${cfg.restRoot.replace(/\/$/, "")}/wp/v2/${taxonomy}` + ); + url.searchParams.set("per_page", String(params.perPage ?? 50)); + url.searchParams.set("page", String(params.page ?? 1)); + url.searchParams.set( + "_fields", + "id,name,slug,parent,count,description,desktop_mode_count,desktop_mode_is_default" + ); + url.searchParams.set("orderby", params.orderby ?? "name"); + url.searchParams.set("order", params.order ?? "asc"); + if (params.search) { + url.searchParams.set("search", params.search); + } + if (typeof params.parent === "number" && params.parent >= 0) { + url.searchParams.set("parent", String(params.parent)); + } + const { data, headers } = await request( + url.toString(), + { method: "GET" } + ); + const items = Array.isArray(data) ? data.map((t) => { + const anyCount = t.desktop_mode_count; + const isDefault = t.desktop_mode_is_default === true; + return { + id: t.id ?? 0, + name: t.name ?? "", + slug: t.slug ?? "", + parent: t.parent ?? 0, + count: typeof anyCount === "number" ? anyCount : t.count ?? 0, + description: t.description ?? "", + isDefault + }; + }) : []; + return { + items, + total: parseInt(headers.get("X-WP-Total") ?? "0", 10) || 0, + totalPages: parseInt(headers.get("X-WP-TotalPages") ?? "0", 10) || 0 + }; + } + async function updateTerm(taxonomy, id, patch) { + const cfg = getConfig(); + const url = `${cfg.restRoot.replace(/\/$/, "")}/wp/v2/${taxonomy}/${id}`; + const { data } = await request(url, { + method: "POST", + body: JSON.stringify(patch) + }); + broadcastTermChange( + taxonomy === "categories" ? "category" : "post_tag", + "updated", + id + ); + return { + id: data.id ?? id, + name: data.name ?? "", + slug: data.slug ?? "", + parent: data.parent ?? 0, + count: data.count ?? 0, + description: data.description ?? "", + isDefault: data.isDefault ?? false + }; + } + async function deleteTerm(taxonomy, id) { + const cfg = getConfig(); + const url = new URL( + `${cfg.restRoot.replace(/\/$/, "")}/wp/v2/${taxonomy}/${id}` + ); + url.searchParams.set("force", "true"); + await request(url.toString(), { method: "DELETE" }); + broadcastTermChange( + taxonomy === "categories" ? "category" : "post_tag", + "deleted", + id + ); + } + 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 TOOLBAR_TRAILING_EXTRAS = "[data-desktop-mode-posts-toolbar-extras]"; + const BULK_ACTIONS_HOST = "[data-desktop-mode-posts-bulk-actions]"; + const HOOK_FILTER_COLUMNS = "desktop_mode.postsWindow.columns"; + const HOOK_FILTER_STATUS_SEGMENTS = "desktop_mode.postsWindow.statusSegments"; + const HOOK_FILTER_BULK_ACTIONS = "desktop_mode.postsWindow.bulkActions"; + const HOOK_FILTER_TOOLBAR_TRAILING = "desktop_mode.postsWindow.toolbarTrailing"; + const HOOK_ACTION_OPENED = "desktop_mode.postsWindow.opened"; + const HOOK_ACTION_DATA_LOADED = "desktop_mode.postsWindow.dataLoaded"; + const SEARCH_DEBOUNCE_MS = 250; + const STATUS_LABELS = { + publish: __("Published"), + future: __("Scheduled"), + draft: __("Draft"), + pending: __("Pending"), + private: __("Private"), + trash: __("Trash") + }; + function statusBadgeColor(status) { + switch (status) { + case "publish": + return { bg: "#e6f4ea", fg: "#1d6f42" }; + case "draft": + return { bg: "#fdecea", fg: "#a02622" }; + case "pending": + return { bg: "#fef7e0", fg: "#8a6d00" }; + case "private": + return { bg: "#e8f0fe", fg: "#1a52a8" }; + case "future": + return { bg: "#ede7f6", fg: "#5b3aa0" }; + case "trash": + return { bg: "#f1f1f2", fg: "#50575e" }; + default: + return { bg: "#f1f1f2", fg: "#50575e" }; + } + } + function decodeTitle(raw) { + const ta = document.createElement("textarea"); + ta.innerHTML = raw; + return ta.value; + } + function authorOf(row) { + const embedded = row._embedded?.author?.[0]; + if (embedded) { + const avatars = embedded.avatar_urls ?? {}; + return { + id: embedded.id, + name: embedded.name, + avatar: avatars["48"] ?? avatars["96"] ?? avatars["24"] + }; + } + return { id: row.author, name: __("Unknown") }; + } + function termRecordsOf(row, taxonomy) { + const groups = row._embedded?.["wp:term"] ?? []; + for (const group of groups) { + if (group.length === 0) { + continue; + } + if (group[0].taxonomy === taxonomy) { + return group.map((t) => ({ id: t.id, name: t.name })); + } + } + return []; + } + function featuredMediaOf(row) { + const media = row._embedded?.["wp:featuredmedia"]?.[0]; + if (!media) { + return null; + } + const sizes = media.media_details?.sizes ?? {}; + const small = sizes.thumbnail?.source_url ?? sizes.medium?.source_url ?? media.source_url; + return { url: small, alt: media.alt_text ?? "" }; + } + function cacheKey(rowId, columnKey) { + return `${rowId}|${columnKey}`; + } + function memoCell(cache, rowId, columnKey, build) { + const key = cacheKey(rowId, columnKey); + const cached = cache.get(key); + if (cached) { + return cached; + } + const built = build(); + cache.set(key, built); + return built; + } + const REQUIRED_COLUMN_KEYS = /* @__PURE__ */ new Set(["title"]); + function getHiddenColumns() { + try { + const api = window.wp?.desktop; + if (api && typeof api.getOsSettings === "function") { + const snap = api.getOsSettings(); + if (Array.isArray(snap.nativePostsHiddenColumns)) { + return new Set(snap.nativePostsHiddenColumns); + } + } + } catch { + } + return /* @__PURE__ */ new Set(); + } + const EMPTY_FILTER_DATA = { authors: [], tags: [] }; + function buildAllColumns(cache, filterData = EMPTY_FILTER_DATA) { + const cols = _buildBaseColumns(cache, filterData); + const hooks = window.wp?.hooks; + return hooks && typeof hooks.applyFilters === "function" ? hooks.applyFilters( + HOOK_FILTER_COLUMNS, + cols + ) : cols; + } + function buildColumns(cache, filterData = EMPTY_FILTER_DATA) { + const all = buildAllColumns(cache, filterData); + const hidden = getHiddenColumns(); + if (hidden.size === 0) { + return all; + } + return all.filter( + (col) => REQUIRED_COLUMN_KEYS.has(col.key) || !hidden.has(col.key) + ); + } + function _buildBaseColumns(cache, filterData) { + return [ + { + key: "title", + label: __("Title"), + sortable: true, + sticky: true, + render: (_v, row) => memoCell(cache, row.id, "title", () => buildTitleCell(row)) + }, + { + key: "author", + label: __("Author"), + sortable: true, + width: "180px", + filterRender: (host, ctx) => renderMultiSelectFilter(host, ctx, filterData.authors, { + label: __("All authors"), + ariaLabel: __("Filter by author") + }), + render: (_v, row) => memoCell(cache, row.id, "author", () => buildAuthorCell(row)) + }, + { + key: "categories", + label: __("Categories"), + width: "260px", + render: (_v, row) => memoCell( + cache, + row.id, + "categories", + () => buildCategoriesCell(row) + ) + }, + { + key: "tags", + label: __("Tags"), + // Drop the fixed width so the column flexes with the + // available space; pin a minimum that comfortably holds + // ~4 chips on one line so the cell doesn't collapse the + // tags into a vertical stack on narrow tables. + minWidth: "360px", + filterRender: (host, ctx) => renderMultiSelectFilter( + host, + ctx, + filterData.tags.map((t) => ({ id: t.id, name: t.name })), + { + label: __("All tags"), + ariaLabel: __("Filter by tag"), + dataKey: "tags", + hasMore: !!filterData.tagsHasMore, + onLoadMore: filterData.loadMoreTags + } + ), + render: (_v, row) => memoCell(cache, row.id, "tags", () => buildTagsCell(row)) + }, + { + key: "date", + label: __("Date"), + sortable: true, + width: "170px", + sortValue: (row) => Date.parse(row.date_gmt + "Z") || 0, + render: (_v, row) => memoCell(cache, row.id, "date", () => buildDateCell(row)) + } + ]; + } + function renderMultiSelectFilter(host, ctx, all, opts) { + const HOST_KEY = "wpdPostsFilterMounted"; + const tagged = host; + const optionsForPicker = all.map((o) => ({ + value: String(o.id), + label: o.name + })); + const nextSig = optionsForPicker.map((o) => `${o.value}:${o.label}`).join("|"); + if (tagged[HOST_KEY]) { + const state = tagged[HOST_KEY]; + if (state.listSig !== nextSig) { + state.picker.items = optionsForPicker; + state.listSig = nextSig; + } + if (state.picker.getAttribute("value") !== ctx.value) { + state.picker.setAttribute("value", ctx.value); + } + state.picker.hasMore = !!opts.hasMore; + return; + } + const picker = document.createElement("wpd-multiselect"); + picker.setAttribute("placeholder", opts.label); + picker.setAttribute("aria-label", opts.ariaLabel); + picker.setAttribute("data-noclick", ""); + picker.setAttribute("value", ctx.value); + if (opts.dataKey) { + picker.setAttribute("data-key", opts.dataKey); + } + host.appendChild(picker); + picker.items = optionsForPicker; + picker.hasMore = !!opts.hasMore; + picker.addEventListener("wpd-pick", (e) => { + const detail = e.detail; + const next = detail?.value ?? ""; + ctx.value = next; + ctx.setValue(next); + }); + if (opts.onLoadMore) { + const onLoadMore = opts.onLoadMore; + picker.addEventListener("wpd-multiselect-load-more", () => { + picker.loadingMore = true; + onLoadMore(); + }); + } + tagged[HOST_KEY] = { picker, listSig: nextSig }; + } + function mountKebabColumnToggles(body, cache, repaintColumns) { + const winEl = body.closest(".desktop-mode-window"); + const panel = winEl?.querySelector( + ".desktop-mode-window__menu-panel" + ); + if (!panel) { + return null; + } + const SECTION_CLASS = "desktop-mode-posts-window__menu-columns"; + const ITEM_CLASS = "desktop-mode-posts-window__menu-column-item"; + const VALUE_PREFIX = "desktop-mode-posts-column:"; + panel.querySelectorAll(`.${SECTION_CLASS}, .${ITEM_CLASS}`).forEach((n) => n.remove()); + const allCols = buildAllColumns(cache); + const togglable = allCols.filter( + (c) => !REQUIRED_COLUMN_KEYS.has(c.key) + ); + if (togglable.length === 0) { + return null; + } + const sectionLabel = document.createElement("div"); + sectionLabel.className = SECTION_CLASS; + sectionLabel.setAttribute("role", "presentation"); + sectionLabel.textContent = __("Show columns"); + panel.appendChild(sectionLabel); + const itemEls = /* @__PURE__ */ new Map(); + for (const col of togglable) { + const item = document.createElement("wpd-menu-item"); + item.setAttribute("role", "menuitemcheckbox"); + item.setAttribute("value", VALUE_PREFIX + col.key); + item.classList.add("desktop-mode-window__menu-item"); + item.classList.add(ITEM_CLASS); + item.textContent = col.label || col.key; + panel.appendChild(item); + itemEls.set(col.key, item); + } + const paintChecked = () => { + const hidden = getHiddenColumns(); + for (const [key, el] of itemEls) { + if (hidden.has(key)) { + el.removeAttribute("checked"); + } else { + el.setAttribute("checked", ""); + } + } + }; + paintChecked(); + const onClick = (e) => { + const detail = e.detail; + const value = detail?.value; + if (typeof value !== "string" || !value.startsWith(VALUE_PREFIX)) { + return; + } + const key = value.slice(VALUE_PREFIX.length); + if (!itemEls.has(key) || REQUIRED_COLUMN_KEYS.has(key)) { + return; + } + const hidden = getHiddenColumns(); + if (hidden.has(key)) { + hidden.delete(key); + } else { + hidden.add(key); + } + const next = Array.from(hidden).sort(); + const api = window.wp?.desktop; + if (api && typeof api.updateOsSettings === "function") { + api.updateOsSettings( + { nativePostsHiddenColumns: next }, + { windowId: "desktop-mode-posts" } + ); + } + paintChecked(); + repaintColumns(); + }; + panel.addEventListener("wpd-menu-item-click", onClick); + return { + refresh: paintChecked, + dispose: () => { + panel.removeEventListener("wpd-menu-item-click", onClick); + sectionLabel.remove(); + for (const el of itemEls.values()) { + el.remove(); + } + itemEls.clear(); + } + }; + } + function defaultStatusSegments() { + return [ + { value: "", label: __("All") }, + { value: "publish", label: __("Published") }, + { value: "draft", label: __("Drafts") }, + { value: "pending", label: __("Pending") }, + { value: "future", label: __("Scheduled") }, + { value: "trash", label: __("Trash") } + ]; + } + function defaultBulkActions() { + return [ + { + id: "trash", + label: __("Move to trash"), + icon: "dashicons-trash", + variant: "danger", + /* translators: %d: row count. */ + confirm: __("Move %d post(s) to the trash?"), + run: async (ids, ctx) => { + const data = ctx.table.data ?? []; + const trashable = ids.filter((id) => { + const row = data.find((r) => r.id === id); + return row && row.status !== "trash"; + }); + if (trashable.length === 0) { + return; + } + const results = await Promise.all( + trashable.map((id) => trashPost(id)) + ); + const errors = results.filter((r) => !r.ok); + if (errors.length > 0) { + console.error("[posts-window] some trashes failed", errors); + } + const okIds = results.filter((r) => r.ok).map((r) => r.id); + const api = window.wp?.desktop; + if (api && typeof api.broadcast === "function") { + api.broadcast("desktop-mode.post.changed", { + source: "posts-window", + action: "trashed", + ids: okIds + }); + } + } + } + ]; + } + function resolveBulkActions() { + const hooks = window.wp?.hooks; + const defaults = defaultBulkActions(); + if (!hooks || typeof hooks.applyFilters !== "function") { + return defaults; + } + try { + const out = hooks.applyFilters(HOOK_FILTER_BULK_ACTIONS, defaults); + return Array.isArray(out) ? out : defaults; + } catch (err) { + console.error( + "[posts-window] bulk-actions filter threw; falling back to defaults:", + err + ); + return defaults; + } + } + function resolveStatusSegments() { + const hooks = window.wp?.hooks; + const defaults = defaultStatusSegments(); + if (!hooks || typeof hooks.applyFilters !== "function") { + return defaults; + } + try { + const out = hooks.applyFilters(HOOK_FILTER_STATUS_SEGMENTS, defaults); + return Array.isArray(out) && out.length > 0 ? out : defaults; + } catch (err) { + console.error( + "[posts-window] status-segments filter threw; falling back to defaults:", + err + ); + return defaults; + } + } + function resolveToolbarTrailing(ctx) { + const hooks = window.wp?.hooks; + if (!hooks || typeof hooks.applyFilters !== "function") { + return []; + } + try { + const out = hooks.applyFilters(HOOK_FILTER_TOOLBAR_TRAILING, [], ctx); + if (!Array.isArray(out)) { + return []; + } + return out.filter((el) => el instanceof HTMLElement); + } catch (err) { + console.error( + "[posts-window] toolbar-trailing filter threw; ignoring:", + err + ); + return []; + } + } + function buildTitleCell(row) { + const cell = document.createElement("span"); + cell.style.cssText = "display:flex;flex-direction:column;gap:4px;min-width:0;"; + const titleRow = document.createElement("span"); + titleRow.style.cssText = "display:flex;align-items:center;gap:8px;min-width:0;"; + const link = document.createElement("a"); + link.href = buildEditPostUrl(row.id); + link.setAttribute("data-noclick", ""); + const title = decodeTitle(row.title.rendered) || __("(no title)"); + link.textContent = title; + link.title = title; + link.style.cssText = "font-weight:600;color:inherit;text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:340px;"; + link.addEventListener("mouseenter", () => { + link.style.textDecoration = "underline"; + }); + link.addEventListener("mouseleave", () => { + link.style.textDecoration = "none"; + }); + link.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + openAdminUrl(link.href, { + title, + icon: "dashicons-admin-post" + }); + }); + titleRow.appendChild(link); + if (row.status && row.status !== "publish") { + const badge = document.createElement("span"); + const colors = statusBadgeColor(row.status); + badge.textContent = STATUS_LABELS[row.status] ?? row.status; + badge.style.cssText = [ + "display:inline-flex", + "align-items:center", + "padding:2px 8px", + "border-radius:10px", + "font-size:11px", + "font-weight:600", + "text-transform:uppercase", + "letter-spacing:0.04em", + `background:${colors.bg}`, + `color:${colors.fg}`, + "white-space:nowrap", + "flex-shrink:0" + ].join(";"); + titleRow.appendChild(badge); + } + cell.appendChild(titleRow); + return cell; + } + function buildAuthorCell(row) { + const a = authorOf(row); + const wrap = document.createElement("span"); + wrap.style.cssText = "display:inline-flex;align-items:center;gap:8px;min-width:0;"; + if (a.avatar) { + const img = document.createElement("img"); + img.src = a.avatar; + img.alt = ""; + img.loading = "eager"; + img.decoding = "sync"; + img.style.cssText = "width:24px;height:24px;border-radius:50%;flex-shrink:0;"; + wrap.appendChild(img); + } + const name = document.createElement("span"); + name.textContent = a.name; + name.style.cssText = "overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"; + wrap.appendChild(name); + return wrap; + } + function buildTagsCell(row) { + const wrap = document.createElement("span"); + wrap.style.cssText = "display:inline-flex;align-items:center;width:100%;min-width:0;"; + const picker = document.createElement("wpd-tag-input"); + picker.setAttribute("creatable", ""); + picker.setAttribute("removable", ""); + picker.setAttribute("min-query", "0"); + picker.setAttribute("placeholder", __("Add tag…")); + picker.setAttribute("add-label", __("Tag")); + picker.setAttribute("data-noclick", ""); + const seed = termRecordsOf(row, "post_tag").map((t) => ({ + id: t.id, + label: t.name + })); + picker.value = seed; + const cellState = { + // Mirror of `picker.value` we mutate optimistically. Keeping + // it here (rather than reading back from the picker) avoids + // double-source-of-truth bugs when two events fire in the + // same tick. + tags: seed.slice(), + // AbortController for the in-flight suggest fetch. + suggestAbort: null, + suggestDebounce: null, + // Last query the user typed — used to drop stale responses + // even after AbortController has fired. + lastQuery: "" + }; + const setValue = (next) => { + cellState.tags = next.slice(); + picker.value = next; + }; + picker.addEventListener("wpd-tag-suggest", (e) => { + const detail = e.detail; + const query = detail?.query ?? ""; + cellState.lastQuery = query; + if (cellState.suggestDebounce !== null) { + window.clearTimeout(cellState.suggestDebounce); + cellState.suggestDebounce = null; + } + cellState.suggestDebounce = window.setTimeout(async () => { + cellState.suggestDebounce = null; + if (cellState.suggestAbort) { + cellState.suggestAbort.abort(); + } + const ac = new AbortController(); + cellState.suggestAbort = ac; + try { + const matches = await searchTags(query, ac.signal); + if (cellState.lastQuery !== query) { + return; + } + const existingIds = new Set(cellState.tags.map((t) => t.id)); + picker.suggestions = matches.filter((m) => !existingIds.has(m.id)).map((m) => ({ id: m.id, label: m.name })); + } catch (err) { + if (err?.name === "AbortError") { + return; + } + picker.suggestions = []; + console.warn( + "[posts-window] tag search failed", + err + ); + } finally { + picker.suggestionsLoading = false; + } + }, 200); + }); + picker.addEventListener("wpd-tag-add", async (e) => { + const detail = e.detail; + if (!detail?.tag) { + return; + } + const optimistic = { + id: detail.tag.id, + label: detail.tag.label, + pending: true + }; + const next = [...cellState.tags, optimistic]; + setValue(next); + try { + let resolvedTag = null; + if (detail.isNew || typeof detail.tag.id !== "number") { + resolvedTag = await createTag(detail.tag.label); + } else { + resolvedTag = { + id: Number(detail.tag.id), + name: detail.tag.label, + slug: "" + }; + } + const desiredIds = [ + ...cellState.tags.filter((t) => !t.pending).map((t) => Number(t.id)), + resolvedTag.id + ]; + await updatePostTags(row.id, desiredIds); + setValue( + cellState.tags.map((t) => { + if (t.label.toLowerCase() === detail.tag.label.toLowerCase()) { + return { + id: resolvedTag.id, + label: resolvedTag.name + }; + } + return t; + }) + ); + const api = window.wp?.desktop; + if (api && typeof api.broadcast === "function") { + api.broadcast("desktop-mode.post.changed", { + source: "posts-window", + action: "tagged", + ids: [row.id] + }); + } + } catch (err) { + setValue( + cellState.tags.filter( + (t) => t.label.toLowerCase() !== detail.tag.label.toLowerCase() + ) + ); + showTagError( + sprintf( + /* translators: %s: tag label */ + __('Couldn’t add tag "%s".'), + detail.tag.label + ), + err + ); + } + }); + picker.addEventListener("wpd-tag-remove", async (e) => { + const detail = e.detail; + if (!detail?.tag) { + return; + } + const removed = detail.tag; + const previous = cellState.tags.slice(); + setValue( + cellState.tags.map( + (t) => t.label === removed.label ? { ...t, pending: true } : t + ) + ); + try { + const desiredIds = previous.filter((t) => t.label !== removed.label).map((t) => Number(t.id)).filter((n) => Number.isFinite(n)); + await updatePostTags(row.id, desiredIds); + setValue( + previous.filter((t) => t.label !== removed.label) + ); + const api = window.wp?.desktop; + if (api && typeof api.broadcast === "function") { + api.broadcast("desktop-mode.post.changed", { + source: "posts-window", + action: "untagged", + ids: [row.id] + }); + } + } catch (err) { + setValue(previous); + showTagError( + sprintf( + /* translators: %s: tag label */ + __('Couldn’t remove tag "%s".'), + removed.label + ), + err + ); + } + }); + wrap.appendChild(picker); + return wrap; + } + function showTagError(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); + } + function buildCategoriesCell(row) { + const wrap = document.createElement("span"); + wrap.className = "wpd-cat-cell-dropzone"; + wrap.style.cssText = "display:inline-flex;align-items:center;width:100%;min-width:0;border-radius:6px;transition:background-color 0.12s ease, box-shadow 0.12s ease;"; + const picker = document.createElement( + "wpd-category-picker" + ); + picker.setAttribute("placeholder", __("Search categories…")); + picker.setAttribute("add-label", __("Categorize")); + picker.setAttribute("data-noclick", ""); + _activePickers.add(picker); + picker.value = row.categories ?? []; + const seedItems = termRecordsOf(row, "category").map( + (t) => ({ id: t.id, name: t.name, parent: 0 }) + ); + picker.items = seedItems; + const cellState = { + categoryIds: (row.categories ?? []).slice() + }; + const setValue = (next) => { + cellState.categoryIds = next.slice(); + picker.value = next; + }; + void getCategoriesTree().then((tree) => { + if (!picker.isConnected) { + return; + } + picker.items = tree; + }).catch((err) => { + console.warn("[posts-window] category tree fetch failed", err); + }); + picker.addEventListener("wpd-categories-open", () => { + void primePickerFromCache(picker); + }); + picker.addEventListener( + "wpd-categories-create", + async (e) => { + const detail = e.detail; + const parent = detail?.parent ?? 0; + if (!detail || !detail.name) { + picker.failCreating(parent); + return; + } + try { + const created = await createCategory(detail.name, parent); + _categoryTreePromise = null; + const nextItems = [ + ...picker.items, + { + id: created.id, + name: created.name, + parent: created.parent + } + ]; + picker.items = nextItems; + const nextValue = [...cellState.categoryIds, created.id]; + setValue(nextValue); + picker.endCreating(parent); + try { + await updatePostCategories(row.id, nextValue); + const api = window.wp?.desktop; + if (api && typeof api.broadcast === "function") { + api.broadcast("desktop-mode.post.changed", { + source: "posts-window", + action: "categorized", + ids: [row.id] + }); + } + } catch (err) { + setValue(cellState.categoryIds.filter((id) => id !== created.id)); + showTagError(__("Couldn’t assign new category."), err); + } + } catch (err) { + picker.failCreating( + parent, + err instanceof Error ? err.message : String(err) + ); + showTagError(__("Couldn’t create category."), err); + } + } + ); + picker.addEventListener("wpd-categories-change", async (e) => { + const detail = e.detail; + if (!detail || !Array.isArray(detail.value)) { + return; + } + const previous = cellState.categoryIds.slice(); + const next = detail.value.slice(); + setValue(next); + try { + await updatePostCategories(row.id, next); + const api = window.wp?.desktop; + if (api && typeof api.broadcast === "function") { + api.broadcast("desktop-mode.post.changed", { + source: "posts-window", + action: "categorized", + ids: [row.id] + }); + } + } catch (err) { + setValue(previous); + showTagError(__("Couldn’t update categories."), err); + } + }); + picker.addEventListener("wpd-categories-delete", async (e) => { + const detail = e.detail; + if (!detail || typeof detail.id !== "number") { + return; + } + const ok = window.confirm( + sprintf( + /* translators: %s: category name. */ + __( + 'Delete the category "%s"? Posts assigned only to it will fall back to Uncategorized.' + ), + detail.name + ) + ); + if (!ok) { + return; + } + try { + await deleteTerm("categories", detail.id); + if (cellState.categoryIds.includes(detail.id)) { + const next = cellState.categoryIds.filter( + (id) => id !== detail.id + ); + setValue(next); + try { + await updatePostCategories(row.id, next); + } catch (err) { + showTagError( + __("Couldn’t update post categories after delete."), + err + ); + } + } + } catch (err) { + showTagError(__("Couldn’t delete category."), err); + } + }); + picker.addEventListener("wpd-chain-segment-dragstart", (e) => { + const detail = e.detail; + if (!detail || !detail.dragEvent || !detail.dragEvent.dataTransfer) { + return; + } + const ids = []; + for (const seg of detail.segments) { + if (typeof seg.id === "number") { + ids.push(seg.id); + } + } + if (ids.length === 0) { + return; + } + const dt = detail.dragEvent.dataTransfer; + dt.setData( + "application/x-desktop-mode-categories", + JSON.stringify({ + ids, + source: "posts-window", + sourcePostId: row.id + }) + ); + dt.setData("text/plain", ids.join(",")); + dt.effectAllowed = "copy"; + }); + let dropEnterCount = 0; + const setDropTargetActive = (on) => { + if (on) { + wrap.style.backgroundColor = "color-mix(in srgb, var(--wp-admin-theme-color, #2271b1) 12%, transparent)"; + wrap.style.boxShadow = "inset 0 0 0 2px var(--wp-admin-theme-color, #2271b1)"; + } else { + wrap.style.backgroundColor = ""; + wrap.style.boxShadow = ""; + } + }; + const acceptsCategoriesDrag = (e) => { + const types = e.dataTransfer?.types; + if (!types) { + return false; + } + return Array.from(types).includes( + "application/x-desktop-mode-categories" + ); + }; + wrap.addEventListener("dragenter", (e) => { + if (!acceptsCategoriesDrag(e)) { + return; + } + e.preventDefault(); + dropEnterCount++; + setDropTargetActive(true); + }); + wrap.addEventListener("dragover", (e) => { + if (!acceptsCategoriesDrag(e)) { + return; + } + e.preventDefault(); + if (e.dataTransfer) { + e.dataTransfer.dropEffect = "copy"; + } + }); + wrap.addEventListener("dragleave", () => { + if (dropEnterCount > 0) { + dropEnterCount--; + } + if (dropEnterCount === 0) { + setDropTargetActive(false); + } + }); + wrap.addEventListener("drop", async (e) => { + dropEnterCount = 0; + setDropTargetActive(false); + if (!acceptsCategoriesDrag(e)) { + return; + } + e.preventDefault(); + const json = e.dataTransfer?.getData( + "application/x-desktop-mode-categories" + ); + if (!json) { + return; + } + let parsed; + try { + parsed = JSON.parse(json); + } catch { + return; + } + const payload = parsed; + if (!payload || !Array.isArray(payload.ids)) { + return; + } + const incoming = []; + for (const v of payload.ids) { + if (typeof v === "number" && Number.isFinite(v)) { + incoming.push(v); + } + } + if (incoming.length === 0) { + return; + } + if (payload.sourcePostId === row.id && incoming.every((id) => cellState.categoryIds.includes(id))) { + return; + } + const merged = Array.from( + /* @__PURE__ */ new Set([...cellState.categoryIds, ...incoming]) + ); + if (merged.length === cellState.categoryIds.length) { + return; + } + const previous = cellState.categoryIds.slice(); + setValue(merged); + try { + await updatePostCategories(row.id, merged); + const api = window.wp?.desktop; + if (api && typeof api.broadcast === "function") { + api.broadcast("desktop-mode.post.changed", { + source: "posts-window", + action: "categorized", + ids: [row.id] + }); + } + } catch (err) { + setValue(previous); + showTagError(__("Couldn’t add category."), err); + } + }); + wrap.appendChild(picker); + return wrap; + } + let _categoryTreePromise = null; + function getCategoriesTree() { + if (!_categoryTreePromise) { + _categoryTreePromise = fetchAllCategories().then( + (terms) => terms.map((t) => ({ + id: t.id, + name: t.name, + parent: t.parent + })) + ); + } + return _categoryTreePromise; + } + function clearCategoryTreeCache() { + _categoryTreePromise = null; + } + const _activePickers = /* @__PURE__ */ new Set(); + function broadcastFreshCategoryTreeToPickers() { + void getCategoriesTree().then((tree) => { + for (const picker of _activePickers) { + if (picker.isConnected) { + picker.items = tree; + } else { + _activePickers.delete(picker); + } + } + }).catch(() => { + }); + } + async function primePickerFromCache(picker) { + if (!_categoryTreePromise) { + return; + } + try { + picker.items = await _categoryTreePromise; + } catch { + } + } + function buildDateCell(row) { + const wrap = document.createElement("span"); + wrap.style.cssText = "display:flex;flex-direction:column;line-height:1.2;"; + const time = document.createElement("wpd-relative-time"); + time.setAttribute("datetime", row.date); + wrap.appendChild(time); + if (row.modified_gmt && row.modified_gmt !== row.date_gmt) { + const meta = document.createElement("span"); + meta.textContent = __("modified"); + meta.style.cssText = "font-size:11px;color:#646970;"; + wrap.appendChild(meta); + } + return wrap; + } + function buildSubRow(row) { + const wrap = document.createElement("div"); + wrap.style.cssText = "display:flex;gap:16px;padding:12px 16px;background:#fafafa;align-items:flex-start;"; + const featured = featuredMediaOf(row); + if (featured) { + const img = document.createElement("img"); + img.src = featured.url; + img.alt = featured.alt; + img.loading = "lazy"; + img.style.cssText = "width:96px;height:96px;border-radius:6px;object-fit:cover;flex-shrink:0;"; + wrap.appendChild(img); + } + const text = document.createElement("div"); + text.style.cssText = "flex:1;min-width:0;display:flex;flex-direction:column;gap:6px;"; + const heading = document.createElement("div"); + heading.style.cssText = "font-size:13px;color:#646970;text-transform:uppercase;letter-spacing:0.04em;"; + heading.textContent = __("Excerpt"); + text.appendChild(heading); + const excerpt = document.createElement("div"); + excerpt.style.cssText = "color:#1d2327;line-height:1.5;"; + const raw = row.excerpt?.rendered ?? ""; + if (raw) { + const stripped = raw.replace(/<[^>]+>/g, "").trim(); + excerpt.textContent = stripped || __("(no excerpt)"); + } else { + excerpt.textContent = __("(no excerpt)"); + excerpt.style.color = "#a7aaad"; + } + text.appendChild(excerpt); + wrap.appendChild(text); + return wrap; + } + async function renderPostsWindow(body) { + const root = body.querySelector(ROOT); + const table = body.querySelector(TABLE); + if (!root || !table) { + return; + } + const catsHost = body.querySelector( + "[data-desktop-mode-posts-cats-host]" + ); + const tagsHost = body.querySelector( + "[data-desktop-mode-posts-tags-host]" + ); + let catsTeardown = null; + let tagsTeardown = null; + const tabsEl = body.querySelector(".desktop-mode-posts__tabs"); + if (tabsEl) { + tabsEl.addEventListener("wpd-tab-change", (e) => { + const detail = e.detail; + const value = detail?.value; + if (value === "categories" && catsHost && !catsTeardown) { + void Promise.resolve().then(() => categoriesMindmap).then( + async ({ mountCategoriesMindmap: mountCategoriesMindmap2 }) => { + catsTeardown = await mountCategoriesMindmap2(catsHost); + } + ); + } + if (value === "tags" && tagsHost && !tagsTeardown) { + void Promise.resolve().then(() => tagsCloud).then( + async ({ mountTagsCloud: mountTagsCloud2 }) => { + tagsTeardown = await mountTagsCloud2(tagsHost); + } + ); + } + }); + } + const cfg = getConfig(); + const view = { + page: 1, + perPage: Math.max(1, cfg.defaultPerPage || 20), + search: "", + status: "", + orderby: "date", + order: "desc", + author: [], + tag: [], + searchDebounce: null + }; + const cellCache = /* @__PURE__ */ new Map(); + const filterData = { authors: [], tags: [] }; + table.columns = buildColumns(cellCache, filterData); + table.getRowId = (row) => row.id; + table.subTable = (row) => buildSubRow(row); + table.sort = { key: "date", direction: "desc" }; + 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 trailingExtras = root.querySelector( + TOOLBAR_TRAILING_EXTRAS + ); + const statusHost = root.querySelector(STATUS); + const statusSegments = resolveStatusSegments(); + if (statusHost) { + statusHost.replaceChildren(); + for (const seg of statusSegments) { + const el = document.createElement("wpd-segment"); + el.setAttribute("value", seg.value); + el.textContent = seg.label; + statusHost.appendChild(el); + } + statusHost.setAttribute("value", view.status); + } + const updatePager = () => { + if (indicator) { + if (totalRows === 0) { + indicator.textContent = __("No posts"); + } else { + indicator.textContent = sprintf( + /* translators: 1: current page, 2: total pages, 3: total posts. */ + __("Page %1$d of %2$d · %3$d posts"), + view.page, + Math.max(totalPages, 1), + totalRows + ); + } + } + if (prevBtn) { + prevBtn.toggleAttribute("disabled", view.page <= 1); + } + if (nextBtn) { + nextBtn.toggleAttribute("disabled", view.page >= totalPages); + } + }; + const updateBulkBar = () => { + if (!bulkBar || !countEl) { + return; + } + const sel = Array.from(table.selection ?? []); + if (sel.length === 0) { + bulkBar.hidden = true; + return; + } + bulkBar.hidden = false; + countEl.textContent = sprintf( + /* translators: %d: selected row count. */ + __("%d selected"), + sel.length + ); + }; + const buildParams = () => ({ + page: view.page, + perPage: view.perPage, + search: view.search || void 0, + status: view.status || void 0, + orderby: view.orderby, + order: view.order, + author: view.author.length > 0 ? view.author : void 0, + tag: view.tag.length > 0 ? view.tag : void 0 + }); + const ctx = { + body, + table, + refresh: () => refresh(), + getSelectedIds: () => Array.from(table.selection ?? []).map((id) => Number(id)), + getSelectedRows: () => { + const ids = new Set(ctx.getSelectedIds()); + return (table.data ?? []).filter((r) => ids.has(r.id)); + }, + getCurrentParams: () => buildParams() + }; + const refresh = async () => { + const mySeq = ++refreshSeq; + table.toggleAttribute("loading", true); + try { + const result = await fetchPosts(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(); + table.data = result.items; + totalRows = result.total; + totalPages = result.totalPages; + updatePager(); + const hooks2 = window.wp?.hooks; + if (hooks2 && typeof hooks2.doAction === "function") { + hooks2.doAction(HOOK_ACTION_DATA_LOADED, { + items: result.items, + total: result.total, + totalPages: result.totalPages, + page: view.page + }); + } + document.dispatchEvent( + new CustomEvent("desktop-mode-posts-window-data-loaded", { + detail: { + items: result.items, + total: result.total, + totalPages: result.totalPages, + page: view.page + } + }) + ); + } catch (err) { + if (mySeq !== refreshSeq) { + return; + } + console.error("[posts-window] list failed", err); + table.data = []; + totalRows = 0; + totalPages = 0; + updatePager(); + } finally { + if (mySeq === refreshSeq) { + table.toggleAttribute("loading", false); + updateBulkBar(); + } + } + }; + const goToFirstPage = () => { + if (view.page !== 1) { + view.page = 1; + } + }; + root.querySelector(STATUS)?.addEventListener("wpd-pick", (e) => { + const value = e.detail?.value ?? ""; + view.status = value; + goToFirstPage(); + void refresh(); + }); + root.querySelector(SEARCH)?.addEventListener( + "wpd-input-change", + (e) => { + const value = e.detail?.value ?? ""; + view.search = value; + if (view.searchDebounce !== null) { + window.clearTimeout(view.searchDebounce); + } + view.searchDebounce = window.setTimeout(() => { + goToFirstPage(); + void refresh(); + }, SEARCH_DEBOUNCE_MS); + } + ); + body.addEventListener("click", (e) => { + const target = e.target; + if (!target) { + return; + } + if (target.closest(REFRESH)) { + void refresh(); + return; + } + if (target.closest(NEW_BTN)) { + openAdminUrl(cfg.newPostUrl, { + title: __("Add New Post"), + icon: "dashicons-admin-post" + }); + return; + } + if (target.closest(PREV)) { + if (view.page > 1) { + view.page -= 1; + void refresh(); + } + return; + } + if (target.closest(NEXT)) { + if (view.page < totalPages) { + view.page += 1; + void refresh(); + } + } + }); + const bulkActions = resolveBulkActions(); + if (bulkActionsHost) { + bulkActionsHost.replaceChildren(); + for (const action of bulkActions) { + bulkActionsHost.appendChild(buildBulkActionButton(action, ctx)); + } + } + if (trailingExtras) { + const extras = resolveToolbarTrailing(ctx); + trailingExtras.replaceChildren(...extras); + } + perPageEl?.addEventListener("change", () => { + const next = parseInt(perPageEl.value, 10); + if (!Number.isFinite(next) || next < 1) { + return; + } + view.perPage = next; + goToFirstPage(); + void refresh(); + }); + table.addEventListener("wpd-table-selection-change", () => { + updateBulkBar(); + }); + table.addEventListener("wpd-table-sort-change", (e) => { + const detail = e.detail; + if (!detail || !detail.sort) { + view.orderby = "date"; + view.order = "desc"; + } else { + view.orderby = mapColumnToOrderby(detail.sort.key); + view.order = detail.sort.direction; + } + void refresh(); + }); + const parseIds = (raw) => raw.split(",").map((s) => parseInt(s.trim(), 10)).filter((n) => Number.isFinite(n) && n > 0); + const sameIds = (a, b) => a.length === b.length && a.every((v, i) => v === b[i]); + table.addEventListener("wpd-table-filter-change", (e) => { + const detail = e.detail; + const filters = detail?.filters ?? {}; + const nextAuthor = parseIds(filters.author ?? ""); + const nextTag = parseIds(filters.tags ?? ""); + const changed = !sameIds(nextAuthor, view.author) || !sameIds(nextTag, view.tag); + if (!changed) { + return; + } + view.author = nextAuthor; + view.tag = nextTag; + view.page = 1; + void refresh(); + }); + activeRunBulkAction = async (action, actionCtx) => { + const ids = actionCtx.getSelectedIds(); + if (ids.length === 0) { + return; + } + if (action.confirm) { + const ok = window.confirm( + sprintf( + /* translators: %d: row count. */ + action.confirm, + ids.length + ) + ); + if (!ok) { + return; + } + } + try { + const result = await action.run(ids, actionCtx); + if (result === false) { + return; + } + } catch (err) { + console.error( + `[posts-window] bulk action "${action.id}" failed`, + err + ); + } + table.clearSelection(); + await refresh(); + }; + const broadcastUnsubs = []; + if (window.wp?.desktop && typeof window.wp.desktop.subscribe === "function") { + const onChange = (payload) => { + const detail = payload; + if (detail?.source === "posts-window") { + return; + } + void refresh(); + }; + broadcastUnsubs.push( + window.wp.desktop.subscribe("desktop-mode.post.changed", onChange) + ); + const onTermChange = (payload) => { + const detail = payload; + if (detail?.taxonomy === "category") { + clearCategoryTreeCache(); + broadcastFreshCategoryTreeToPickers(); + } + }; + broadcastUnsubs.push( + window.wp.desktop.subscribe( + "desktop-mode.term.changed", + onTermChange + ) + ); + } + const repaintColumns = () => { + cellCache.clear(); + table.columns = buildColumns(cellCache, filterData); + }; + void fetchAuthorOptions().then((authors) => { + filterData.authors = authors; + repaintColumns(); + }); + let tagPage = 0; + let tagTotalPages = 1; + let tagFetching = false; + const TAG_PAGE_SIZE = 50; + const fetchNextTagPage = async () => { + if (tagFetching || tagPage >= tagTotalPages) { + return; + } + tagFetching = true; + try { + const next = tagPage + 1; + const res = await fetchTagOptions(next, TAG_PAGE_SIZE); + tagPage = next; + tagTotalPages = Math.max(tagTotalPages, res.totalPages || next); + const seen = new Set(filterData.tags.map((t) => t.id)); + for (const item of res.items) { + if (!seen.has(item.id)) { + filterData.tags.push(item); + seen.add(item.id); + } + } + filterData.tagsHasMore = tagPage < tagTotalPages; + repaintColumns(); + } finally { + tagFetching = false; + } + }; + filterData.loadMoreTags = () => { + void fetchNextTagPage(); + }; + void fetchNextTagPage(); + const teardownKebabColumns = mountKebabColumnToggles( + body, + cellCache, + repaintColumns + ); + let unsubOsSettings = null; + if (window.wp?.desktop && typeof window.wp.desktop.subscribeOsSettings === "function") { + let lastHidden = JSON.stringify( + Array.from(getHiddenColumns()).sort() + ); + unsubOsSettings = window.wp.desktop.subscribeOsSettings(() => { + const next = JSON.stringify( + Array.from(getHiddenColumns()).sort() + ); + if (next === lastHidden) { + return; + } + lastHidden = next; + repaintColumns(); + teardownKebabColumns?.refresh(); + }); + } + const onWindowClosed = (e) => { + const detail = e.detail; + if (detail?.windowId !== "desktop-mode-posts") { + return; + } + document.removeEventListener("desktop-mode-window-closed", onWindowClosed); + for (const unsub of broadcastUnsubs) { + try { + unsub(); + } catch { + } + } + broadcastUnsubs.length = 0; + teardownKebabColumns?.dispose(); + unsubOsSettings?.(); + catsTeardown?.(); + catsTeardown = null; + tagsTeardown?.(); + tagsTeardown = null; + if (view.searchDebounce !== null) { + window.clearTimeout(view.searchDebounce); + view.searchDebounce = null; + } + clearCategoryTreeCache(); + }; + document.addEventListener("desktop-mode-window-closed", onWindowClosed); + await refresh(); + const hooks = window.wp?.hooks; + if (hooks && typeof hooks.doAction === "function") { + hooks.doAction(HOOK_ACTION_OPENED, ctx); + } + document.dispatchEvent( + new CustomEvent("desktop-mode-posts-window-opened", { + detail: ctx + }) + ); + } + function buildBulkActionButton(action, ctx) { + const btn = document.createElement("wpd-button"); + btn.setAttribute("variant", action.variant ?? "secondary"); + btn.setAttribute("data-desktop-mode-posts-bulk-action", action.id); + if (action.icon) { + const icon = document.createElement("span"); + icon.className = `dashicons ${action.icon}`; + icon.setAttribute("aria-hidden", "true"); + btn.appendChild(icon); + } + btn.appendChild(document.createTextNode(" " + action.label)); + btn.addEventListener("click", () => { + void runBulkActionFor(action, ctx); + }); + return btn; + } + let activeRunBulkAction = async () => { + }; + async function runBulkActionFor(action, ctx) { + await activeRunBulkAction(action, ctx); + } + function openAdminUrl(url, opts = {}) { + const api = window.wp?.desktop; + if (!api || !api.windowManager || !api.deriveWindowId) { + window.location.href = url; + return; + } + const id = api.deriveWindowId(url); + api.windowManager.open({ + id, + baseId: id, + url, + title: opts.title ?? url, + icon: opts.icon ?? "dashicons-admin-generic" + }); + } + function mapColumnToOrderby(key) { + switch (key) { + case "title": + return "title"; + case "author": + return "author"; + case "date": + return "date"; + case "modified": + return "modified"; + case "comments": + return "comment_count"; + default: + return "date"; + } + } + const registry = window.desktopModeNativeWindows ?? (window.desktopModeNativeWindows = {}); + registry["desktop-mode-posts"] = (body) => { + return renderPostsWindow(body).catch((err) => { + console.error("[posts-window] render failed:", err); + }); + }; + 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) { + 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 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(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 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 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); + } + } + 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 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 = 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(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 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 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 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 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 = getConfig(); + const url = new URL( + `${cfg.restRoot.replace(/\/$/, "")}/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(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(); + if (terms.length <= 1) { + 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); + } + return () => { + if (raf !== null) { + cancelAnimationFrame(raf); + raf = null; + } + if (settleTimer !== null) { + window.clearTimeout(settleTimer); + settleTimer = null; + } + ro.disconnect(); + stage.removeEventListener("wheel", onWheel); + try { + app.destroy(true, { children: true, texture: true }); + } 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(html) { + const tmp = document.createElement("div"); + tmp.innerHTML = html; + 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(url) { + const cfg = getConfig(); + const api = window.wp?.desktop; + const init = { + method: "GET", + credentials: "same-origin", + headers: { + "X-WP-Nonce": cfg.restNonce, + Accept: "application/json" + } + }; + let response; + if (api && typeof api.fetch === "function") { + response = await api.fetch(url, init, { + windowId: "desktop-mode-posts" + }); + } else { + response = await fetch(url, init); + } + 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) { + 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 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(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); + const themeHue = readAdminThemeHue(); + try { + const all = []; + let page = 1; + while (page <= 5) { + const res = await 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 = []; + 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 + }); + } + } + fresh.sort((a, b) => b.count - a.count); + for (const box of fresh) { + const slot = findSpiralSlot(box.width, box.height, placed); + box.tx = slot.x; + box.ty = slot.y; + box.x = slot.x; + box.y = slot.y; + placed.push({ + x: slot.x - box.width / 2, + y: slot.y - box.height / 2, + w: box.width, + h: box.height + }); + } + } + 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) { + if (placed.length === 0) { + return { x: 0, y: 0 }; + } + const padding = SPIRAL_PADDING; + let theta = 0; + const maxIter = 1e4; + for (let i = 0; i < maxIter; i++) { + theta += 0.18; + const r = theta * 5; + const cx = r * Math.cos(theta); + const cy = 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: 0, y: (placed.length + 1) * (h + padding) }; + } + 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 = 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(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 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 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 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 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()); + const placed = []; + allBoxes.sort((a, b) => b.count - a.count); + for (const box of allBoxes) { + const slot = findSpiralSlot(box.width, box.height, placed); + box.tx = slot.x; + box.ty = slot.y; + placed.push({ + x: slot.x - box.width / 2, + y: slot.y - box.height / 2, + w: box.width, + h: box.height + }); + } + fitToView({ animate: true }); + }); + 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 = getConfig(); + const url = new URL( + `${cfg.restRoot.replace(/\/$/, "")}/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(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 { + } + } + buildCloud(); + paintSidebar(); + raf = requestAnimationFrame(tick); + void refreshCountsViaBulk(); + 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); + } + return () => { + if (raf !== null) { + cancelAnimationFrame(raf); + raf = null; + } + if (settleTimer !== null) { + window.clearTimeout(settleTimer); + settleTimer = null; + } + ro.disconnect(); + stage.removeEventListener("wheel", onWheel); + try { + app.destroy(true, { children: true, texture: true }); + } 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(html) { + const tmp = document.createElement("div"); + tmp.innerHTML = html; + 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(url) { + const cfg = getConfig(); + const api = window.wp?.desktop; + const init = { + method: "GET", + credentials: "same-origin", + headers: { + "X-WP-Nonce": cfg.restNonce, + Accept: "application/json" + } + }; + let response; + if (api && typeof api.fetch === "function") { + response = await api.fetch(url, init, { + windowId: "desktop-mode-posts" + }); + } else { + response = await fetch(url, init); + } + 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" })); + exports.renderPostsWindow = renderPostsWindow; + Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" }); + return exports; +}({}); diff --git a/assets/js/posts-window.min.js b/assets/js/posts-window.min.js new file mode 100644 index 00000000..7594abc1 --- /dev/null +++ b/assets/js/posts-window.min.js @@ -0,0 +1 @@ +var desktopModePostsWindow=function(ze){"use strict";const Hn="desktop-mode";function Je(){return window.wp?.i18n}function x(e,n=Hn){return Je()?.__(e,n)??e}function jt(e,...n){const a=Je()?.sprintf;if(a)return a(e,...n);let o=0;return e.replace(/%[sd]/g,()=>String(n[o++]??""))}const On="desktop-mode-posts";function ft(){const e=window.desktopModeWindowConfig,n=e?e[On]:void 0;if(!n)throw new Error("[desktop-mode-posts] config blob is missing — was the window opened without registration? See `desktop_mode_register_window()` in `includes/posts-window/window.php`.");return n}function Dn(e,n){const a=window.wp?.desktop;return a&&typeof a.fetch=="function"?a.fetch(e,n,{windowId:"desktop-mode-posts"}):fetch(e,n)}async function Ft(e,n={}){const a=ft(),o=await Dn(e,{...n,credentials:"same-origin",headers:{"X-WP-Nonce":a.restNonce,Accept:"application/json",...n.body?{"Content-Type":"application/json"}:{},...n.headers??{}}});if(!o.ok){let f=`${o.status} ${o.statusText}`;try{const _=await o.json();_&&typeof _.message=="string"&&(f=_.message)}catch{}throw new Error(f)}return{data:n.expectJson===!1?null:await o.json(),headers:o.headers}}async function Fn(e={}){const n=ft(),a=new URL(n.postsUrl);for(const[_,E]of Object.entries(n.queryArgs??{}))typeof E=="string"&&E!==""&&a.searchParams.set(_,E);e.page&&a.searchParams.set("page",String(e.page)),e.perPage&&a.searchParams.set("per_page",String(e.perPage)),e.search&&a.searchParams.set("search",e.search),e.status?a.searchParams.set("status",e.status):a.searchParams.set("status","any"),e.orderby&&a.searchParams.set("orderby",e.orderby),e.order&&a.searchParams.set("order",e.order);const o=(_,E)=>{const P=Array.isArray(E)?E:[E];for(const r of P)Number.isFinite(r)&&r>0&&a.searchParams.append(`${_}[]`,String(r))};e.author&&o("author",e.author),e.tag&&o("tags",e.tag);const{data:l,headers:f}=await Ft(a.toString(),{method:"GET"});return{items:Array.isArray(l)?l:[],total:parseInt(f.get("X-WP-Total")??"0",10)||0,totalPages:parseInt(f.get("X-WP-TotalPages")??"0",10)||0}}async function $n(e){const n=ft();try{return await Ft(`${n.postsUrl}/${e}`,{method:"DELETE"}),{id:e,ok:!0}}catch(a){return{id:e,ok:!1,error:a instanceof Error?a.message:String(a)}}}function Wn(e){const n=ft(),a=n.editPostUrlBase.includes("?")?"&":"?";return`${n.editPostUrlBase}${a}post=${e}&action=edit`}async function Ze(e,n){const a=ft(),o=new URL(`${a.restRoot.replace(/\/$/,"")}/wp/v2/tags`);o.searchParams.set("per_page","20"),o.searchParams.set("_fields","id,name,slug,count"),o.searchParams.set("orderby","count"),o.searchParams.set("order","desc"),e&&(o.searchParams.set("search",e),o.searchParams.set("orderby","name"),o.searchParams.set("order","asc"));const{data:l}=await Ft(o.toString(),{method:"GET",signal:n});return Array.isArray(l)?l:[]}async function Qe(e){const a=`${ft().restRoot.replace(/\/$/,"")}/wp/v2/tags`;try{const{data:o}=await Ft(a,{method:"POST",body:JSON.stringify({name:e})});return ke("post_tag","created",o.id),o}catch(o){const l=o instanceof Error?o.message:String(o);if(/term[\s_]?exists/i.test(l)){const _=(await Ze(e)).find(E=>E.name.toLowerCase()===e.toLowerCase());if(_)return _}throw o}}async function tn(e,n){const o=`${ft().postsUrl}/${e}`,{data:l}=await Ft(o,{method:"POST",body:JSON.stringify({tags:n})});return l}async function en(e){const n=ft(),a=new URL(`${n.restRoot.replace(/\/$/,"")}/wp/v2/categories`);a.searchParams.set("per_page","100"),a.searchParams.set("_fields","id,name,slug,parent"),a.searchParams.set("orderby","name"),a.searchParams.set("order","asc");const{data:o}=await Ft(a.toString(),{method:"GET",signal:e});return Array.isArray(o)?o:[]}async function Bn(e){const n=ft(),a=new URL(`${n.restRoot.replace(/\/$/,"")}/wp/v2/users`);a.searchParams.set("per_page","100"),a.searchParams.set("who","authors"),a.searchParams.set("_fields","id,name"),a.searchParams.set("orderby","name"),a.searchParams.set("order","asc");try{const{data:o}=await Ft(a.toString(),{method:"GET",signal:e});return Array.isArray(o)?o:[]}catch{return[]}}async function zn(e=1,n=50,a){const o=ft(),l=new URL(`${o.restRoot.replace(/\/$/,"")}/wp/v2/tags`);l.searchParams.set("per_page",String(Math.max(1,n))),l.searchParams.set("page",String(Math.max(1,e))),l.searchParams.set("_fields","id,name,count"),l.searchParams.set("orderby","count"),l.searchParams.set("order","desc");try{const{data:f,headers:_}=await Ft(l.toString(),{method:"GET",signal:a});return{items:Array.isArray(f)?f:[],totalPages:parseInt(_.get("X-WP-TotalPages")??"0",10)||0}}catch{return{items:[],totalPages:0}}}async function nn(e,n=0,a={}){const l=`${ft().restRoot.replace(/\/$/,"")}/wp/v2/categories`,f={name:e,parent:n};a.slug&&(f.slug=a.slug),a.description&&(f.description=a.description);try{const{data:_}=await Ft(l,{method:"POST",body:JSON.stringify(f)});return ke("category","created",_.id),_}catch(_){const E=_ instanceof Error?_.message:String(_);if(/term[\s_]?exists/i.test(E)){const r=(await en()).find(w=>w.name.toLowerCase()===e.toLowerCase()&&w.parent===n);if(r)return r}throw _}}function ke(e,n,a){const o=window.wp?.desktop;o&&typeof o.broadcast=="function"&&o.broadcast("desktop-mode.term.changed",{source:"posts-window",taxonomy:e,action:n,id:a})}async function Pe(e,n){const o=`${ft().postsUrl}/${e}`,{data:l}=await Ft(o,{method:"POST",body:JSON.stringify({categories:n})});return l}async function on(e,n={}){const a=ft(),o=new URL(`${a.restRoot.replace(/\/$/,"")}/wp/v2/${e}`);o.searchParams.set("per_page",String(n.perPage??50)),o.searchParams.set("page",String(n.page??1)),o.searchParams.set("_fields","id,name,slug,parent,count,description,desktop_mode_count,desktop_mode_is_default"),o.searchParams.set("orderby",n.orderby??"name"),o.searchParams.set("order",n.order??"asc"),n.search&&o.searchParams.set("search",n.search),typeof n.parent=="number"&&n.parent>=0&&o.searchParams.set("parent",String(n.parent));const{data:l,headers:f}=await Ft(o.toString(),{method:"GET"});return{items:Array.isArray(l)?l.map(E=>{const P=E.desktop_mode_count,r=E.desktop_mode_is_default===!0;return{id:E.id??0,name:E.name??"",slug:E.slug??"",parent:E.parent??0,count:typeof P=="number"?P:E.count??0,description:E.description??"",isDefault:r}}):[],total:parseInt(f.get("X-WP-Total")??"0",10)||0,totalPages:parseInt(f.get("X-WP-TotalPages")??"0",10)||0}}async function he(e,n,a){const l=`${ft().restRoot.replace(/\/$/,"")}/wp/v2/${e}/${n}`,{data:f}=await Ft(l,{method:"POST",body:JSON.stringify(a)});return ke(e==="categories"?"category":"post_tag","updated",n),{id:f.id??n,name:f.name??"",slug:f.slug??"",parent:f.parent??0,count:f.count??0,description:f.description??"",isDefault:f.isDefault??!1}}async function Ue(e,n){const a=ft(),o=new URL(`${a.restRoot.replace(/\/$/,"")}/wp/v2/${e}/${n}`);o.searchParams.set("force","true"),await Ft(o.toString(),{method:"DELETE"}),ke(e==="categories"?"category":"post_tag","deleted",n)}const Un="[data-desktop-mode-posts-root]",an="[data-desktop-mode-posts-status]",Gn="[data-desktop-mode-posts-search]",Xn="[data-desktop-mode-posts-refresh]",qn="[data-desktop-mode-posts-new]",jn="[data-desktop-mode-posts-table]",Yn="[data-desktop-mode-posts-bulk]",Kn="[data-desktop-mode-posts-count]",Vn="[data-desktop-mode-posts-page-indicator]",sn="[data-desktop-mode-posts-prev]",cn="[data-desktop-mode-posts-next]",Jn="[data-desktop-mode-posts-per-page]",Zn="[data-desktop-mode-posts-toolbar-extras]",Qn="[data-desktop-mode-posts-bulk-actions]",to="desktop_mode.postsWindow.columns",eo="desktop_mode.postsWindow.statusSegments",no="desktop_mode.postsWindow.bulkActions",oo="desktop_mode.postsWindow.toolbarTrailing",ao="desktop_mode.postsWindow.opened",so="desktop_mode.postsWindow.dataLoaded",io=250,co={publish:x("Published"),future:x("Scheduled"),draft:x("Draft"),pending:x("Pending"),private:x("Private"),trash:x("Trash")};function ro(e){switch(e){case"publish":return{bg:"#e6f4ea",fg:"#1d6f42"};case"draft":return{bg:"#fdecea",fg:"#a02622"};case"pending":return{bg:"#fef7e0",fg:"#8a6d00"};case"private":return{bg:"#e8f0fe",fg:"#1a52a8"};case"future":return{bg:"#ede7f6",fg:"#5b3aa0"};case"trash":return{bg:"#f1f1f2",fg:"#50575e"};default:return{bg:"#f1f1f2",fg:"#50575e"}}}function lo(e){const n=document.createElement("textarea");return n.innerHTML=e,n.value}function uo(e){const n=e._embedded?.author?.[0];if(n){const a=n.avatar_urls??{};return{id:n.id,name:n.name,avatar:a[48]??a[96]??a[24]}}return{id:e.author,name:x("Unknown")}}function rn(e,n){const a=e._embedded?.["wp:term"]??[];for(const o of a)if(o.length!==0&&o[0].taxonomy===n)return o.map(l=>({id:l.id,name:l.name}));return[]}function po(e){const n=e._embedded?.["wp:featuredmedia"]?.[0];if(!n)return null;const a=n.media_details?.sizes??{};return{url:a.thumbnail?.source_url??a.medium?.source_url??n.source_url,alt:n.alt_text??""}}function ho(e,n){return`${e}|${n}`}function fe(e,n,a,o){const l=ho(n,a),f=e.get(l);if(f)return f;const _=o();return e.set(l,_),_}const Ge=new Set(["title"]);function me(){try{const e=window.wp?.desktop;if(e&&typeof e.getOsSettings=="function"){const n=e.getOsSettings();if(Array.isArray(n.nativePostsHiddenColumns))return new Set(n.nativePostsHiddenColumns)}}catch{}return new Set}const dn={authors:[],tags:[]};function ln(e,n=dn){const a=fo(e,n),o=window.wp?.hooks;return o&&typeof o.applyFilters=="function"?o.applyFilters(to,a):a}function un(e,n=dn){const a=ln(e,n),o=me();return o.size===0?a:a.filter(l=>Ge.has(l.key)||!o.has(l.key))}function fo(e,n){return[{key:"title",label:x("Title"),sortable:!0,sticky:!0,render:(a,o)=>fe(e,o.id,"title",()=>Co(o))},{key:"author",label:x("Author"),sortable:!0,width:"180px",filterRender:(a,o)=>pn(a,o,n.authors,{label:x("All authors"),ariaLabel:x("Filter by author")}),render:(a,o)=>fe(e,o.id,"author",()=>vo(o))},{key:"categories",label:x("Categories"),width:"260px",render:(a,o)=>fe(e,o.id,"categories",()=>To(o))},{key:"tags",label:x("Tags"),minWidth:"360px",filterRender:(a,o)=>pn(a,o,n.tags.map(l=>({id:l.id,name:l.name})),{label:x("All tags"),ariaLabel:x("Filter by tag"),dataKey:"tags",hasMore:!!n.tagsHasMore,onLoadMore:n.loadMoreTags}),render:(a,o)=>fe(e,o.id,"tags",()=>_o(o))},{key:"date",label:x("Date"),sortable:!0,width:"170px",sortValue:a=>Date.parse(a.date_gmt+"Z")||0,render:(a,o)=>fe(e,o.id,"date",()=>ko(o))}]}function pn(e,n,a,o){const l="wpdPostsFilterMounted",f=e,_=a.map(r=>({value:String(r.id),label:r.name})),E=_.map(r=>`${r.value}:${r.label}`).join("|");if(f[l]){const r=f[l];r.listSig!==E&&(r.picker.items=_,r.listSig=E),r.picker.getAttribute("value")!==n.value&&r.picker.setAttribute("value",n.value),r.picker.hasMore=!!o.hasMore;return}const P=document.createElement("wpd-multiselect");if(P.setAttribute("placeholder",o.label),P.setAttribute("aria-label",o.ariaLabel),P.setAttribute("data-noclick",""),P.setAttribute("value",n.value),o.dataKey&&P.setAttribute("data-key",o.dataKey),e.appendChild(P),P.items=_,P.hasMore=!!o.hasMore,P.addEventListener("wpd-pick",r=>{const k=r.detail?.value??"";n.value=k,n.setValue(k)}),o.onLoadMore){const r=o.onLoadMore;P.addEventListener("wpd-multiselect-load-more",()=>{P.loadingMore=!0,r()})}f[l]={picker:P,listSig:E}}function mo(e,n,a){const l=e.closest(".desktop-mode-window")?.querySelector(".desktop-mode-window__menu-panel");if(!l)return null;const f="desktop-mode-posts-window__menu-columns",_="desktop-mode-posts-window__menu-column-item",E="desktop-mode-posts-column:";l.querySelectorAll(`.${f}, .${_}`).forEach(G=>G.remove());const r=ln(n).filter(G=>!Ge.has(G.key));if(r.length===0)return null;const w=document.createElement("div");w.className=f,w.setAttribute("role","presentation"),w.textContent=x("Show columns"),l.appendChild(w);const k=new Map;for(const G of r){const at=document.createElement("wpd-menu-item");at.setAttribute("role","menuitemcheckbox"),at.setAttribute("value",E+G.key),at.classList.add("desktop-mode-window__menu-item"),at.classList.add(_),at.textContent=G.label||G.key,l.appendChild(at),k.set(G.key,at)}const v=()=>{const G=me();for(const[at,V]of k)G.has(at)?V.removeAttribute("checked"):V.setAttribute("checked","")};v();const A=G=>{const V=G.detail?.value;if(typeof V!="string"||!V.startsWith(E))return;const Pt=V.slice(E.length);if(!k.has(Pt)||Ge.has(Pt))return;const Et=me();Et.has(Pt)?Et.delete(Pt):Et.add(Pt);const it=Array.from(Et).sort(),lt=window.wp?.desktop;lt&&typeof lt.updateOsSettings=="function"&<.updateOsSettings({nativePostsHiddenColumns:it},{windowId:"desktop-mode-posts"}),v(),a()};return l.addEventListener("wpd-menu-item-click",A),{refresh:v,dispose:()=>{l.removeEventListener("wpd-menu-item-click",A),w.remove();for(const G of k.values())G.remove();k.clear()}}}function go(){return[{value:"",label:x("All")},{value:"publish",label:x("Published")},{value:"draft",label:x("Drafts")},{value:"pending",label:x("Pending")},{value:"future",label:x("Scheduled")},{value:"trash",label:x("Trash")}]}function wo(){return[{id:"trash",label:x("Move to trash"),icon:"dashicons-trash",variant:"danger",confirm:x("Move %d post(s) to the trash?"),run:async(e,n)=>{const a=n.table.data??[],o=e.filter(P=>{const r=a.find(w=>w.id===P);return r&&r.status!=="trash"});if(o.length===0)return;const l=await Promise.all(o.map(P=>$n(P))),f=l.filter(P=>!P.ok);f.length>0&&console.error("[posts-window] some trashes failed",f);const _=l.filter(P=>P.ok).map(P=>P.id),E=window.wp?.desktop;E&&typeof E.broadcast=="function"&&E.broadcast("desktop-mode.post.changed",{source:"posts-window",action:"trashed",ids:_})}}]}function yo(){const e=window.wp?.hooks,n=wo();if(!e||typeof e.applyFilters!="function")return n;try{const a=e.applyFilters(no,n);return Array.isArray(a)?a:n}catch(a){return console.error("[posts-window] bulk-actions filter threw; falling back to defaults:",a),n}}function xo(){const e=window.wp?.hooks,n=go();if(!e||typeof e.applyFilters!="function")return n;try{const a=e.applyFilters(eo,n);return Array.isArray(a)&&a.length>0?a:n}catch(a){return console.error("[posts-window] status-segments filter threw; falling back to defaults:",a),n}}function bo(e){const n=window.wp?.hooks;if(!n||typeof n.applyFilters!="function")return[];try{const a=n.applyFilters(oo,[],e);return Array.isArray(a)?a.filter(o=>o instanceof HTMLElement):[]}catch(a){return console.error("[posts-window] toolbar-trailing filter threw; ignoring:",a),[]}}function Co(e){const n=document.createElement("span");n.style.cssText="display:flex;flex-direction:column;gap:4px;min-width:0;";const a=document.createElement("span");a.style.cssText="display:flex;align-items:center;gap:8px;min-width:0;";const o=document.createElement("a");o.href=Wn(e.id),o.setAttribute("data-noclick","");const l=lo(e.title.rendered)||x("(no title)");if(o.textContent=l,o.title=l,o.style.cssText="font-weight:600;color:inherit;text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:340px;",o.addEventListener("mouseenter",()=>{o.style.textDecoration="underline"}),o.addEventListener("mouseleave",()=>{o.style.textDecoration="none"}),o.addEventListener("click",f=>{f.preventDefault(),f.stopPropagation(),wn(o.href,{title:l,icon:"dashicons-admin-post"})}),a.appendChild(o),e.status&&e.status!=="publish"){const f=document.createElement("span"),_=ro(e.status);f.textContent=co[e.status]??e.status,f.style.cssText=["display:inline-flex","align-items:center","padding:2px 8px","border-radius:10px","font-size:11px","font-weight:600","text-transform:uppercase","letter-spacing:0.04em",`background:${_.bg}`,`color:${_.fg}`,"white-space:nowrap","flex-shrink:0"].join(";"),a.appendChild(f)}return n.appendChild(a),n}function vo(e){const n=uo(e),a=document.createElement("span");if(a.style.cssText="display:inline-flex;align-items:center;gap:8px;min-width:0;",n.avatar){const l=document.createElement("img");l.src=n.avatar,l.alt="",l.loading="eager",l.decoding="sync",l.style.cssText="width:24px;height:24px;border-radius:50%;flex-shrink:0;",a.appendChild(l)}const o=document.createElement("span");return o.textContent=n.name,o.style.cssText="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;",a.appendChild(o),a}function _o(e){const n=document.createElement("span");n.style.cssText="display:inline-flex;align-items:center;width:100%;min-width:0;";const a=document.createElement("wpd-tag-input");a.setAttribute("creatable",""),a.setAttribute("removable",""),a.setAttribute("min-query","0"),a.setAttribute("placeholder",x("Add tag…")),a.setAttribute("add-label",x("Tag")),a.setAttribute("data-noclick","");const o=rn(e,"post_tag").map(_=>({id:_.id,label:_.name}));a.value=o;const l={tags:o.slice(),suggestAbort:null,suggestDebounce:null,lastQuery:""},f=_=>{l.tags=_.slice(),a.value=_};return a.addEventListener("wpd-tag-suggest",_=>{const P=_.detail?.query??"";l.lastQuery=P,l.suggestDebounce!==null&&(window.clearTimeout(l.suggestDebounce),l.suggestDebounce=null),l.suggestDebounce=window.setTimeout(async()=>{l.suggestDebounce=null,l.suggestAbort&&l.suggestAbort.abort();const r=new AbortController;l.suggestAbort=r;try{const w=await Ze(P,r.signal);if(l.lastQuery!==P)return;const k=new Set(l.tags.map(v=>v.id));a.suggestions=w.filter(v=>!k.has(v.id)).map(v=>({id:v.id,label:v.name}))}catch(w){if(w?.name==="AbortError")return;a.suggestions=[],console.warn("[posts-window] tag search failed",w)}finally{a.suggestionsLoading=!1}},200)}),a.addEventListener("wpd-tag-add",async _=>{const E=_.detail;if(!E?.tag)return;const P={id:E.tag.id,label:E.tag.label,pending:!0},r=[...l.tags,P];f(r);try{let w=null;E.isNew||typeof E.tag.id!="number"?w=await Qe(E.tag.label):w={id:Number(E.tag.id),name:E.tag.label,slug:""};const k=[...l.tags.filter(A=>!A.pending).map(A=>Number(A.id)),w.id];await tn(e.id,k),f(l.tags.map(A=>A.label.toLowerCase()===E.tag.label.toLowerCase()?{id:w.id,label:w.name}:A));const v=window.wp?.desktop;v&&typeof v.broadcast=="function"&&v.broadcast("desktop-mode.post.changed",{source:"posts-window",action:"tagged",ids:[e.id]})}catch(w){f(l.tags.filter(k=>k.label.toLowerCase()!==E.tag.label.toLowerCase())),Zt(jt(x('Couldn’t add tag "%s".'),E.tag.label),w)}}),a.addEventListener("wpd-tag-remove",async _=>{const E=_.detail;if(!E?.tag)return;const P=E.tag,r=l.tags.slice();f(l.tags.map(w=>w.label===P.label?{...w,pending:!0}:w));try{const w=r.filter(v=>v.label!==P.label).map(v=>Number(v.id)).filter(v=>Number.isFinite(v));await tn(e.id,w),f(r.filter(v=>v.label!==P.label));const k=window.wp?.desktop;k&&typeof k.broadcast=="function"&&k.broadcast("desktop-mode.post.changed",{source:"posts-window",action:"untagged",ids:[e.id]})}catch(w){f(r),Zt(jt(x('Couldn’t remove tag "%s".'),P.label),w)}}),n.appendChild(a),n}function Zt(e,n){const a=n instanceof Error?n.message:String(n),o=window.wp?.desktop;if(o&&typeof o.showToast=="function"){o.showToast({message:`${e} ${a}`.trim(),duration:6e3});return}console.error(e,n)}function To(e){const n=document.createElement("span");n.className="wpd-cat-cell-dropzone",n.style.cssText="display:inline-flex;align-items:center;width:100%;min-width:0;border-radius:6px;transition:background-color 0.12s ease, box-shadow 0.12s ease;";const a=document.createElement("wpd-category-picker");a.setAttribute("placeholder",x("Search categories…")),a.setAttribute("add-label",x("Categorize")),a.setAttribute("data-noclick",""),Xe.add(a),a.value=e.categories??[];const o=rn(e,"category").map(r=>({id:r.id,name:r.name,parent:0}));a.items=o;const l={categoryIds:(e.categories??[]).slice()},f=r=>{l.categoryIds=r.slice(),a.value=r};hn().then(r=>{a.isConnected&&(a.items=r)}).catch(r=>{console.warn("[posts-window] category tree fetch failed",r)}),a.addEventListener("wpd-categories-open",()=>{So(a)}),a.addEventListener("wpd-categories-create",async r=>{const w=r.detail,k=w?.parent??0;if(!w||!w.name){a.failCreating(k);return}try{const v=await nn(w.name,k);ne=null;const A=[...a.items,{id:v.id,name:v.name,parent:v.parent}];a.items=A;const G=[...l.categoryIds,v.id];f(G),a.endCreating(k);try{await Pe(e.id,G);const at=window.wp?.desktop;at&&typeof at.broadcast=="function"&&at.broadcast("desktop-mode.post.changed",{source:"posts-window",action:"categorized",ids:[e.id]})}catch(at){f(l.categoryIds.filter(V=>V!==v.id)),Zt(x("Couldn’t assign new category."),at)}}catch(v){a.failCreating(k,v instanceof Error?v.message:String(v)),Zt(x("Couldn’t create category."),v)}}),a.addEventListener("wpd-categories-change",async r=>{const w=r.detail;if(!w||!Array.isArray(w.value))return;const k=l.categoryIds.slice(),v=w.value.slice();f(v);try{await Pe(e.id,v);const A=window.wp?.desktop;A&&typeof A.broadcast=="function"&&A.broadcast("desktop-mode.post.changed",{source:"posts-window",action:"categorized",ids:[e.id]})}catch(A){f(k),Zt(x("Couldn’t update categories."),A)}}),a.addEventListener("wpd-categories-delete",async r=>{const w=r.detail;if(!(!w||typeof w.id!="number"||!window.confirm(jt(x('Delete the category "%s"? Posts assigned only to it will fall back to Uncategorized.'),w.name))))try{if(await Ue("categories",w.id),l.categoryIds.includes(w.id)){const v=l.categoryIds.filter(A=>A!==w.id);f(v);try{await Pe(e.id,v)}catch(A){Zt(x("Couldn’t update post categories after delete."),A)}}}catch(v){Zt(x("Couldn’t delete category."),v)}}),a.addEventListener("wpd-chain-segment-dragstart",r=>{const w=r.detail;if(!w||!w.dragEvent||!w.dragEvent.dataTransfer)return;const k=[];for(const A of w.segments)typeof A.id=="number"&&k.push(A.id);if(k.length===0)return;const v=w.dragEvent.dataTransfer;v.setData("application/x-desktop-mode-categories",JSON.stringify({ids:k,source:"posts-window",sourcePostId:e.id})),v.setData("text/plain",k.join(",")),v.effectAllowed="copy"});let _=0;const E=r=>{r?(n.style.backgroundColor="color-mix(in srgb, var(--wp-admin-theme-color, #2271b1) 12%, transparent)",n.style.boxShadow="inset 0 0 0 2px var(--wp-admin-theme-color, #2271b1)"):(n.style.backgroundColor="",n.style.boxShadow="")},P=r=>{const w=r.dataTransfer?.types;return w?Array.from(w).includes("application/x-desktop-mode-categories"):!1};return n.addEventListener("dragenter",r=>{P(r)&&(r.preventDefault(),_++,E(!0))}),n.addEventListener("dragover",r=>{P(r)&&(r.preventDefault(),r.dataTransfer&&(r.dataTransfer.dropEffect="copy"))}),n.addEventListener("dragleave",()=>{_>0&&_--,_===0&&E(!1)}),n.addEventListener("drop",async r=>{if(_=0,E(!1),!P(r))return;r.preventDefault();const w=r.dataTransfer?.getData("application/x-desktop-mode-categories");if(!w)return;let k;try{k=JSON.parse(w)}catch{return}const v=k;if(!v||!Array.isArray(v.ids))return;const A=[];for(const V of v.ids)typeof V=="number"&&Number.isFinite(V)&&A.push(V);if(A.length===0||v.sourcePostId===e.id&&A.every(V=>l.categoryIds.includes(V)))return;const G=Array.from(new Set([...l.categoryIds,...A]));if(G.length===l.categoryIds.length)return;const at=l.categoryIds.slice();f(G);try{await Pe(e.id,G);const V=window.wp?.desktop;V&&typeof V.broadcast=="function"&&V.broadcast("desktop-mode.post.changed",{source:"posts-window",action:"categorized",ids:[e.id]})}catch(V){f(at),Zt(x("Couldn’t add category."),V)}}),n.appendChild(a),n}let ne=null;function hn(){return ne||(ne=en().then(e=>e.map(n=>({id:n.id,name:n.name,parent:n.parent})))),ne}function fn(){ne=null}const Xe=new Set;function Eo(){hn().then(e=>{for(const n of Xe)n.isConnected?n.items=e:Xe.delete(n)}).catch(()=>{})}async function So(e){if(ne)try{e.items=await ne}catch{}}function ko(e){const n=document.createElement("span");n.style.cssText="display:flex;flex-direction:column;line-height:1.2;";const a=document.createElement("wpd-relative-time");if(a.setAttribute("datetime",e.date),n.appendChild(a),e.modified_gmt&&e.modified_gmt!==e.date_gmt){const o=document.createElement("span");o.textContent=x("modified"),o.style.cssText="font-size:11px;color:#646970;",n.appendChild(o)}return n}function Po(e){const n=document.createElement("div");n.style.cssText="display:flex;gap:16px;padding:12px 16px;background:#fafafa;align-items:flex-start;";const a=po(e);if(a){const E=document.createElement("img");E.src=a.url,E.alt=a.alt,E.loading="lazy",E.style.cssText="width:96px;height:96px;border-radius:6px;object-fit:cover;flex-shrink:0;",n.appendChild(E)}const o=document.createElement("div");o.style.cssText="flex:1;min-width:0;display:flex;flex-direction:column;gap:6px;";const l=document.createElement("div");l.style.cssText="font-size:13px;color:#646970;text-transform:uppercase;letter-spacing:0.04em;",l.textContent=x("Excerpt"),o.appendChild(l);const f=document.createElement("div");f.style.cssText="color:#1d2327;line-height:1.5;";const _=e.excerpt?.rendered??"";if(_){const E=_.replace(/<[^>]+>/g,"").trim();f.textContent=E||x("(no excerpt)")}else f.textContent=x("(no excerpt)"),f.style.color="#a7aaad";return o.appendChild(f),n.appendChild(o),n}async function mn(e){const n=e.querySelector(Un),a=e.querySelector(jn);if(!n||!a)return;const o=e.querySelector("[data-desktop-mode-posts-cats-host]"),l=e.querySelector("[data-desktop-mode-posts-tags-host]");let f=null,_=null;const E=e.querySelector(".desktop-mode-posts__tabs");E&&E.addEventListener("wpd-tab-change",M=>{const O=M.detail?.value;O==="categories"&&o&&!f&&Promise.resolve().then(()=>Uo).then(async({mountCategoriesMindmap:nt})=>{f=await nt(o)}),O==="tags"&&l&&!_&&Promise.resolve().then(()=>oa).then(async({mountTagsCloud:nt})=>{_=await nt(l)})});const P=ft(),r={page:1,perPage:Math.max(1,P.defaultPerPage||20),search:"",status:"",orderby:"date",order:"desc",author:[],tag:[],searchDebounce:null},w=new Map,k={authors:[],tags:[]};a.columns=un(w,k),a.getRowId=M=>M.id,a.subTable=M=>Po(M),a.sort={key:"date",direction:"desc"};let v=0,A=0,G=0;const at=n.querySelector(Jn);at&&(at.value=String(r.perPage));const V=n.querySelector(Vn),Pt=n.querySelector(sn),Et=n.querySelector(cn),it=n.querySelector(Yn),lt=n.querySelector(Kn),bt=n.querySelector(Qn),wt=n.querySelector(Zn),Ct=n.querySelector(an),q=xo();if(Ct){Ct.replaceChildren();for(const M of q){const N=document.createElement("wpd-segment");N.setAttribute("value",M.value),N.textContent=M.label,Ct.appendChild(N)}Ct.setAttribute("value",r.status)}const Lt=()=>{V&&(A===0?V.textContent=x("No posts"):V.textContent=jt(x("Page %1$d of %2$d · %3$d posts"),r.page,Math.max(v,1),A)),Pt&&Pt.toggleAttribute("disabled",r.page<=1),Et&&Et.toggleAttribute("disabled",r.page>=v)},It=()=>{if(!it||!lt)return;const M=Array.from(a.selection??[]);if(M.length===0){it.hidden=!0;return}it.hidden=!1,lt.textContent=jt(x("%d selected"),M.length)},I=()=>({page:r.page,perPage:r.perPage,search:r.search||void 0,status:r.status||void 0,orderby:r.orderby,order:r.order,author:r.author.length>0?r.author:void 0,tag:r.tag.length>0?r.tag:void 0}),mt={body:e,table:a,refresh:()=>rt(),getSelectedIds:()=>Array.from(a.selection??[]).map(M=>Number(M)),getSelectedRows:()=>{const M=new Set(mt.getSelectedIds());return(a.data??[]).filter(N=>M.has(N.id))},getCurrentParams:()=>I()},rt=async()=>{const M=++G;a.toggleAttribute("loading",!0);try{const N=await Fn(I());if(M!==G)return;if(N.items.length===0&&r.page>1&&N.totalPages>0&&r.page>N.totalPages){r.page=1,await rt();return}w.clear(),a.data=N.items,A=N.total,v=N.totalPages,Lt();const O=window.wp?.hooks;O&&typeof O.doAction=="function"&&O.doAction(so,{items:N.items,total:N.total,totalPages:N.totalPages,page:r.page}),document.dispatchEvent(new CustomEvent("desktop-mode-posts-window-data-loaded",{detail:{items:N.items,total:N.total,totalPages:N.totalPages,page:r.page}}))}catch(N){if(M!==G)return;console.error("[posts-window] list failed",N),a.data=[],A=0,v=0,Lt()}finally{M===G&&(a.toggleAttribute("loading",!1),It())}},Rt=()=>{r.page!==1&&(r.page=1)};n.querySelector(an)?.addEventListener("wpd-pick",M=>{const N=M.detail?.value??"";r.status=N,Rt(),rt()}),n.querySelector(Gn)?.addEventListener("wpd-input-change",M=>{const N=M.detail?.value??"";r.search=N,r.searchDebounce!==null&&window.clearTimeout(r.searchDebounce),r.searchDebounce=window.setTimeout(()=>{Rt(),rt()},io)}),e.addEventListener("click",M=>{const N=M.target;if(N){if(N.closest(Xn)){rt();return}if(N.closest(qn)){wn(P.newPostUrl,{title:x("Add New Post"),icon:"dashicons-admin-post"});return}if(N.closest(sn)){r.page>1&&(r.page-=1,rt());return}N.closest(cn)&&r.page{const M=parseInt(at.value,10);!Number.isFinite(M)||M<1||(r.perPage=M,Rt(),rt())}),a.addEventListener("wpd-table-selection-change",()=>{It()}),a.addEventListener("wpd-table-sort-change",M=>{const N=M.detail;!N||!N.sort?(r.orderby="date",r.order="desc"):(r.orderby=No(N.sort.key),r.order=N.sort.direction),rt()});const dt=M=>M.split(",").map(N=>parseInt(N.trim(),10)).filter(N=>Number.isFinite(N)&&N>0),$t=(M,N)=>M.length===N.length&&M.every((O,nt)=>O===N[nt]);a.addEventListener("wpd-table-filter-change",M=>{const O=M.detail?.filters??{},nt=dt(O.author??""),kt=dt(O.tags??"");(!$t(nt,r.author)||!$t(kt,r.tag))&&(r.author=nt,r.tag=kt,r.page=1,rt())}),gn=async(M,N)=>{const O=N.getSelectedIds();if(O.length!==0&&!(M.confirm&&!window.confirm(jt(M.confirm,O.length)))){try{if(await M.run(O,N)===!1)return}catch(nt){console.error(`[posts-window] bulk action "${M.id}" failed`,nt)}a.clearSelection(),await rt()}};const Wt=[];if(window.wp?.desktop&&typeof window.wp.desktop.subscribe=="function"){const M=O=>{O?.source!=="posts-window"&&rt()};Wt.push(window.wp.desktop.subscribe("desktop-mode.post.changed",M));const N=O=>{O?.taxonomy==="category"&&(fn(),Eo())};Wt.push(window.wp.desktop.subscribe("desktop-mode.term.changed",N))}const Ht=()=>{w.clear(),a.columns=un(w,k)};Bn().then(M=>{k.authors=M,Ht()});let j=0,gt=1,Ot=!1;const Bt=50,Mt=async()=>{if(!(Ot||j>=gt)){Ot=!0;try{const M=j+1,N=await zn(M,Bt);j=M,gt=Math.max(gt,N.totalPages||M);const O=new Set(k.tags.map(nt=>nt.id));for(const nt of N.items)O.has(nt.id)||(k.tags.push(nt),O.add(nt.id));k.tagsHasMore=j{Mt()},Mt();const Tt=mo(e,w,Ht);let At=null;if(window.wp?.desktop&&typeof window.wp.desktop.subscribeOsSettings=="function"){let M=JSON.stringify(Array.from(me()).sort());At=window.wp.desktop.subscribeOsSettings(()=>{const N=JSON.stringify(Array.from(me()).sort());N!==M&&(M=N,Ht(),Tt?.refresh())})}const yt=M=>{if(M.detail?.windowId==="desktop-mode-posts"){document.removeEventListener("desktop-mode-window-closed",yt);for(const O of Wt)try{O()}catch{}Wt.length=0,Tt?.dispose(),At?.(),f?.(),f=null,_?.(),_=null,r.searchDebounce!==null&&(window.clearTimeout(r.searchDebounce),r.searchDebounce=null),fn()}};document.addEventListener("desktop-mode-window-closed",yt),await rt();const xt=window.wp?.hooks;xt&&typeof xt.doAction=="function"&&xt.doAction(ao,mt),document.dispatchEvent(new CustomEvent("desktop-mode-posts-window-opened",{detail:mt}))}function Mo(e,n){const a=document.createElement("wpd-button");if(a.setAttribute("variant",e.variant??"secondary"),a.setAttribute("data-desktop-mode-posts-bulk-action",e.id),e.icon){const o=document.createElement("span");o.className=`dashicons ${e.icon}`,o.setAttribute("aria-hidden","true"),a.appendChild(o)}return a.appendChild(document.createTextNode(" "+e.label)),a.addEventListener("click",()=>{Ao(e,n)}),a}let gn=async()=>{};async function Ao(e,n){await gn(e,n)}function wn(e,n={}){const a=window.wp?.desktop;if(!a||!a.windowManager||!a.deriveWindowId){window.location.href=e;return}const o=a.deriveWindowId(e);a.windowManager.open({id:o,baseId:o,url:e,title:n.title??e,icon:n.icon??"dashicons-admin-generic"})}function No(e){switch(e){case"title":return"title";case"author":return"author";case"date":return"date";case"modified":return"modified";case"comments":return"comment_count";default:return"date"}}const Lo=window.desktopModeNativeWindows??(window.desktopModeNativeWindows={});Lo["desktop-mode-posts"]=e=>mn(e).catch(n=>{console.error("[posts-window] render failed:",n)});const Io=5500,yn=.05,Ro=130,xn=22,Ho=48,Oo=10,ge=170;async function Do(e){const n=window.wp?.desktop;if(!n||typeof n.loadModules!="function")return e.textContent=x("Mindmap unavailable: shell modules API missing."),()=>{};try{await n.loadModules(["pixijs"])}catch{return e.textContent=x("Mindmap unavailable."),()=>{}}const a=window.PIXI;if(!a)return e.textContent=x("Mindmap unavailable."),()=>{};const o=a;e.replaceChildren(),e.classList.add("wpd-mindmap");const l=document.createElement("div");l.className="wpd-mindmap__toolbar";const f=document.createElement("button");f.type="button",f.className="wpd-mindmap__btn wpd-mindmap__btn--primary",f.innerHTML=''+x("Add root category");const _=document.createElement("button");_.type="button",_.className="wpd-mindmap__btn",_.innerHTML=''+x("Recenter");const E=document.createElement("span");E.className="wpd-mindmap__hint",E.textContent=x("Click a node to focus + edit · drag onto another to reparent · wheel to zoom"),l.appendChild(f),l.appendChild(_),l.appendChild(E),e.appendChild(l);const P=document.createElement("div");P.className="wpd-mindmap__layout",e.appendChild(P);const r=document.createElement("div");r.className="wpd-mindmap__stage",r.classList.add("is-loading"),P.appendChild(r);const w=document.createElement("aside");w.className="wpd-mindmap__sidebar",P.appendChild(w);const k=new o.Application;await k.init({resizeTo:r,backgroundAlpha:0,antialias:!0,autoDensity:!0,resolution:Math.min(window.devicePixelRatio||1,2)}),r.appendChild(k.canvas),k.canvas.classList.add("wpd-mindmap__canvas");const v=new o.Container;v.x=r.clientWidth/2,v.y=r.clientHeight/2,k.stage.addChild(v);const A=new o.Container,G=new o.Container,at=new o.Container,V=new o.Container,Pt=new o.Container,Et=new o.Container;v.addChild(A),v.addChild(at),v.addChild(V),v.addChild(G),v.addChild(Pt),v.addChild(Et);const it=new o.Graphics;A.addChild(it);const lt=new o.Graphics;at.addChild(lt);const bt=4,wt=new o.Container;wt.eventMode="passive",wt.visible=!1,V.addChild(wt);const Ct=new o.Graphics,q=new o.Graphics,Lt=new o.Text({text:"1 / 1",style:{fill:5265246,fontSize:14,fontFamily:'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',fontWeight:"600"},resolution:bt});Lt.anchor.set(.5),Ct.eventMode="static",Ct.cursor="pointer",q.eventMode="static",q.cursor="pointer",Ct.hitArea=new o.Circle(0,0,16),q.hitArea=new o.Circle(0,0,16),wt.addChild(Ct),wt.addChild(Lt),wt.addChild(q);const It=t=>{t.stopPropagation?.(),Ht=performance.now()};Ct.on("pointerdown",It),q.on("pointerdown",It),Ct.on("pointertap",t=>{It(t),Jt=performance.now(),!(dt<=1)&&(dt--,H())}),q.on("pointertap",t=>{It(t),Jt=performance.now(),!(dt>=$t)&&(dt++,H())});const I=new Map,mt=new Map,rt=new Map,Rt=new Map;let W=null,dt=1,$t=1,Wt=0,Ht=0,j=null,gt=null,Ot=!1,Bt=null,Mt=0,Tt=null,At=performance.now(),yt=v.scale.x,xt=v.x,M=v.y,N=null;const O=new Map;let nt=null,kt=null;const ie=Fo(),Yt=t=>Wo((ie+t*47)%360,55,52);let st=[];try{const t=[];let c=1;for(;c<=5;){const s=await on("categories",{page:c,perPage:100});if(t.push(...s.items),c>=s.totalPages)break;c++}st=t}catch(t){Cn(x("Couldn’t load categories:"),t)}const Qt=(t,c)=>Cn(t,c);function te(t){return t.isDefault?!0:t.id===1||t.slug==="uncategorized"||t.name.toLowerCase()==="uncategorized"}function zt(){const t=new Map;for(const C of st){const L=t.get(C.parent)??[];L.push(C),t.set(C.parent,L)}const c=t.get(0)??[],s=c.filter(C=>!te(C)),d=c.find(te),y=(C,L,R,Z,X)=>{const ct=s.length>1?110+s.length*28:0,Y=d?Math.max(ct,140):ct,ht=L===0?Y:Y+160+(L-1)*150,pt=ht*Math.cos(Z),ot=ht*Math.sin(Z),K=bn(C.count,st),Q=L===0?Yt(R):I.get(C.parent)?.color??Yt(R);let tt=I.get(C.id);if(tt)tt.parent=C.parent,tt.name=C.name,tt.description=C.description,tt.count=C.count,tt.depth=L,tt.color=Q,tt.radius=K,tt.tx=pt,tt.ty=ot,tt.pinned=L===0;else{const U=new o.Graphics;U.eventMode="static",U.cursor="pointer",tt={id:C.id,parent:C.parent,name:C.name,description:C.description,count:C.count,x:pt,y:ot,tx:pt,ty:ot,radius:K,depth:L,color:Q,gfx:U,pinned:L===0},G.addChild(U),U.on("pointerdown",Nt=>le(Nt,tt)),I.set(C.id,tt)}Gt(tt,!1);const D=t.get(C.id)??[];if(D.length>0){const U=X/D.length;D.forEach((Nt,qt)=>{y(Nt,L+1,R,Z-X/2+U*(qt+.5),U*.85)})}},b=new Set(st.map(C=>C.id));for(const[C,L]of I)b.has(C)||(G.removeChild(L.gfx),L.gfx.destroy(),I.delete(C),re(C));const h=Math.max(1,s.length);s.forEach((C,L)=>{const R=2*Math.PI/h*L;y(C,0,L,R,2*Math.PI/h)}),d&&Ae(d)}function Ae(t){const d=bn(t.count,st),y=9211796;let b=I.get(t.id);if(b)b.parent=0,b.name=t.name,b.description=t.description,b.count=t.count,b.depth=0,b.color=y,b.radius=d,b.tx=0,b.ty=0,b.pinned=!0;else{const h=new o.Graphics;h.eventMode="static",h.cursor="pointer",b={id:t.id,parent:0,name:t.name,description:t.description,count:t.count,x:0,y:0,tx:0,ty:0,radius:d,depth:0,color:y,gfx:h,pinned:!0},G.addChild(h),h.on("pointerdown",C=>le(C,b)),I.set(t.id,b)}Gt(b,!1)}function xe(t,c,s,d,y,b,h={}){const C=d-c,L=c+C*.5,R=s,Z=d-C*.5,X=y,ct=h.alpha??.5,Y=h.width??1.5;if(!h.dashed){t.moveTo(c,s),t.bezierCurveTo(L,R,Z,X,d,y),t.stroke({color:b,width:Y,alpha:ct});return}const ht=D=>{const U=1-D,Nt=U*U*U*c+3*U*U*D*L+3*U*D*D*Z+D*D*D*d,qt=U*U*U*s+3*U*U*D*R+3*U*D*D*X+D*D*D*y;return{x:Nt,y:qt}},pt=32,ot=h.dashPhase??0,K=Math.max(1,h.dashStride??1);let Q=c,tt=s;for(let D=1;D<=pt;D++){const U=ht(D/pt);Math.floor((D-1+ot)/K)%2===0&&(t.moveTo(Q,tt),t.lineTo(U.x,U.y),t.stroke({color:b,width:Y,alpha:ct})),Q=U.x,tt=U.y}}function Gt(t,c){const s=t.gfx;s.clear();const d=t.radius;c||(s.circle(0,5,d),s.fill({color:0,alpha:.18})),c&&(s.circle(0,0,d+10),s.fill({color:t.color,alpha:.22})),s.circle(0,0,d),s.fill(Bo(t.color,-.18)),s.circle(0,-d*.06,d*.94),s.fill(t.color),s.circle(-d*.32,-d*.42,d*.3),s.fill({color:16777215,alpha:.32}),s.circle(0,0,d),s.stroke({color:16777215,width:c?3:2,alignment:0}),s.x=t.x,s.y=t.y,s.zIndex=10,s.hitArea=new o.Circle(0,0,d+4)}function Ne(t,c){Gt(t,!1);const s=t.gfx,d=performance.now(),y=Math.sin(d/280)*.5+.5,b=t.radius+6+y*5;s.circle(0,0,b),s.stroke({color:c,width:3,alpha:.6+y*.35}),s.circle(0,0,t.radius*.42),s.fill({color:c,alpha:.85}),s.hitArea=new o.Circle(0,0,t.radius+12)}function Le(){it.clear();for(const t of I.values()){if(!t.parent)continue;const c=I.get(t.parent);if(!c)continue;const s=j!==null&&t===j,d=W!==null&&(t.id===W||t.parent===W),y=W!==null&&!d?.35:1;xe(it,c.x,c.y,t.x,t.y,c.color,s?{dashed:!0,alpha:.28*y}:{alpha:.5*y})}if(j&>){const t=j.x,c=j.y,s=gt.x,d=gt.y,y=gt.color;xe(it,t,c,s,d,y,{alpha:.22,width:9});const b=Math.floor(performance.now()/70);xe(it,t,c,s,d,y,{alpha:.95,width:2.5,dashed:!0,dashStride:2,dashPhase:b});const h=performance.now()%1300/1300,C=1-h,L=s-t,R=t+L*.5,Z=c,X=s-L*.5,ct=d,Y=C*C*C*t+3*C*C*h*R+3*C*h*h*X+h*h*h*s,ht=C*C*C*c+3*C*C*h*Z+3*C*h*h*ct+h*h*h*d;it.circle(Y,ht,5),it.fill({color:16777215,alpha:.95}),it.stroke({color:y,width:2,alpha:1})}if(lt.clear(),W!==null){const t=I.get(W);if(t)for(const c of Rt.values())lt.moveTo(t.x,t.y),lt.lineTo(c.x,c.y),lt.stroke({color:t.color,width:1,alpha:.35})}}const be='-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',Ie=18,Ce=22;function ce(t){return t.length>Ie?t.slice(0,Ie-1)+"…":t}function oe(t){const c=mt.get(t.id);if(c)return c;const s=new o.Container;s.eventMode="static",s.cursor="pointer";const d=new o.Graphics;s.addChild(d);const y=new o.Text({text:ce(t.name),style:{fill:1909543,fontSize:14,fontFamily:be,fontWeight:"600"},resolution:bt});s.addChild(y);const b=new o.Graphics;s.addChild(b);const h=new o.Text({text:String(t.count),style:{fill:16777215,fontSize:12,fontFamily:be,fontWeight:"700"},resolution:bt});s.addChild(h);const C={container:s,bg:d,nameText:y,countBg:b,countText:h,width:0,height:0,cachedName:"",cachedCount:-1,cachedFocused:!1,cachedHover:!1,cachedColor:-1};return mt.set(t.id,C),Pt.addChild(s),s.on("pointerdown",L=>{L.stopPropagation?.(),Ht=performance.now()}),s.on("pointertap",()=>{We(t.id)}),s.on("pointerover",()=>{C.cachedHover=!0,Xt(C,t)}),s.on("pointerout",()=>{C.cachedHover=!1,Xt(C,t)}),C}function Xt(t,c){const s=W===c.id,d=ce(c.name),y=String(c.count);t.nameText.text!==d&&(t.nameText.text=d),t.countText.text!==y&&(t.countText.text=y),t.cachedName=d,t.cachedCount=c.count,t.cachedFocused=s,t.cachedColor=c.color;const b=9,h=3,C=5,L=5,R=2,Z=18,X=t.nameText.width,ct=t.nameText.height,Y=t.countText.width,ht=t.countText.height,pt=Math.max(Z,Y+L*2),ot=ht+R*2,K=b+X+C+pt+b,Q=Math.max(ct,ot)+h*2;t.width=K,t.height=Q;const tt=-K/2;t.bg.clear(),t.bg.roundRect(tt,0,K,Q,Q/2),s?t.bg.fill(c.color):t.cachedHover?(t.bg.fill({color:16777215,alpha:.96}),t.bg.stroke({color:c.color,width:1.5,alpha:1})):(t.bg.fill({color:16777215,alpha:.88}),t.bg.stroke({color:0,width:1,alpha:.06})),t.nameText.x=tt+b,t.nameText.y=(Q-ct)/2,t.nameText.style.fill=s?16777215:1909543;const D=tt+b+X+C,U=(Q-ot)/2;t.countBg.clear(),t.countBg.roundRect(D,U,pt,ot,ot/2),t.countBg.fill(s?{color:16777215,alpha:.25}:c.color),t.countText.x=D+(pt-Y)/2,t.countText.y=U+(ot-ht)/2}function re(t){const c=mt.get(t);c&&(Pt.removeChild(c.container),c.container.destroy({children:!0}),mt.delete(t))}function Re(){const t=new Set(I.keys());for(const d of[...mt.keys()])t.has(d)||re(d);const c=1/Math.max(.01,v.scale.x),s=W!==null;for(const d of I.values()){const y=oe(d);y.container.x=d.x,y.container.y=d.y+d.radius+6,y.container.scale.set(c);const b=W===d.id,h=!s||b?1:.4;Math.abs(y.container.alpha-h)>.005?y.container.alpha+=(h-y.container.alpha)*.18:y.container.alpha=h,Math.abs(d.gfx.alpha-h)>.005?d.gfx.alpha+=(h-d.gfx.alpha)*.18:d.gfx.alpha=h;const C=ce(d.name);(y.cachedName!==C||y.cachedCount!==d.count||y.cachedFocused!==b||y.cachedColor!==d.color)&&Xt(y,d)}for(const d of Rt.values()){const y=rt.get(d.id);y&&(y.container.x=d.x,y.container.y=d.y,y.container.scale.set(c),y.container.alpha<1&&(y.container.alpha=Math.min(1,y.container.alpha+.18)))}}function He(t){const c=Array.from(I.values());for(const s of c){if(s.pinned){s.x+=(s.tx-s.x)*.12,s.y+=(s.ty-s.y)*.12,s.gfx.x=s.x,s.gfx.y=s.y;continue}let d=0,y=0;for(const h of c){if(s===h)continue;const C=s.x-h.x,L=s.y-h.y,R=C*C+L*L+1,Z=Io/R,X=Math.sqrt(R);d+=C/X*Z,y+=L/X*Z}const b=I.get(s.parent);if(b){const h=b.x-s.x,C=b.y-s.y,L=Math.sqrt(h*h+C*C)||1,R=L-Ro;d+=h/L*R*yn,y+=C/L*R*yn}else d+=-s.x*8e-4,y+=-s.y*8e-4;if(N&&s.id!==W){const h=s.x-N.x,C=s.y-N.y,L=Math.sqrt(h*h+C*C)||1,R=N.radius+s.radius;L5e-4||Math.abs(y)>.5||Math.abs(b)>.5)&&(v.scale.set(v.scale.x+d*s),v.x+=y*s,v.y+=b*s),He(c);for(const h of Rt.values())h.x+=(h.tx-h.x)*.18,h.y+=(h.ty-h.y)*.18,h.gfx.x=h.x,h.gfx.y=h.y;Le(),j&>&&Ne(gt,j.color),Re(),Tt=requestAnimationFrame(Oe)}let de=null,ve={x:0,y:0};function le(t,c){const s=t;s.stopPropagation?.(),Ht=performance.now(),j=c,c.pinned=!0,c.tx=c.x,c.ty=c.y,de={x:s.global.x,y:s.global.y};const d=ue({x:s.global.x,y:s.global.y});ve={x:c.x-d.x,y:c.y-d.y}}function ue(t){return{x:(t.x-v.x)/v.scale.x,y:(t.y-v.y)/v.scale.y}}function Ye(t){const c=t;Ot=!0,Bt={x:c.global.x,y:c.global.y},Mt=0}function _e(t){const c=t;if(j){const s=ue(c.global),d=s.x+ve.x,y=s.y+ve.y;j.x=d,j.y=y,j.tx=d,j.ty=y,j.gfx.x=d,j.gfx.y=y;let b=null;for(const h of I.values()){if(h===j)continue;const C=h.x-s.x,L=h.y-s.y;if(C*C+L*Lh.id===c.id?{...h,parent:s.id}:h),zt()}catch(h){Qt(x("Reparent failed:"),h)}else Gt(c,W===c.id),s&&Gt(s,W===s.id)}Ot=!1,Bt=null}k.stage.eventMode="static",k.stage.hitArea=new o.Rectangle(0,0,r.clientWidth,r.clientHeight),k.stage.on("pointerdown",Ye),k.stage.on("pointermove",_e),k.stage.on("pointerup",t=>void De(t)),k.stage.on("pointerupoutside",t=>void De(t));function Te(t){t.preventDefault();const s=Math.exp(-t.deltaY*8e-4),d=yt,y=Math.max(.3,Math.min(2.5,d*s));if(Math.abs(y-d)<5e-4)return;const b=r.getBoundingClientRect(),h=t.clientX-b.left,C=t.clientY-b.top,L=(h-xt)/d,R=(C-M)/d;yt=y,xt=h-L*y,M=C-R*y}r.addEventListener("wheel",Te,{passive:!1});let Ee=!1,ee=0,Se=0;const Fe=24,$e=80;let Vt=null;function Ke(){const t=r.getBoundingClientRect();k.renderer.resize(t.width,t.height),k.stage.hitArea=new o.Rectangle(0,0,t.width,t.height),!Ee&&t.width>0&&t.height>0&&(Ee=!0,ee=t.width,Se=t.height,ut(),r.classList.remove("is-loading")),Vt!==null&&window.clearTimeout(Vt),Vt=window.setTimeout(()=>{Vt=null;const c=r.getBoundingClientRect(),s=Math.abs(c.width-ee),d=Math.abs(c.height-Se);(s>=Fe||d>=Fe)&&(ee=c.width,Se=c.height,_t())},$e),k.render()}const Dt=new ResizeObserver(Ke);Dt.observe(r);function Ve(t,c){let s=I.get(c),d=32;for(;s&&d-- >0;){if(s.id===t)return!0;if(!s.parent)return!1;s=I.get(s.parent)}return!1}let Jt=0;const pe=ge+130;async function We(t){if(W===t){i();return}const c=W!==null;W=t,dt=1,Jt=performance.now();const s=I.get(t);if(s){c||(nt={scale:yt,x:xt,y:M});const d=r.getBoundingClientRect();if(d.width>0&&d.height>0){const y=ge+70,b=d.width*.85/(2*y),h=d.height*.85/(2*y),C=Math.max(.5,Math.min(1.6,Math.min(b,h)));yt=C,xt=d.width/2-s.x*C,M=d.height/2-s.y*C}N={x:s.x,y:s.y,radius:pe},O.clear();for(const y of I.values()){if(y.id===t||!y.pinned)continue;const b=y.x-s.x,h=y.y-s.y,C=Math.sqrt(b*b+h*h)||1;if(C>=pe+y.radius)continue;O.set(y.id,{tx:y.tx,ty:y.ty});const L=pe+y.radius+20;y.tx=s.x+b/C*L,y.ty=s.y+h/C*L}}for(const d of I.values())Gt(d,W===d.id);et(),await H()}function i(){W=null,Jt=performance.now(),Wt++,N=null;for(const[t,c]of O){const s=I.get(t);s&&(s.tx=c.tx,s.ty=c.ty)}O.clear(),nt&&(yt=nt.scale,xt=nt.x,M=nt.y,nt=null),et(),u();for(const t of I.values())Gt(t,!1)}function u(){for(const t of Rt.values())V.removeChild(t.gfx),t.gfx.destroy();Rt.clear();for(const t of rt.values())Et.removeChild(t.container),t.container.destroy({children:!0});rt.clear(),lt.clear(),wt.visible=!1}function p(t){const c=rt.get(t.id);if(c)return c;const s=new o.Container;s.eventMode="static",s.cursor="pointer",s.alpha=0;const d=new o.Graphics;s.addChild(d);const y=new o.Graphics;s.addChild(y);const b=new o.Text({text:t.title,style:{fill:1909543,fontSize:14,fontFamily:be,fontWeight:"500"},resolution:bt});s.addChild(b);const h={container:s,bg:d,dot:y,titleText:b,width:0,height:0,cachedTitle:"",cachedHover:!1};return rt.set(t.id,h),Et.addChild(s),s.on("pointerdown",C=>{C.stopPropagation?.(),Ht=performance.now()}),s.on("pointertap",()=>{z(t.id,t.editUrl,t.title),i()}),s.on("pointerover",()=>{h.cachedHover=!0,m(h,t)}),s.on("pointerout",()=>{h.cachedHover=!1,m(h,t)}),m(h,t),h}function m(t,c){const s=c.title.length>Ce?c.title.slice(0,Ce-1)+"…":c.title;t.titleText.text!==s&&(t.titleText.text=s),t.cachedTitle=s;const d=9,y=3,b=4,h=6,C=t.titleText.width,L=t.titleText.height,R=d+b*2+h+C+d,Z=Math.max(L,b*2)+y*2;t.width=R,t.height=Z;const X=-R/2,ct=-Z/2;t.bg.clear(),t.bg.roundRect(X,ct,R,Z,Z/2),t.cachedHover?(t.bg.fill({color:16777215,alpha:1}),t.bg.stroke({color:c.tone,width:1.5,alpha:1})):(t.bg.fill({color:16777215,alpha:.95}),t.bg.stroke({color:0,width:1,alpha:.12})),t.dot.clear(),t.dot.circle(X+d+b,0,b),t.dot.fill({color:c.tone,alpha:.85}),t.dot.stroke({color:16777215,width:1}),t.titleText.x=X+d+b*2+h,t.titleText.y=-L/2}const g=6e4,S=new Map;function T(t,c){if($t=t.totalPages,Number.isFinite(t.realTotal)){const s=I.get(c);s&&s.count!==t.realTotal&&(s.count=t.realTotal,st=st.map(d=>d.id===s.id?{...d,count:t.realTotal}:d),Xt(oe(s),s))}F(t.items)}async function H(){if(W===null)return;const t=++Wt,c=W,s=`${W}:${dt}`,d=S.get(s);if(d&&performance.now()-d.fetchedAt({id:Y.id,title:zo(Y.title?.rendered||`#${Y.id}`),editUrl:`${y.editPostUrlBase}?post=${Y.id}&action=edit`})),totalPages:L,realTotal:Z,fetchedAt:performance.now()};S.set(s,ct),T(ct,c)}catch(h){Qt(x("Couldn’t load posts:"),h)}}function F(t){if(u(),W===null)return;const c=I.get(W);if(!c)return;const s=t.length,d=ge+Math.max(0,s-8)*6;t.forEach((y,b)=>{const h=2*Math.PI/Math.max(1,s)*b-Math.PI/2,C=c.x+Math.cos(h)*d,L=c.y+Math.sin(h)*d,R=c.color,Z=new o.Graphics;V.addChild(Z);const X={id:y.id,title:y.title,editUrl:y.editUrl,angle:h,r:d,x:c.x,y:c.y,tx:C,ty:L,gfx:Z,tone:R};Rt.set(y.id,X),p(X)}),$()}function $(){if(W===null||$t<=1){wt.visible=!1;return}wt.visible=!0;const t=I.get(W);if(!t){wt.visible=!1;return}const c=dt<=1,s=dt>=$t;B(Ct,"◀",c),B(q,"▶",s),Ct.cursor=c?"default":"pointer",q.cursor=s?"default":"pointer",Lt.text=`${dt} / ${$t}`,Ct.x=-38,Ct.y=0,q.x=38,q.y=0,Lt.x=0,Lt.y=0,wt.x=t.x,wt.y=t.y+ge+60}function B(t,c,s){t.clear(),t.circle(0,0,14),t.fill({color:s?15921906:16777215,alpha:s?.7:1}),t.stroke({color:0,width:1,alpha:.12});const y=t.children?.[0]??null;if(y)y.text=c,y.style.fill=s?11580344:5265246;else{const b=new o.Text({text:c,style:{fill:s?11580344:5265246,fontSize:16,fontFamily:'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',fontWeight:"600"},resolution:bt});b.anchor.set(.5),t.addChild(b)}}function z(t,c,s){const d=n?.windowManager,y=n?.deriveWindowId,b=d&&typeof d.getById=="function"?d.getById("desktop-mode-posts"):void 0;if(b&&typeof b.isFullscreen=="function"&&typeof b.toggleFullscreen=="function"&&b.isFullscreen()&&b.toggleFullscreen(),d&&typeof y=="function"){const h=y(c);d.open({id:h,baseId:h,url:c,title:s??c,icon:"dashicons-admin-post"});return}try{window.open(c,"_blank")}catch{window.location.assign(c)}}function J(t){const c=t.parent!==0?I.get(t.parent):null,s=document.createElement("div");s.className="wpd-mindmap__sidebar-header";const d=document.createElement("span");d.className="wpd-mindmap__sidebar-dot";const y=c?c.color:Yt(st.length);d.style.background=`#${y.toString(16).padStart(6,"0")}`;const b=document.createElement("code");b.className="wpd-mindmap__sidebar-slug",b.textContent=c?jt(x("New child of %s"),c.name):x("New root category"),s.appendChild(d),s.appendChild(b),w.appendChild(s);const h=document.createElement("label");h.className="wpd-mindmap__sidebar-label",h.textContent=x("Name"),w.appendChild(h);const C=document.createElement("input");C.type="text",C.className="wpd-mindmap__editor-name",C.placeholder=x("e.g. Recipes"),w.appendChild(C),requestAnimationFrame(()=>C.focus());const L=document.createElement("label");L.className="wpd-mindmap__sidebar-label",L.textContent=x("Slug"),w.appendChild(L);const R=document.createElement("input");R.type="text",R.className="wpd-mindmap__editor-name",R.placeholder=x("auto-from-name"),R.spellcheck=!1,R.autocapitalize="off",R.addEventListener("input",()=>{const ot=R.value,K=ot.toLowerCase().replace(/[^a-z0-9-]+/g,"-");if(ot!==K){const Q=R.selectionStart??K.length;R.value=K,R.setSelectionRange(Q,Q)}}),w.appendChild(R);const Z=document.createElement("label");Z.className="wpd-mindmap__sidebar-label",Z.textContent=x("Description"),w.appendChild(Z);const X=document.createElement("textarea");X.className="wpd-mindmap__editor-desc",X.placeholder=x("Description (optional)"),X.rows=4,w.appendChild(X);const ct=document.createElement("div");ct.className="wpd-mindmap__editor-actions";const Y=document.createElement("button");Y.type="button",Y.className="wpd-mindmap__btn wpd-mindmap__btn--primary",Y.textContent=x("Create");const ht=document.createElement("button");ht.type="button",ht.className="wpd-mindmap__btn wpd-mindmap__btn--danger",ht.textContent=x("Cancel");const pt=async()=>{const ot=C.value.trim();if(!ot){C.focus();return}Y.disabled=!0;try{const K=await nn(ot,t.parent,{slug:R.value.trim()||void 0,description:X.value||void 0}),Q={id:K.id,name:K.name,slug:K.slug||"",parent:K.parent,count:0,description:K.description||"",isDefault:!1};st.some(tt=>tt.id===Q.id)||(st=st.concat(Q)),kt=null,zt(),W=K.id,et(),await H()}catch(K){Y.disabled=!1,Qt(x("Couldn’t create:"),K)}};Y.addEventListener("click",()=>{pt()}),ht.addEventListener("click",()=>{kt=null,et()}),C.addEventListener("keydown",ot=>{ot.key==="Enter"?(ot.preventDefault(),pt()):ot.key==="Escape"&&(kt=null,et())}),ct.appendChild(Y),ct.appendChild(ht),w.appendChild(ct)}function et(){if(w.replaceChildren(),kt!==null){J(kt);return}if(W===null){const D=document.createElement("div");D.className="wpd-mindmap__sidebar-empty";const U=document.createElement("span");U.className="dashicons dashicons-admin-tools",U.setAttribute("aria-hidden","true"),D.appendChild(U);const Nt=document.createElement("h3");Nt.textContent=x("No category selected"),D.appendChild(Nt);const qt=document.createElement("p");qt.textContent=x("Click a node on the mindmap to edit its name, description, and posts."),D.appendChild(qt),w.appendChild(D);return}const t=I.get(W);if(!t){W=null,et();return}const c=t.id,s=document.createElement("div");s.className="wpd-mindmap__sidebar-header";const d=document.createElement("span");d.className="wpd-mindmap__sidebar-dot",d.style.background=`#${t.color.toString(16).padStart(6,"0")}`;const y=st.find(D=>D.id===c),b=document.createElement("code");b.className="wpd-mindmap__sidebar-slug",b.textContent=`#${c}`,s.appendChild(d),s.appendChild(b),w.appendChild(s);const h=document.createElement("label");h.className="wpd-mindmap__sidebar-label",h.textContent=x("Name"),w.appendChild(h);const C=document.createElement("input");C.type="text",C.className="wpd-mindmap__editor-name",C.value=t.name,C.placeholder=x("Name"),w.appendChild(C);const L=document.createElement("label");L.className="wpd-mindmap__sidebar-label",L.textContent=x("Slug"),w.appendChild(L);const R=document.createElement("input");R.type="text",R.className="wpd-mindmap__editor-name",R.value=y?.slug||"",R.placeholder=x("auto-from-name"),R.spellcheck=!1,R.autocapitalize="off",R.addEventListener("input",()=>{const D=R.value,U=D.toLowerCase().replace(/[^a-z0-9-]+/g,"-");if(D!==U){const Nt=R.selectionStart??U.length;R.value=U,R.setSelectionRange(Nt,Nt)}}),w.appendChild(R);const Z=document.createElement("label");Z.className="wpd-mindmap__sidebar-label",Z.textContent=x("Description"),w.appendChild(Z);const X=document.createElement("textarea");X.className="wpd-mindmap__editor-desc",X.value=t.description||"",X.placeholder=x("Description (optional)"),X.rows=4,w.appendChild(X);const ct=document.createElement("p");ct.className="wpd-mindmap__sidebar-meta",ct.textContent=jt(x("%d posts in this category."),t.count),w.appendChild(ct);const Y=document.createElement("div");Y.className="wpd-mindmap__editor-actions";const ht=document.createElement("button");ht.type="button",ht.className="wpd-mindmap__btn wpd-mindmap__btn--secondary",ht.textContent=x("+ Child"),ht.addEventListener("click",()=>{vt(c)});const pt=t.parent&&t.parent!==0?document.createElement("button"):null;pt&&(pt.type="button",pt.className="wpd-mindmap__btn wpd-mindmap__btn--secondary",pt.textContent=x("Make root"),pt.title=x("Promote this category to a top-level root (no parent)."),pt.addEventListener("click",async()=>{try{await he("categories",t.id,{parent:0}),t.parent=0,st=st.map(D=>D.id===t.id?{...D,parent:0}:D),zt(),et()}catch(D){Qt(x("Couldn’t reparent:"),D)}}));const ot=document.createElement("button");ot.type="button",ot.className="wpd-mindmap__btn wpd-mindmap__btn--primary",ot.textContent=x("Save"),ot.addEventListener("click",async()=>{const D=C.value.trim();if(!D)return;const U=X.value,Nt=R.value.trim(),qt=y?.slug??"";if(D===t.name&&U===(t.description||"")&&Nt===qt)return;const Rn={name:D,description:U};Nt!==qt&&(Rn.slug=Nt);try{const ae=await he("categories",t.id,Rn);t.name=ae.name,t.description=ae.description,st=st.map(Be=>Be.id===t.id?{...Be,name:ae.name,description:ae.description,slug:ae.slug??Be.slug}:Be),Xt(oe(t),t),et()}catch(ae){Qt(x("Couldn’t save:"),ae)}});const K=document.createElement("button");K.type="button",K.className="wpd-mindmap__btn wpd-mindmap__btn--danger",K.textContent=x("Delete");let Q=null;const tt=()=>{K.textContent=x("Click again to delete"),K.classList.add("is-armed"),Q!==null&&window.clearTimeout(Q),Q=window.setTimeout(()=>{K.textContent=x("Delete"),K.classList.remove("is-armed"),Q=null},2500)};K.addEventListener("click",async()=>{if(!K.classList.contains("is-armed")){tt();return}Q!==null&&(window.clearTimeout(Q),Q=null);try{await Ue("categories",t.id),st=st.filter(D=>D.id!==t.id),W=null,u(),zt(),et()}catch(D){Qt(x("Couldn’t delete:"),D)}}),Y.appendChild(ht),pt&&Y.appendChild(pt),Y.appendChild(ot),Y.appendChild(K),w.appendChild(Y)}function vt(t){t!==0&&!I.get(t)||(kt={parent:t},et())}f.addEventListener("click",()=>{vt(0)});function ut(t={}){const c=t.padding??90,s=t.animate??!1,d=r.getBoundingClientRect();if(I.size===0||d.width===0||d.height===0){const Q=d.width/2,tt=d.height/2;yt=1,xt=Q,M=tt,s||(v.x=Q,v.y=tt,v.scale.set(1));return}let y=1/0,b=1/0,h=-1/0,C=-1/0;const L=30;for(const Q of I.values()){const tt=Q.radius;y=Math.min(y,Q.tx-tt),b=Math.min(b,Q.ty-tt),h=Math.max(h,Q.tx+tt),C=Math.max(C,Q.ty+tt+L)}const R=Math.max(1,h-y),Z=Math.max(1,C-b),X=(d.width-c*2)/R,ct=(d.height-c*2)/Z,Y=Math.max(.2,Math.min(1.5,Math.min(X,ct))),ht=(y+h)/2,pt=(b+C)/2,ot=d.width/2-ht*Y,K=d.height/2-pt*Y;yt=Y,xt=ot,M=K,s||(v.scale.set(Y),v.x=ot,v.y=K)}function _t(){if(W!==null){const t=I.get(W),c=r.getBoundingClientRect();if(t&&c.width>0&&c.height>0){const s=ge+70,d=c.width*.85/(2*s),y=c.height*.85/(2*s),b=Math.max(.5,Math.min(1.6,Math.min(d,y)));yt=b,xt=c.width/2-t.x*b,M=c.height/2-t.y*b;return}}ut({animate:!0})}_.addEventListener("click",()=>_t()),k.canvas.addEventListener("click",t=>{const c=performance.now();if(c-Jt<250||c-Ht<250||Mt>4)return;t.target===k.canvas&&!j&&W!==null&&i()});async function St(){if(st.length===0)return;const t=ft(),c=new URL(`${t.restRoot.replace(/\/$/,"")}/desktop-mode/v1/term-counts`);c.searchParams.set("taxonomy","category"),c.searchParams.set("ids",st.map(s=>s.id).join(","));try{const d=(await vn(c.toString())).json;let y=!1;st=st.map(b=>{const h=d[String(b.id)];if(typeof h=="number"&&h!==b.count){y=!0;const C=I.get(b.id);return C&&(C.count=h,Xt(oe(C),C)),{...b,count:h}}return b}),y&&(zt(),ut({animate:!0}))}catch{}}if(zt(),et(),Kt(80),Tt=requestAnimationFrame(Oe),St(),st.length<=1){const t=document.createElement("div");t.className="wpd-mindmap__empty",t.textContent=x('No custom categories yet. Click "Add root category" to start branching.'),r.appendChild(t)}return()=>{Tt!==null&&(cancelAnimationFrame(Tt),Tt=null),Vt!==null&&(window.clearTimeout(Vt),Vt=null),Dt.disconnect(),r.removeEventListener("wheel",Te);try{k.destroy(!0,{children:!0,texture:!0})}catch{}e.replaceChildren(),e.classList.remove("wpd-mindmap")}}function bn(e,n){const a=Math.max(1,...n.map(l=>l.count)),o=Math.sqrt(e/a);return xn+(Ho-xn)*o}function Fo(){try{const e=getComputedStyle(document.documentElement).getPropertyValue("--wp-admin-theme-color").trim();if(!e)return 210;const n=document.createElement("span");n.style.color=e,document.body.appendChild(n);const a=getComputedStyle(n).color;n.remove();const o=a.match(/\d+/g);return!o||o.length<3?210:$o(parseInt(o[0],10),parseInt(o[1],10),parseInt(o[2],10))}catch{return 210}}function $o(e,n,a){const o=e/255,l=n/255,f=a/255,_=Math.max(o,l,f),E=Math.min(o,l,f),P=_-E;if(P===0)return 210;let r;switch(_){case o:r=(l-f)/P+(lMath.round(_*(1+n));return f(a)*65536+f(o)*256+f(l)}function zo(e){const n=document.createElement("div");return n.innerHTML=e,n.textContent||n.innerText||""}function Cn(e,n){const a=n instanceof Error?n.message:String(n),o=window.wp?.desktop;if(o&&typeof o.showToast=="function"){o.showToast({message:`${e} ${a}`.trim(),duration:6e3});return}console.error(e,n)}async function vn(e){const n=ft(),a=window.wp?.desktop,o={method:"GET",credentials:"same-origin",headers:{"X-WP-Nonce":n.restNonce,Accept:"application/json"}};let l;if(a&&typeof a.fetch=="function"?l=await a.fetch(e,o,{windowId:"desktop-mode-posts"}):l=await fetch(e,o),!l.ok)throw new Error(`${l.status} ${l.statusText}`);return{json:await l.json(),headers:l.headers}}const Uo=Object.freeze(Object.defineProperty({__proto__:null,mountCategoriesMindmap:Do},Symbol.toStringTag,{value:"Module"})),Go=10,we=170,_n=11,Xo=28,se='-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',Me=3,Tn=22,En=22,ye=11,qo=6,qe=4,Sn=8,jo=14,Yo=we+130;async function Ko(e){const n=window.wp?.desktop;if(!n||typeof n.loadModules!="function")return e.textContent=x("Tag cloud unavailable: shell modules API missing."),()=>{};try{await n.loadModules(["pixijs"])}catch{return e.textContent=x("Tag cloud unavailable."),()=>{}}const a=window.PIXI;if(!a)return e.textContent=x("Tag cloud unavailable."),()=>{};const o=a;e.replaceChildren(),e.classList.add("wpd-tagcloud");const l=document.createElement("div");l.className="wpd-tagcloud__toolbar";const f=document.createElement("button");f.type="button",f.className="wpd-tagcloud__btn wpd-tagcloud__btn--primary",f.innerHTML=''+x("Add tag");const _=document.createElement("button");_.type="button",_.className="wpd-tagcloud__btn",_.innerHTML=''+x("Recenter");const E=document.createElement("button");E.type="button",E.className="wpd-tagcloud__btn",E.innerHTML=''+x("Reflow"),E.title=x("Recompute the chip layout from scratch — discards manual repositioning.");const P=document.createElement("span");P.className="wpd-tagcloud__hint",P.textContent=x("Click a tag to focus + edit · drag to reposition · wheel to zoom"),l.appendChild(f),l.appendChild(_),l.appendChild(E),l.appendChild(P),e.appendChild(l);const r=document.createElement("div");r.className="wpd-tagcloud__layout",e.appendChild(r);const w=document.createElement("div");w.className="wpd-tagcloud__stage",w.classList.add("is-loading"),r.appendChild(w);const k=document.createElement("aside");k.className="wpd-tagcloud__sidebar",r.appendChild(k);const v=new o.Application;await v.init({resizeTo:w,backgroundAlpha:0,antialias:!0,autoDensity:!0,resolution:Math.min(window.devicePixelRatio||1,2)}),w.appendChild(v.canvas),v.canvas.classList.add("wpd-tagcloud__canvas");const A=new o.Container;A.x=w.clientWidth/2,A.y=w.clientHeight/2,v.stage.addChild(A);const G=new o.Container,at=new o.Container,V=new o.Container,Pt=new o.Container;A.addChild(at),A.addChild(G),A.addChild(V),A.addChild(Pt);const Et=new o.Graphics;at.addChild(Et);const it=new o.Container;it.eventMode="passive",it.visible=!1,V.addChild(it);const lt=new o.Graphics,bt=new o.Graphics,wt=new o.Text({text:"1 / 1",style:{fill:5265246,fontSize:12,fontFamily:se,fontWeight:"600"}});wt.anchor.set(.5),lt.eventMode="static",lt.cursor="pointer",bt.eventMode="static",bt.cursor="pointer",lt.hitArea=new o.Circle(0,0,16),bt.hitArea=new o.Circle(0,0,16),it.addChild(lt),it.addChild(wt),it.addChild(bt);const Ct=i=>{i.stopPropagation?.(),W=performance.now()};lt.on("pointerdown",Ct),bt.on("pointerdown",Ct),lt.on("pointertap",i=>{Ct(i),M=performance.now(),!(mt<=1)&&(mt--,ee())}),bt.on("pointertap",i=>{Ct(i),M=performance.now(),!(mt>=rt)&&(mt++,ee())});const q=new Map,Lt=new Map,It=new Map;let I=null,mt=1,rt=1,Rt=0,W=0,dt=null,$t={x:0,y:0},Wt=null,Ht=!1,j=null,gt=0,Ot=null,Bt=performance.now(),Mt=A.scale.x,Tt=A.x,At=A.y,yt=null,xt=null,M=0,N=null,O=[];const nt=ta(),kt=ea(nt),ie=Jo();try{const i=[];let u=1;for(;u<=5;){const p=await on("tags",{page:u,perPage:100});if(i.push(...p.items),u>=p.totalPages)break;u++}O=i}catch(i){Ln(x("Couldn’t load tags:"),i)}const Yt=(i,u)=>Ln(i,u);function st(){const i=new Set(O.map(g=>g.id));for(const[g,S]of q)i.has(g)||(G.removeChild(S.chip.container),S.chip.container.destroy({children:!0}),q.delete(g));const u=Math.max(1,...O.map(g=>g.count)),p=[];for(const g of O){const S=kn(g.count,u),T=An(g.slug||g.name,ie),H=Nn(g.slug||g.name),F=q.get(g.id);if(F)F.name=g.name,F.slug=g.slug,F.description=g.description,F.count=g.count,F.fontSize=S,F.hue=T,F.rotation=H,te(F);else{const $=na(o,G,g,S,T),B=kt.get(g.id),z={id:g.id,name:g.name,slug:g.slug,description:g.description,count:g.count,fontSize:S,hue:T,rotation:H,x:B?B.x:0,y:B?B.y:0,tx:B?B.x:0,ty:B?B.y:0,width:0,height:0,chip:$};q.set(g.id,z),te(z),Qt(z),B||p.push(z)}}const m=[];for(const g of q.values())p.includes(g)||m.push({x:g.tx-g.width/2,y:g.ty-g.height/2,w:g.width,h:g.height});p.sort((g,S)=>S.count-g.count);for(const g of p){const S=Ae(g.width,g.height,m);g.tx=S.x,g.ty=S.y,g.x=S.x,g.y=S.y,m.push({x:S.x-g.width/2,y:S.y-g.height/2,w:g.width,h:g.height})}}function Qt(i){const u=i.chip.container;u.on("pointerdown",p=>{const m=p;m.stopPropagation?.(),W=performance.now(),dt=i,Wt={x:m.global.x,y:m.global.y};const g=Le({x:m.global.x,y:m.global.y});$t={x:i.x-g.x,y:i.y-g.y}}),u.on("pointerover",()=>{i.chip.cachedHover=!0,zt(i)}),u.on("pointerout",()=>{i.chip.cachedHover=!1,zt(i)})}function te(i){const u=i.chip,p=Pn(i.name),m=String(i.count);u.nameText.text!==p&&(u.nameText.text=p),u.countText.text!==m&&(u.countText.text=m),u.nameText.style.fontSize=i.fontSize,u.hashText.style.fontSize=i.fontSize,u.countText.style.fontSize=Math.max(10,Math.round(i.fontSize*.55)),u.cachedName=p,u.cachedCount=i.count,u.cachedHue=i.hue;const g=u.hashText.width,S=u.nameText.width,T=u.nameText.height,H=u.countText.width,F=u.countText.height,$=Math.max(18,H+10),B=Math.max(14,F+4),z=ye+g+qe+S+Sn+$+ye,J=Math.max(T,B)+qo*2;i.width=z,i.height=J,zt(i)}function zt(i){const u=i.chip,p=I===i.id;u.cachedFocused=p;const m=i.width,g=i.height,S=-m/2,T=-g/2,H=g/2;let F;p?F=Ut(i.hue,70,48):u.cachedHover?F=Ut(i.hue,70,92):F=Ut(i.hue,60,95);const $=p?Ut(i.hue,70,38):Ut(i.hue,50,70),B=p?16777215:1909543,z=p?16777215:Ut(i.hue,65,42),J=p?Ut(i.hue,80,30):Ut(i.hue,70,50);u.shadow.clear(),u.shadow.roundRect(S-1,T+3,m+2,g+2,H+1);let et=.1;p?et=.18:u.cachedHover&&(et=.16),u.shadow.fill({color:0,alpha:et}),u.bg.clear(),u.bg.roundRect(S,T,m,g,H),u.bg.fill(F),u.bg.stroke({color:$,width:p?2:1.25,alpha:p?1:.85});const vt=u.hashText.width,ut=u.nameText.width,_t=u.nameText.height,St=u.countText.width,t=u.countText.height,c=Math.max(18,St+10),s=Math.max(14,t+4);u.hashText.x=S+ye,u.hashText.y=(g-_t)/2+T,u.hashText.style.fill=z,u.nameText.x=S+ye+vt+qe,u.nameText.y=(g-_t)/2+T,u.nameText.style.fill=B;const d=S+ye+vt+qe+ut+Sn,y=(g-s)/2+T;u.bg.roundRect(d,y,c,s,s/2),u.bg.fill(J),u.countText.x=d+(c-St)/2,u.countText.y=y+(s-t)/2,u.countText.style.fill=16777215}function Ae(i,u,p){if(p.length===0)return{x:0,y:0};const m=jo;let g=0;const S=1e4;for(let T=0;T.005?m.alpha+=(T-m.alpha)*.18:m.alpha=T}for(const p of It.values()){const m=Lt.get(p.id);m&&(m.container.x=p.x,m.container.y=p.y,m.container.scale.set(i),m.container.alpha<1&&(m.container.alpha=Math.min(1,m.container.alpha+.18)))}}function Gt(){const i=performance.now(),u=Math.min(50,i-Bt);Bt=i;const p=.22,m=Mt-A.scale.x,g=Tt-A.x,S=At-A.y;(Math.abs(m)>5e-4||Math.abs(g)>.5||Math.abs(S)>.5)&&(A.scale.set(A.scale.x+m*p),A.x+=g*p,A.y+=S*p);for(const T of q.values()){if(T===dt)continue;let H=T.tx,F=T.ty;if(yt&&T.id!==I){const B=T.tx-yt.x,z=T.ty-yt.y,J=Math.sqrt(B*B+z*z)||1,et=yt.radius+Math.max(T.width,T.height)/2;if(JCe(i)),v.stage.on("pointerupoutside",i=>Ce(i));function ce(i){i.preventDefault();const p=Math.exp(-i.deltaY*8e-4),m=Mt,g=Math.max(.3,Math.min(2.5,m*p));if(Math.abs(g-m)<5e-4)return;const S=w.getBoundingClientRect(),T=i.clientX-S.left,H=i.clientY-S.top,F=(T-Tt)/m,$=(H-At)/m;Mt=g,Tt=T-F*g,At=H-$*g}w.addEventListener("wheel",ce,{passive:!1});let oe=!1,Xt=0,re=0;const Re=24,He=80;let Kt=null;function Oe(){const i=w.getBoundingClientRect();v.renderer.resize(i.width,i.height),v.stage.hitArea=new o.Rectangle(0,0,i.width,i.height),!oe&&i.width>0&&i.height>0&&(oe=!0,Xt=i.width,re=i.height,Jt(),w.classList.remove("is-loading")),Kt!==null&&window.clearTimeout(Kt),Kt=window.setTimeout(()=>{Kt=null;const u=w.getBoundingClientRect(),p=Math.abs(u.width-Xt),m=Math.abs(u.height-re);(p>=Re||m>=Re)&&(Xt=u.width,re=u.height,pe())},He),v.render()}const de=new ResizeObserver(Oe);de.observe(w);async function ve(i){if(I===i){le();return}const u=I!==null;I=i,mt=1,M=performance.now();const p=q.get(i);if(p){u||(xt={scale:Mt,x:Tt,y:At});const m=w.getBoundingClientRect();if(m.width>0&&m.height>0){const g=we+70,S=m.width*.85/(2*g),T=m.height*.85/(2*g),H=Math.max(.5,Math.min(1.6,Math.min(S,T)));Mt=H,Tt=m.width/2-p.x*H,At=m.height/2-p.y*H}yt={x:p.x,y:p.y,radius:Yo}}for(const m of q.values())zt(m);Dt(),await ee()}function le(){I=null,M=performance.now(),Rt++,yt=null,xt&&(Mt=xt.scale,Tt=xt.x,At=xt.y,xt=null),Dt(),ue();for(const i of q.values())zt(i)}function ue(){for(const i of It.values())V.removeChild(i.gfx),i.gfx.destroy();It.clear();for(const i of Lt.values())Pt.removeChild(i.container),i.container.destroy({children:!0});Lt.clear(),Et.clear(),it.visible=!1}function Ye(i){const u=Lt.get(i.id);if(u)return u;const p=new o.Container;p.eventMode="static",p.cursor="pointer",p.alpha=0;const m=new o.Graphics;p.addChild(m);const g=new o.Graphics;p.addChild(g);const S=new o.Text({text:i.title,style:{fill:1909543,fontSize:12,fontFamily:se,fontWeight:"500"},resolution:Me});p.addChild(S);const T={container:p,bg:m,dot:g,titleText:S,width:0,height:0,cachedTitle:"",cachedHover:!1};return Lt.set(i.id,T),Pt.addChild(p),p.on("pointerdown",H=>{H.stopPropagation?.(),W=performance.now()}),p.on("pointertap",()=>{Vt(i.id,i.editUrl,i.title),le()}),p.on("pointerover",()=>{T.cachedHover=!0,_e(T,i)}),p.on("pointerout",()=>{T.cachedHover=!1,_e(T,i)}),_e(T,i),T}function _e(i,u){const p=u.title.length>En?u.title.slice(0,En-1)+"…":u.title;i.titleText.text!==p&&(i.titleText.text=p),i.cachedTitle=p;const m=9,g=3,S=4,T=6,H=i.titleText.width,F=i.titleText.height,$=m+S*2+T+H+m,B=Math.max(F,S*2)+g*2;i.width=$,i.height=B;const z=-$/2,J=-B/2;i.bg.clear(),i.bg.roundRect(z,J,$,B,B/2),i.cachedHover?(i.bg.fill({color:16777215,alpha:1}),i.bg.stroke({color:u.tone,width:1.5,alpha:1})):(i.bg.fill({color:16777215,alpha:.95}),i.bg.stroke({color:0,width:1,alpha:.12})),i.dot.clear(),i.dot.circle(z+m+S,0,S),i.dot.fill({color:u.tone,alpha:.85}),i.dot.stroke({color:16777215,width:1}),i.titleText.x=z+m+S*2+T,i.titleText.y=-F/2}const De=6e4,Te=new Map;function Ee(i,u){if(rt=i.totalPages,Number.isFinite(i.realTotal)){const p=q.get(u);p&&p.count!==i.realTotal&&(p.count=i.realTotal,O=O.map(m=>m.id===p.id?{...m,count:i.realTotal}:m),te(p))}Se(i.items)}async function ee(){if(I===null)return;const i=++Rt,u=I,p=`${I}:${mt}`,m=Te.get(p);if(m&&performance.now()-m.fetchedAt({id:et.id,title:Qo(et.title?.rendered||`#${et.id}`),editUrl:`${g.editPostUrlBase}?post=${et.id}&action=edit`})),totalPages:F,realTotal:B,fetchedAt:performance.now()};Te.set(p,J),Ee(J,u)}catch(T){Yt(x("Couldn’t load posts:"),T)}}function Se(i){if(ue(),I===null)return;const u=q.get(I);if(!u)return;const p=i.length,m=we+Math.max(0,p-8)*6,g=Ut(u.hue,70,48);i.forEach((S,T)=>{const H=2*Math.PI/Math.max(1,p)*T-Math.PI/2,F=u.x+Math.cos(H)*m,$=u.y+Math.sin(H)*m,B=new o.Graphics;V.addChild(B);const z={id:S.id,title:S.title,editUrl:S.editUrl,angle:H,r:m,x:u.x,y:u.y,tx:F,ty:$,gfx:B,tone:g};It.set(S.id,z),Ye(z)}),Fe()}function Fe(){if(I===null||rt<=1){it.visible=!1;return}it.visible=!0;const i=q.get(I);if(!i){it.visible=!1;return}const u=mt<=1,p=mt>=rt;$e(lt,"◀",u),$e(bt,"▶",p),lt.cursor=u?"default":"pointer",bt.cursor=p?"default":"pointer",wt.text=`${mt} / ${rt}`,lt.x=-38,lt.y=0,bt.x=38,bt.y=0,wt.x=0,wt.y=0,it.x=i.x,it.y=i.y+we+60}function $e(i,u,p){i.clear(),i.circle(0,0,14),i.fill({color:p?15921906:16777215,alpha:p?.7:1}),i.stroke({color:0,width:1,alpha:.12});const g=i.children?.[0]??null;if(g)g.text=u,g.style.fill=p?11580344:5265246;else{const S=new o.Text({text:u,style:{fill:p?11580344:5265246,fontSize:14,fontFamily:se,fontWeight:"600"}});S.anchor.set(.5),i.addChild(S)}}function Vt(i,u,p){const m=n?.windowManager,g=n?.deriveWindowId,S=m&&typeof m.getById=="function"?m.getById("desktop-mode-posts"):void 0;if(S&&typeof S.isFullscreen=="function"&&typeof S.toggleFullscreen=="function"&&S.isFullscreen()&&S.toggleFullscreen(),m&&typeof g=="function"){const T=g(u);m.open({id:T,baseId:T,url:u,title:p??u,icon:"dashicons-admin-post"});return}try{window.open(u,"_blank")}catch{window.location.assign(u)}}function Ke(){const i=document.createElement("div");i.className="wpd-tagcloud__sidebar-header";const u=document.createElement("span");u.className="wpd-tagcloud__sidebar-dot",u.style.background=`hsl( ${ie}deg 60% 55% )`;const p=document.createElement("code");p.className="wpd-tagcloud__sidebar-slug",p.textContent=x("New tag"),i.appendChild(u),i.appendChild(p),k.appendChild(i);const m=document.createElement("label");m.className="wpd-tagcloud__sidebar-label",m.textContent=x("Name"),k.appendChild(m);const g=document.createElement("input");g.type="text",g.className="wpd-tagcloud__editor-name",g.placeholder=x("e.g. featured"),k.appendChild(g),requestAnimationFrame(()=>g.focus());const S=document.createElement("label");S.className="wpd-tagcloud__sidebar-label",S.textContent=x("Description"),k.appendChild(S);const T=document.createElement("textarea");T.className="wpd-tagcloud__editor-desc",T.placeholder=x("Description (optional)"),T.rows=4,k.appendChild(T);const H=document.createElement("div");H.className="wpd-tagcloud__editor-actions";const F=document.createElement("button");F.type="button",F.className="wpd-tagcloud__btn wpd-tagcloud__btn--primary",F.textContent=x("Create");const $=document.createElement("button");$.type="button",$.className="wpd-tagcloud__btn wpd-tagcloud__btn--danger",$.textContent=x("Cancel");const B=async()=>{const z=g.value.trim();if(!z){g.focus();return}F.disabled=!0;try{const J=await Qe(z),et={id:J.id,name:J.name,slug:J.slug||"",parent:0,count:0,description:J.description||"",isDefault:!1};O.some(ut=>ut.id===et.id)||(O=O.concat(et));const vt=T.value.trim();if(vt)try{const ut=await he("tags",J.id,{description:vt});O=O.map(_t=>_t.id===ut.id?{..._t,description:ut.description??vt}:_t)}catch{Yt(x("Tag created but description failed:"),null)}N=null,st(),I=J.id,Dt(),await ee()}catch(J){F.disabled=!1,Yt(x("Couldn’t create:"),J)}};F.addEventListener("click",()=>{B()}),$.addEventListener("click",()=>{N=null,Dt()}),g.addEventListener("keydown",z=>{z.key==="Enter"?(z.preventDefault(),B()):z.key==="Escape"&&(N=null,Dt())}),H.appendChild(F),H.appendChild($),k.appendChild(H)}function Dt(){if(k.replaceChildren(),N!==null){Ke();return}if(I===null){const t=document.createElement("div");t.className="wpd-tagcloud__sidebar-empty";const c=document.createElement("span");c.className="dashicons dashicons-tag",c.setAttribute("aria-hidden","true"),t.appendChild(c);const s=document.createElement("h3");s.className="wpd-tagcloud__sidebar-empty-title",s.textContent=x("No tag selected"),t.appendChild(s);const d=document.createElement("p");d.className="wpd-tagcloud__sidebar-empty-hint",d.textContent=x("Click a tag on the cloud to edit it, or click + Add tag to create a new one."),t.appendChild(d),k.appendChild(t);return}const i=q.get(I);if(!i){I=null,Dt();return}const u=i.id,p=document.createElement("div");p.className="wpd-tagcloud__sidebar-header";const m=document.createElement("span");m.className="wpd-tagcloud__sidebar-dot",m.style.background=`hsl( ${i.hue}deg 60% 55% )`;const g=O.find(t=>t.id===u),S=document.createElement("code");S.className="wpd-tagcloud__sidebar-slug",S.textContent=`#${u}`,p.appendChild(m),p.appendChild(S),k.appendChild(p);const T=document.createElement("label");T.className="wpd-tagcloud__sidebar-label",T.textContent=x("Name"),k.appendChild(T);const H=document.createElement("input");H.type="text",H.className="wpd-tagcloud__editor-name",H.value=i.name,H.placeholder=x("Name"),k.appendChild(H);const F=document.createElement("label");F.className="wpd-tagcloud__sidebar-label",F.textContent=x("Slug"),k.appendChild(F);const $=document.createElement("input");$.type="text",$.className="wpd-tagcloud__editor-name",$.value=g?.slug||"",$.placeholder=x("auto-from-name"),$.spellcheck=!1,$.autocapitalize="off",$.addEventListener("input",()=>{const t=$.value,c=t.toLowerCase().replace(/[^a-z0-9-]+/g,"-");if(t!==c){const s=$.selectionStart??c.length;$.value=c,$.setSelectionRange(s,s)}}),k.appendChild($);const B=document.createElement("label");B.className="wpd-tagcloud__sidebar-label",B.textContent=x("Description"),k.appendChild(B);const z=document.createElement("textarea");z.className="wpd-tagcloud__editor-desc",z.value=i.description||"",z.placeholder=x("Description (optional)"),z.rows=4,k.appendChild(z);const J=document.createElement("p");J.className="wpd-tagcloud__sidebar-meta",J.textContent=jt(x("%d posts tagged with this."),i.count),k.appendChild(J);const et=document.createElement("div");et.className="wpd-tagcloud__editor-actions";const vt=document.createElement("button");vt.type="button",vt.className="wpd-tagcloud__btn wpd-tagcloud__btn--primary",vt.textContent=x("Save"),vt.addEventListener("click",async()=>{const t=H.value.trim();if(!t)return;const c=z.value,s=$.value.trim(),d=g?.slug??"";if(t===i.name&&c===(i.description||"")&&s===d)return;const y={name:t,description:c};s!==d&&(y.slug=s);try{const b=await he("tags",i.id,y);i.name=b.name,i.description=b.description,i.slug=b.slug??i.slug,i.hue=An(i.slug||i.name,ie),i.rotation=Nn(i.slug||i.name),O=O.map(h=>h.id===i.id?{...h,name:b.name,description:b.description,slug:b.slug??h.slug}:h),te(i),Dt()}catch(b){Yt(x("Couldn’t save:"),b)}});const ut=document.createElement("button");ut.type="button",ut.className="wpd-tagcloud__btn wpd-tagcloud__btn--danger",ut.textContent=x("Delete");let _t=null;const St=()=>{ut.textContent=x("Click again to delete"),ut.classList.add("is-armed"),_t!==null&&window.clearTimeout(_t),_t=window.setTimeout(()=>{ut.textContent=x("Delete"),ut.classList.remove("is-armed"),_t=null},2500)};ut.addEventListener("click",async()=>{if(!ut.classList.contains("is-armed")){St();return}_t!==null&&(window.clearTimeout(_t),_t=null);try{await Ue("tags",i.id),O=O.filter(t=>t.id!==i.id),kt.delete(i.id),je(nt,kt),I=null,ue(),st(),Dt()}catch(t){Yt(x("Couldn’t delete:"),t)}}),et.appendChild(vt),et.appendChild(ut),k.appendChild(et)}function Ve(){N=!0,Dt()}f.addEventListener("click",()=>{Ve()});function Jt(i={}){const u=i.padding??90,p=i.animate??!1,m=w.getBoundingClientRect();if(q.size===0||m.width===0||m.height===0){const St=m.width/2,t=m.height/2;Mt=1,Tt=St,At=t,p||(A.x=St,A.y=t,A.scale.set(1));return}let g=1/0,S=1/0,T=-1/0,H=-1/0;for(const St of q.values())g=Math.min(g,St.tx-St.width/2),S=Math.min(S,St.ty-St.height/2),T=Math.max(T,St.tx+St.width/2),H=Math.max(H,St.ty+St.height/2);const F=Math.max(1,T-g),$=Math.max(1,H-S),B=(m.width-u*2)/F,z=(m.height-u*2)/$,J=Math.max(.2,Math.min(1.5,Math.min(B,z))),et=(g+T)/2,vt=(S+H)/2,ut=m.width/2-et*J,_t=m.height/2-vt*J;Mt=J,Tt=ut,At=_t,p||(A.scale.set(J),A.x=ut,A.y=_t)}function pe(){if(I!==null){const i=q.get(I),u=w.getBoundingClientRect();if(i&&u.width>0&&u.height>0){const p=we+70,m=u.width*.85/(2*p),g=u.height*.85/(2*p),S=Math.max(.5,Math.min(1.6,Math.min(m,g)));Mt=S,Tt=u.width/2-i.x*S,At=u.height/2-i.y*S;return}}Jt({animate:!0})}_.addEventListener("click",()=>pe()),E.addEventListener("click",()=>{kt.clear(),je(nt,kt);for(const p of q.values())p.tx=0,p.ty=0;const i=Array.from(q.values()),u=[];i.sort((p,m)=>m.count-p.count);for(const p of i){const m=Ae(p.width,p.height,u);p.tx=m.x,p.ty=m.y,u.push({x:m.x-p.width/2,y:m.y-p.height/2,w:p.width,h:p.height})}Jt({animate:!0})}),v.canvas.addEventListener("click",i=>{const u=performance.now();if(u-M<250||u-W<250||gt>4)return;i.target===v.canvas&&!dt&&I!==null&&le()});async function We(){if(O.length===0)return;const i=ft(),u=new URL(`${i.restRoot.replace(/\/$/,"")}/desktop-mode/v1/term-counts`);u.searchParams.set("taxonomy","post_tag"),u.searchParams.set("ids",O.map(p=>p.id).join(","));try{const m=(await In(u.toString())).json;let g=!1;if(O=O.map(S=>{const T=m[String(S.id)];if(typeof T=="number"&&T!==S.count){g=!0;const H=q.get(S.id);return H&&(H.count=T),{...S,count:T}}return S}),g){const S=Math.max(1,...O.map(T=>T.count));for(const T of O){const H=q.get(T.id);H&&(H.count=T.count,H.fontSize=kn(T.count,S),te(H))}I!==null&&Dt()}}catch{}}if(st(),Dt(),Ot=requestAnimationFrame(Gt),We(),O.length===0){const i=document.createElement("div");i.className="wpd-tagcloud__empty",i.textContent=x('No tags yet. Click "Add tag" to start building the cloud.'),w.appendChild(i)}return()=>{Ot!==null&&(cancelAnimationFrame(Ot),Ot=null),Kt!==null&&(window.clearTimeout(Kt),Kt=null),de.disconnect(),w.removeEventListener("wheel",ce);try{v.destroy(!0,{children:!0,texture:!0})}catch{}e.replaceChildren(),e.classList.remove("wpd-tagcloud")}}function kn(e,n){const a=Math.sqrt(e/Math.max(1,n));return Math.round(_n+(Xo-_n)*a)}function Pn(e){return e.length>Tn?e.slice(0,Tn-1)+"…":e}function Vo(e,n){return e.xn.x&&e.yn.y}function Mn(e){let n=0;for(let a=0;a`-driven replacement for the chromeless `edit.php` iframe. Server-paginated, sortable, filterable, multi-select bulk-trash, sub-row excerpt + featured image. Per-user opt-in via **OS Settings → Features → Use the native Posts window**; the dock tile stays where it is — only the destination changes. + +> Status: **Experimental** since 0.8.0. Hook names are stable; the JS column-filter shape may grow. + +## How the swap works + +The dock tile that points at `edit.php` is unchanged. Every code path that opens an admin URL (dock click, portal deep-link, `` anywhere in the shell) consults a central registry — [`src/native-url-remap.ts`](../../src/native-url-remap.ts) — before falling back to the iframe. + +``` +User clicks the Posts dock tile + │ + ▼ +Dock.openPage(item) + │ + ▼ +tryNativeUrlRemap(item.url) ── matches "edit.php" ─┐ + │ │ + ▼ ▼ + no match nativePostsEnabled? + │ │ + ▼ ▼ +iframe edit.php openById('desktop-mode-posts') +``` + +Future native windows (Pages, Media, Users) register themselves with one line — they don't need to touch the Dock or any dispatcher. + +## Register your own URL → native-window remap + +```js +const unsub = wp.desktop.registerNativeUrlRemap( { + id: 'myplugin-pages', + nativeWindowId: 'myplugin-pages', + matches: ( _url, parsed ) => + parsed.pathname.endsWith( '/edit.php' ) && + parsed.searchParams.get( 'post_type' ) === 'page', + enabled: ( settings ) => settings.nativePagesEnabled === true, +} ); +``` + +Returning `false` from `enabled` (or returning `false` from `matches`) lets the click fall through to the iframe path. Returning a `nativeWindowId` that isn't registered for the current user (cap-gated, opt-in-gated) also falls through — `openById()` reports `false` and the registry walks on. + +> The `wp.desktop.registerNativeUrlRemap` public API will be exposed in 0.9.0; today, this same primitive is consumed internally by the bundled Posts window. + +## Filter the column descriptors + +```js +wp.hooks.addFilter( + 'desktop_mode.postsWindow.columns', + 'myplugin/word-count-column', + ( cols ) => [ + ...cols, + { + key: 'wordCount', + label: 'Words', + sortable: false, + width: '100px', + align: 'end', + render: ( _v, row ) => { + const text = ( row.excerpt?.rendered ?? '' ).replace( /<[^>]+>/g, '' ); + return text.trim() ? text.split( /\s+/ ).length.toString() : '—'; + }, + }, + ], +); +``` + +The columns render inside ``'s shadow DOM — outer stylesheets do not reach the cells. Inline styles on the returned element are the working contract. + +## End-to-end: add a Comments column + +Walks all three legs of the extensibility surface — server-side data, REST projection, JS column. The default `/wp/v2/posts` response doesn't expose a comments count, so we expose one ourselves with `register_rest_field`, ask the bundle to fetch it via the existing query-args filter, and render it via the columns filter. + +**1. Server: expose the comment count on `/wp/v2/posts`.** + +```php +add_action( 'rest_api_init', function () { + register_rest_field( 'post', 'desktop_mode_comment_count', array( + 'get_callback' => static function ( $post ) { + return (int) get_post_field( 'comment_count', $post['id'] ); + }, + 'schema' => array( + 'type' => 'integer', + 'description' => 'Approved + pending comment count for the post.', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + ) ); +} ); +``` + +**2. Server: ask the bundle to fetch the new field.** + +The Posts window sends a tight `_fields` projection on every request to keep the payload small. Append our field so it lands in the response: + +```php +add_filter( 'desktop_mode_posts_window_query_args', function ( $args ) { + $args['_fields'] .= ',desktop_mode_comment_count'; + return $args; +} ); +``` + +**3. Client: render the column.** + +```js +wp.hooks.addFilter( + 'desktop_mode.postsWindow.columns', + 'myplugin/comments-column', + ( cols ) => [ + ...cols, + { + key: 'desktop_mode_comment_count', + label: 'Comments', + sortable: true, // server orderby=comment_count + width: '110px', + align: 'end', + render: ( _v, row ) => { + const span = document.createElement( 'span' ); + const n = row.desktop_mode_comment_count ?? 0; + span.textContent = String( n ); + if ( n > 0 ) { + span.style.fontWeight = '600'; + } + return span; + }, + }, + ], +); +``` + +That's it. The column appears in every Posts window load, sorts via the server (`orderby=comment_count` is supported by core), and never makes a second round-trip per row. + +## Add a bulk action + +The default bulk action is "Move to trash". Plugins extend the registry via the `desktop_mode.postsWindow.bulkActions` filter — every entry shows up in the bulk bar when one or more rows are selected. The `run()` callback receives the selected row ids and a `PostsWindowContext` (`{ body, table, refresh, getSelectedIds, getSelectedRows, getCurrentParams }`): + +```js +wp.hooks.addFilter( + 'desktop_mode.postsWindow.bulkActions', + 'myplugin/bulk-duplicate', + ( actions ) => [ + ...actions, + { + id: 'duplicate', + label: 'Duplicate', + icon: 'dashicons-admin-page', + variant: 'secondary', + confirm: 'Duplicate %d post(s)?', + run: async ( ids ) => { + await fetch( '/wp-json/myplugin/v1/duplicate', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': wpApiSettings.nonce, + }, + body: JSON.stringify( { ids } ), + } ); + // Returning anything other than `false` triggers the + // window's auto-clear-selection + auto-refresh after + // the action resolves. + }, + }, + ], +); +``` + +`confirm` is interpolated with the row count via `%d`. Returning `false` from `run()` opts out of the auto-refresh — useful when the action navigates away or shows its own modal. + +To remove the default trash action (read-only views, audit-style mirrors), filter it out by id: + +```js +wp.hooks.addFilter( + 'desktop_mode.postsWindow.bulkActions', + 'myplugin/no-trash', + ( actions ) => actions.filter( ( a ) => a.id !== 'trash' ), +); +``` + +## Add a status segment + +The segmented control above the table is built from the (filterable) status list. CPTs that register custom statuses can surface them here: + +```js +wp.hooks.addFilter( + 'desktop_mode.postsWindow.statusSegments', + 'myplugin/awaiting-review', + ( segs ) => [ + ...segs, + { value: 'awaiting-review', label: 'Awaiting review' }, + ], +); +``` + +The `value` is sent verbatim as the REST `?status=…` param. Use `''` (empty string) for the "All" sentinel — the bundle remaps that to `?status=any` so the user sees every status they can edit. + +## Add a button to the toolbar + +The trailing slot sits before the built-in **Refresh** + **Add New** buttons: + +```js +wp.hooks.addFilter( + 'desktop_mode.postsWindow.toolbarTrailing', + 'myplugin/export-button', + ( elements, ctx ) => { + const btn = document.createElement( 'wpd-button' ); + btn.setAttribute( 'variant', 'ghost' ); + btn.textContent = 'Export CSV'; + btn.addEventListener( 'click', () => { + const params = ctx.getCurrentParams(); + window.open( `/wp-json/myplugin/v1/posts/export?status=${ params.status ?? 'any' }` ); + } ); + return [ ...elements, btn ]; + }, +); +``` + +The filter receives a fresh array on every window open (and an empty default), plus the live `PostsWindowContext` so the button can refresh, read the selection, or read the current view params at click time. + +## React to lifecycle events + +Two actions on the hook bus, both with matching CustomEvents on `document`: + +```js +// Fired AFTER the first paint with a populated table. +wp.hooks.addAction( + 'desktop_mode.postsWindow.opened', + 'myplugin/track-open', + ( ctx ) => { + analytics.track( 'posts-window-opened', { + count: ctx.table.data?.length ?? 0, + } ); + }, +); + +// Fired after every successful refresh (initial + every search / +// sort / pagination change). +wp.hooks.addAction( + 'desktop_mode.postsWindow.dataLoaded', + 'myplugin/track-page', + ( payload ) => { + analytics.track( 'posts-window-page', { + page: payload.page, + total: payload.total, + } ); + }, +); + +// CustomEvent equivalents — same payloads. +document.addEventListener( 'desktop-mode-posts-window-opened', ( e ) => { /* … */ } ); +document.addEventListener( 'desktop-mode-posts-window-data-loaded', ( e ) => { /* … */ } ); +``` + +The `opened` action's `ctx` is the same `PostsWindowContext` passed to bulk-action runners — read the table, fire `ctx.refresh()`, etc. + +## Restrict who sees the window + +```php +add_filter( 'desktop_mode_posts_window_user_can_use', function ( $can, $user_id ) { + return $can && user_can( $user_id, 'edit_others_posts' ); +}, 10, 2 ); +``` + +The default gate is `edit_posts` AND the user has flipped the toggle on. Returning `false` here forces the classic chromeless `edit.php` iframe to remain the destination. + +## Point the window at a CPT + +```php +add_filter( 'desktop_mode_posts_window_query_args', function ( $args ) { + $args['post_type'] = 'product'; + return $args; +} ); +``` + +The bundle threads `post_type` straight through to `/wp/v2/posts` (or, if your CPT registers its own REST base, swap `postsUrl` via `desktop_mode_posts_window_args`). v1 ships with `post`; v1.1 will add a CPT picker in the toolbar. + +## Add a custom REST query param + +```php +add_filter( 'desktop_mode_posts_window_query_args', function ( $args ) { + $args['meta_key'] = 'featured'; + $args['meta_value'] = '1'; + return $args; +} ); +``` + +Anything `/wp/v2/posts` accepts is fair game — `meta_*`, `categories_exclude`, `sticky`, etc. + +## React to a bulk trash + +The window broadcasts `desktop-mode.post.changed` after every bulk trash. Subscribe to keep your own UI in sync without re-fetching: + +```js +const unsub = wp.desktop.subscribe( 'desktop-mode.post.changed', ( payload ) => { + if ( payload.source !== 'posts-window' ) { + return; + } + console.log( 'posts trashed:', payload.ids ); +} ); +``` + +The recycle bin window is already a subscriber — that's how trashing 12 posts here makes the bin tile's badge tick up to 12 without a refresh. + +## Hooks reference (Experimental, 0.8.0) + +PHP: +- `desktop_mode_posts_window_user_can_use( $can, $user_id )` — gate. Default: `edit_posts` + opt-in. +- `desktop_mode_posts_window_args( $args )` — args passed to `desktop_mode_register_window()` (title, icon, dimensions, config blob). +- `desktop_mode_posts_window_template_html( $html )` — the rendered template HTML before `wp_kses`. +- `desktop_mode_posts_window_query_args( $args )` — outbound REST query params (`_fields`, `_embed`, `post_type`). + +JavaScript: +- `desktop_mode.postsWindow.columns` (filter) — column descriptors before `table.columns =` is set. + +CustomEvents / broadcasts: +- `desktop-mode.post.changed` — broadcast on bulk trash; `{ source: 'posts-window', action: 'trashed', ids }`. diff --git a/docs/examples/window-activity.md b/docs/examples/window-activity.md new file mode 100644 index 00000000..9b9c67fc --- /dev/null +++ b/docs/examples/window-activity.md @@ -0,0 +1,161 @@ +# Example: window activity & the modem dot + +Every desktop window's title bar has a small **modem-style activity LED** sitting between the icon and the title. At rest it's a hollow ring tinted with the user's accent color — a calm "alive, ready" affordance. While work is in flight it blinks like a 1990s data modem; on success it briefly fills in green; on failure it goes solid red with the error message as a tooltip. + +> Status: **Experimental** since 0.8.0. + +## The shortest possible adoption + +Use `wp.desktop.fetch` instead of the global `fetch`: + +```js +// Before: +const res = await fetch( '/wp-json/myplugin/v1/save', { method: 'POST' } ); + +// After: +const res = await wp.desktop.fetch( '/wp-json/myplugin/v1/save', { method: 'POST' } ); +``` + +That's it. The title-bar dot blinks for the round-trip, flashes green on success, red on failure (with the error message as tooltip). No CSS, no DOM, no per-window plumbing. + +## Where it lights up + +By default, `wp.desktop.fetch` attributes the request to the **focused window** at the moment of the call. Most fetches happen inside event handlers — clicks, key presses, form submits — and the click already focused the window. So in 95% of cases the default attribution is correct. + +For the 5% where focus isn't your friend, pass an explicit attribution: + +```js +// You have the window's id (most native-window bundles know their own id): +wp.desktop.fetch( url, init, { windowId: 'my-plugin/inbox' } ); + +// You have a Window instance in scope: +wp.desktop.fetch( url, init, { window: ctx.window } ); + +// Don't blink for this fetch (background polls, prefetches): +wp.desktop.fetch( url, init, { silent: true } ); +``` + +## Bundle-level migration recipe + +Wrap the bundle's fetch helper once, then every call inherits the indicator: + +```js +// my-plugin/rest.js +function shellFetch( input, init ) { + if ( window.wp?.desktop?.fetch ) { + return wp.desktop.fetch( input, init, { windowId: 'my-plugin/inbox' } ); + } + return fetch( input, init ); +} + +export async function fetchInbox() { + return ( await shellFetch( '/wp-json/myplugin/v1/inbox' ) ).json(); +} +export async function archive( id ) { + return shellFetch( `/wp-json/myplugin/v1/inbox/${ id }/archive`, { + method: 'POST', + } ); +} +``` + +Every call site (`fetchInbox`, `archive`) now lights up the inbox window's title-bar dot, with no per-call adoption. + +## Non-fetch async work + +When the operation isn't a single fetch — a `postMessage` handshake, an IndexedDB write, a `BroadcastChannel` round-trip, a long client-side computation — reach for `Window.trackActivity( promise )`: + +```js +const win = wp.desktop.windowManager.getById( 'my-plugin/dashboard' ); + +// Single Promise: +await win.trackActivity( indexedDbWrite( record ) ); + +// Sequence: +await win.trackActivity( ( async () => { + const a = await load(); + const b = await transform( a ); + await commit( b ); +} )() ); +``` + +Returns the Promise unchanged so callers can chain. The minimum 1.8s saving-display floor still applies, so even a 100ms operation shows a full modem cycle. + +## Streaming / event-driven flows + +For activity that doesn't map to a single Promise — an SSE stream, a WebSocket, a chained subscription — drive the phase manually with `Window.markActivity()`: + +```js +win.markActivity( 'saving' ); + +const sse = new EventSource( '/wp-json/myplugin/v1/stream' ); +sse.addEventListener( 'data', applyChunk ); +sse.addEventListener( 'end', () => { + sse.close(); + win.markActivity( 'saved' ); +} ); +sse.addEventListener( 'error', ( err ) => { + sse.close(); + win.markActivity( 'failed', { error: 'Connection lost' } ); +} ); +``` + +Phases: + +| Phase | Visual | Auto-clears | +|---|---|---| +| `'idle'` | Always-on hollow ring (accent color). | — | +| `'pending'` / `'saving'` | Filled, modem-blink with soft glow. | No | +| `'saved'` | Brief green fill. | After 2.2s | +| `'failed'` | Solid red. `opts.error` → tooltip. | After 6s | + +`markActivity()` is idempotent — setting the same phase twice is a no-op except for resetting the auto-clear timer. + +## Concurrent fetches + +`Window.trackActivity` is **reference-counted**, so concurrent operations on the same window don't fight: + +```js +// Two fetches in parallel — dot stays lit until the LAST one settles. +await Promise.all( [ + wp.desktop.fetch( urlA ), + wp.desktop.fetch( urlB ), +] ); +``` + +The terminal phase reflects the **last settled outcome**. A burst of 5 successful fetches followed by 1 error reads "failed" — surface the bad news; the user wants to know. + +## Subtle UX choices the framework already made + +**Minimum 1.8s saving display** — even a 50ms fetch holds the saving phase for ~1.8s so the modem-blink animation has time to register. Concurrent fetches that re-start within the floor cancel any deferred settle, so chained operations keep blinking smoothly without dropping into "saved" between calls. + +**Always-on idle ring** — at rest, the dot is a 12px hollow circle with a 2px border tinted by the user's accent (`color-mix(in srgb, var(--wp-admin-theme-color) 55%, transparent)`). It looks like a real modem's "ready" LED — quietly present, not flashing, not invisible. + +**Drift-by-design animation** — the modem stutter cycles at 1.8s, the soft-glow halo at 2.4s. The two periods are coprime, so the LCM puts the next true cycle repeat at 21.6s — the pattern never reads as a metronome. + +**Reduced-motion** — users with `prefers-reduced-motion: reduce` get a calm solid-on dot during saving (no animation, same affordance). + +## What about non-fetch HTTP calls (XHR, sendBeacon)? + +Wrap them in a Promise and hand to `Window.trackActivity`: + +```js +function trackedXhr( url, body, win ) { + return win.trackActivity( new Promise( ( resolve, reject ) => { + const xhr = new XMLHttpRequest(); + xhr.open( 'POST', url ); + xhr.onload = () => + xhr.status >= 200 && xhr.status < 300 + ? resolve( xhr.response ) + : reject( new Error( `${ xhr.status } ${ xhr.statusText }` ) ); + xhr.onerror = () => reject( new Error( 'Network error' ) ); + xhr.send( body ); + } ) ); +} +``` + +`fetch` covers the vast majority of cases; this pattern is the escape hatch. + +## See also + +- [`docs/javascript-reference.md`](../javascript-reference.md#wpdesktopfetch--input-init-opts---experimental-since-080) — full API surface. +- [``](./components-reference.md#wpd-save-status) — the standalone component the title-bar indicator uses. Drop one anywhere (panel headers, plugin own settings forms, custom toolbars) — it auto-listens to a configurable CustomEvent and renders the same modem dot. diff --git a/docs/hooks-reference.md b/docs/hooks-reference.md index 70d99039..6f89ceeb 100644 --- a/docs/hooks-reference.md +++ b/docs/hooks-reference.md @@ -1514,6 +1514,109 @@ See [`docs/examples/recycle-bin.md`](./examples/recycle-bin.md) for end-to-end r --- +## Native Posts window + +``-driven native window that replaces the chromeless `edit.php` iframe behind a per-user opt-in toggle (**OS Settings → Features → Use the native Posts window**, persisted as `OsSettingsState.nativePostsEnabled`). The dock tile that points at `edit.php` is unchanged — every click path consults the URL → native-window remap registry first and falls back to the iframe on no-match. See [`examples/native-posts.md`](./examples/native-posts.md) for end-to-end recipes. + +### `desktop_mode_posts_window_user_can_use` — Stable *(filter, since 0.8.0)* + +The two-condition gate: `edit_posts` AND the user has flipped the opt-in. Returning `false` here forces the classic chromeless `edit.php` iframe to remain the destination. + +```php +apply_filters( 'desktop_mode_posts_window_user_can_use', bool $can, int $user_id ); +``` + +Use cases: +- Force the window on for everyone on a managed install. +- Restrict to `edit_others_posts` on a multi-author site so contributors stay on the iframe. +- Per-user A/B rollouts driven by an external flag store. + +### `desktop_mode_posts_window_args` — Experimental *(filter, since 0.8.0)* + +Args passed to `desktop_mode_register_window( 'desktop-mode-posts', … )`. Customize the title / icon / dimensions, or extend the `config` blob with extra REST URLs the bundle should know about. + +```php +apply_filters( 'desktop_mode_posts_window_args', array $args ); +``` + +### `desktop_mode_posts_window_template_html` — Experimental *(filter, since 0.8.0)* + +The full template body before it's `wp_kses`'d into the native-window template element. Keep the `data-desktop-mode-posts-*` hooks intact so the JS bundle can find its mount points (search input, status segmented, table, bulk bar, pager). + +```php +apply_filters( 'desktop_mode_posts_window_template_html', string $html ); +``` + +### `desktop_mode_posts_window_query_args` — Experimental *(filter, since 0.8.0)* + +Default outbound REST query args the bundle merges into every `/wp/v2/posts` request. Drop in `'post_type' => 'product'` to point the window at a CPT, or extend `_fields` to ship more columns. The bundle merges page / per_page / search / status / sort args on top. + +```php +apply_filters( 'desktop_mode_posts_window_query_args', array $args ); +``` + +Default args: + +```php +[ + '_embed' => 'author,wp:term,wp:featuredmedia', + '_fields' => 'id,title,status,date,date_gmt,modified,modified_gmt,author,categories,tags,comment_status,excerpt,_links,_embedded', +] +``` + +### JS extension points + +Every JS hook below is also documented on `wp.hooks` so plugins can register with priorities + namespaces. Filter signatures match `wp.hooks.applyFilters( name, default, ...args )`. + +| Hook | Type | Default | Args / Detail | +|---|---|---|---| +| `desktop_mode.postsWindow.columns` | filter | built-in 5 columns | `WpdTableColumn< PostListItem >[]` — append, replace, or remove cells. | +| `desktop_mode.postsWindow.statusSegments` | filter | All / Published / Drafts / Pending / Scheduled / Trash | `StatusSegment[]` — `{ value, label }` pairs. `value` is sent verbatim as `?status=…`; use `''` for "All" (the bundle remaps to `?status=any`). | +| `desktop_mode.postsWindow.bulkActions` | filter | one entry: "Move to trash" | `BulkAction[]` — `{ id, label, icon?, variant?, confirm?, run( ids, ctx ) }`. Filter out by id to remove. | +| `desktop_mode.postsWindow.toolbarTrailing` | filter | `[]` | `HTMLElement[]` rendered before Refresh + Add New. Receives the live `PostsWindowContext` as the second arg. | +| `desktop_mode.postsWindow.opened` | action | — | `( ctx: PostsWindowContext )` — fired after the first paint with a populated table. | +| `desktop_mode.postsWindow.dataLoaded` | action | — | `( payload: { items, total, totalPages, page } )` — fired after every successful refresh. | + +`PostsWindowContext`: `{ body, table, refresh(), getSelectedIds(), getSelectedRows(), getCurrentParams() }` — see [`src/posts-window/types.ts`](../src/posts-window/types.ts) for the full TypeScript surface. + +### CustomEvents (same payloads as the hook-bus actions) + +```js +document.addEventListener( 'desktop-mode-posts-window-opened', e => /* e.detail = PostsWindowContext */ ); +document.addEventListener( 'desktop-mode-posts-window-data-loaded', e => /* e.detail = { items, total, totalPages, page } */ ); +``` + +### Cross-window broadcast + +```js +wp.desktop.broadcast( 'desktop-mode.post.changed', { + source: 'posts-window', + action: 'trashed', + ids: number[], +} ); +``` + +Fired after every bulk trash. The recycle bin and any other listener are cross-window subscribers via `wp.desktop.subscribe`. + +### URL → native-window remap registry + +Centralized in `src/native-url-remap.ts`. Every code path that opens an admin URL (dock click, portal deep-link, `` anywhere in the shell) consults `tryNativeUrlRemap()` before falling back to the iframe. Future native windows (Pages, Media, Users) register themselves with one line: + +```js +wp.desktop.registerNativeUrlRemap( { // public API in 0.9.0; internal today + id: 'myplugin-pages', + nativeWindowId: 'myplugin-pages', + matches: ( _url, parsed ) => + parsed.pathname.endsWith( '/edit.php' ) && + parsed.searchParams.get( 'post_type' ) === 'page', + enabled: ( settings ) => settings.nativePagesEnabled === true, +} ); +``` + +Returning `false` from `enabled` (or `matches`) lets the click fall through. An `openById( nativeWindowId )` call that reports the window isn't registered for the current user (cap-gated, opt-in-gated) also falls through — the registry walks on to the next entry, then to the iframe path. + +--- + ## Presence Framework-level presence tracking. Storage in diff --git a/docs/javascript-reference.md b/docs/javascript-reference.md index 1ae2e472..990a3ae8 100644 --- a/docs/javascript-reference.md +++ b/docs/javascript-reference.md @@ -630,6 +630,88 @@ For programmatic deep-linking into the **Code editor** specifically (open + jump --- +### `wp.desktop.fetch( input, init?, opts? )` — Experimental *(since 0.8.0)* + +Drop-in wrapper around the global `fetch()` that lights up the target window's title-bar **modem activity dot** while the request is in flight. Same return type and resolution semantics as native `fetch()` — callers can `.then(r => r.json())` / `await` / `catch` unchanged. + +```js +// In any window's render callback / event handler: +const res = await wp.desktop.fetch( '/wp-json/myplugin/v1/save', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': nonce }, + body: JSON.stringify( payload ), +} ); +``` + +That's the whole pattern. The dot blinks for the duration of the round-trip, flashes green on `2xx` and red on failure (with the `Error.message` exposed as the dot's tooltip), then settles back to the always-on idle ring. **No CSS, no per-window plumbing, no DOM.** + +#### Arguments + +| Arg | Type | Description | +|---|---|---| +| `input` | `RequestInfo \| URL` | Same as native `fetch`. | +| `init` | `RequestInit?` | Same as native `fetch`. | +| `opts` | `{ windowId?: string; window?: Window; silent?: boolean }?` | Attribution + opt-out. | + +`opts` is the only addition. Resolution order for "which window's title bar pulses": + +1. **`opts.window`** — explicit `Window` reference. Use when you have the handle in scope (e.g. inside a render callback that received `ctx.window`). +2. **`opts.windowId`** — id looked up via `wp.desktop.windowManager.getById(id)`. Use when you have the id but not the instance (it's the most common case for native-window bundles — they know their own id from `desktop_mode_register_window( '…' )`). +3. **focused window** — `manager.getFocused()`. Default. So inside a click handler, the click already focused the window and the fetch attributes to it without any extra wiring. + +`opts.silent: true` skips the indicator entirely. Reserved for background polls (heartbeat, presence, count-bumps) that shouldn't blink the title bar every tick. The fetch is otherwise identical. + +#### Why it works + +Internally, `wp.desktop.fetch` calls `Window.trackActivity( promise )` on the resolved target. The window enforces a **minimum saving-display time of 1.8s** so even a 50ms fetch shows a full modem-blink cycle before flashing green — fast successes don't get lost between the click and the next paint. Concurrent fetches reference-count: 5 in-flight settles on the **last** one (and inherits the **last error** if any failed), matching the user's "is anything still happening?" mental model. + +#### Migration tip + +You don't need to migrate everything. Bundles that currently call native `fetch` keep working unchanged — they just don't show a title-bar pulse. Adopt `wp.desktop.fetch` per call where the indicator is valuable: REST mutations (saves, deletes, tag-add/remove), data refreshes that take more than a frame, anything users would otherwise wonder "did that work?". Keep using native `fetch` for fire-and-forget telemetry, prefetches, anything users shouldn't notice. + +#### Source + +`src/desktop.ts` `trackedFetch`. The component the dot is rendered with is [``](#wpd-save-status--experimental-since-080) — read on for the standalone component, plus `Window.trackActivity` / `Window.markActivity` for non-fetch async work. + +See also [`examples/window-activity.md`](./examples/window-activity.md) for end-to-end recipes. + +--- + +### `Window.trackActivity( promise )` — Experimental *(since 0.8.0)* + +The lower-level primitive `wp.desktop.fetch()` is built on. Call it directly when you have a Promise from a non-fetch source — a `postMessage` handshake, an IndexedDB transaction, a `BroadcastChannel` round-trip, a long client-side computation wrapped in `requestAnimationFrame` chains. + +```js +const win = wp.desktop.windowManager.getById( 'my-plugin/inbox' ); +await win.trackActivity( indexedDbWrite( record ) ); +``` + +Returns the Promise unchanged so callers can chain. Multiple concurrent calls are reference-counted and the **minimum 1.8s saving-display floor** still applies — so even a 100ms IDB write shows a full modem cycle. + +### `Window.markActivity( phase, opts? )` — Experimental *(since 0.8.0)* + +Manual escape hatch when the activity isn't a single Promise. Phases: + +- `'idle'` — clear. Indicator resets to the always-on green ring. +- `'pending'` / `'saving'` — modem-blink with a soft glow. Stays in this phase until you transition out. +- `'saved'` — brief green flash. Auto-clears to `idle` after ~2.2s. +- `'failed'` — red dot. `opts.error` becomes the host's `title` attribute (and so the native browser tooltip on hover). Auto-clears after ~6s. + +```js +win.markActivity( 'saving' ); +streamingSubscriber.on( 'data', () => { + /* … */ +} ); +streamingSubscriber.on( 'end', () => win.markActivity( 'saved' ) ); +streamingSubscriber.on( 'error', ( err ) => { + win.markActivity( 'failed', { error: err.message } ); +} ); +``` + +Idempotent. Setting the same phase twice is a no-op except for resetting the auto-clear timer. + +--- + ### `wp.desktop.getWindowConfig( id )` — Stable *(since 0.6.0)* Read the bundle-bound config blob shipped via the `'config'` arg on `desktop_mode_register_window( $id, [ 'config' => … ] )`. Returns `undefined` when no config was registered for `id`. diff --git a/includes/assets.php b/includes/assets.php index 0a220b39..247a92d5 100644 --- a/includes/assets.php +++ b/includes/assets.php @@ -96,6 +96,17 @@ function desktop_mode_register_assets() { file_exists( $recycle_bin_css ) ? (string) filemtime( $recycle_bin_css ) : $version ); + // `filemtime` for the native Posts window CSS — same rationale as + // the recycle-bin CSS: bundle iterates faster than the plugin + // version and stale caches are worse than the cost of a 304. + $posts_window_css = DESKTOP_MODE_DIR . 'assets/css/posts-window.css'; + wp_register_style( + 'desktop-mode-posts-window', + DESKTOP_MODE_URL . 'assets/css/posts-window.css', + array( 'desktop-mode-variables', 'dashicons' ), + file_exists( $posts_window_css ) ? (string) filemtime( $posts_window_css ) : $version + ); + // Scripts. // // `wp-hooks` — the shell exposes a WordPress-style filter/action @@ -156,6 +167,25 @@ function desktop_mode_register_assets() { DESKTOP_MODE_DIR . 'languages' ); + // `desktop-mode-posts-window` — small bundle for the native Posts + // window. Lazy-loaded by the native-window sync the first time the + // window opens (via the dock-click swap when the user opts in); + // registers a render callback on + // `window.desktopModeNativeWindows['desktop-mode-posts']`. + $posts_window_js = DESKTOP_MODE_DIR . 'assets/js/posts-window' . $suffix . '.js'; + wp_register_script( + 'desktop-mode-posts-window', + DESKTOP_MODE_URL . 'assets/js/posts-window' . $suffix . '.js', + array( 'wp-i18n' ), + file_exists( $posts_window_js ) ? (string) filemtime( $posts_window_js ) : $version, + true + ); + wp_set_script_translations( + 'desktop-mode-posts-window', + 'desktop-mode', + DESKTOP_MODE_DIR . 'languages' + ); + // Wire the translation bundle to this script handle. WP looks // for `languages/desktop-mode-{locale}-desktop-mode.json` and // injects its `locale_data` into `wp.i18n` just before the diff --git a/includes/components.php b/includes/components.php index 6add17dc..d47b746b 100644 --- a/includes/components.php +++ b/includes/components.php @@ -1624,9 +1624,13 @@ function desktop_mode_native_window_allowed_html() { 'badge' => true, 'selectable' => true, 'sticky-header' => true, + 'sticky-columns' => true, 'hover' => true, 'striped' => true, + 'bordered' => true, + 'compact' => true, 'loading' => true, + 'loading-rows' => true, 'columns' => true, 'rows' => true, 'sortable' => true, diff --git a/includes/os-settings.php b/includes/os-settings.php index 7d130355..17ad31fc 100644 --- a/includes/os-settings.php +++ b/includes/os-settings.php @@ -79,6 +79,16 @@ function desktop_mode_default_os_settings() { 'apiKeys' => array(), // Per-provider keys: { [provider_id]: string }. 'transport' => 'off', // Live-progress transport: 'sse' | 'off'. Default off — see DESKTOP_MODE_OS_SETTINGS_AI_TRANSPORTS. ), + // Per-user opt-in for the native Posts window. When true, clicking + // the Posts dock tile opens the ``-driven native window + // instead of the chromeless `edit.php` iframe. Default off so + // existing muscle memory survives an upgrade. + 'nativePostsEnabled' => false, + // Per-user list of column keys hidden in the native Posts + // window (e.g. array( 'author', 'tags' )). Empty array means + // every column is visible. The sticky 'title' column is always + // shown — the UI prevents toggling it. + 'nativePostsHiddenColumns' => array(), ); } @@ -272,16 +282,41 @@ function desktop_mode_sanitize_os_settings( $raw ) { } } + $native_posts_enabled = isset( $raw['nativePostsEnabled'] ) + ? (bool) $raw['nativePostsEnabled'] + : $defaults['nativePostsEnabled']; + + $native_posts_hidden_columns = $defaults['nativePostsHiddenColumns']; + if ( isset( $raw['nativePostsHiddenColumns'] ) && is_array( $raw['nativePostsHiddenColumns'] ) ) { + $native_posts_hidden_columns = array(); + foreach ( $raw['nativePostsHiddenColumns'] as $col ) { + if ( ! is_string( $col ) || '' === $col ) { + continue; + } + $slug = sanitize_key( $col ); + if ( '' === $slug ) { + continue; + } + $native_posts_hidden_columns[] = $slug; + } + // Cap to a sane upper bound — far more than any plausible + // column count, but blocks a malicious payload from bloating + // user meta indefinitely. + $native_posts_hidden_columns = array_slice( array_values( array_unique( $native_posts_hidden_columns ) ), 0, 32 ); + } + return array( - 'wallpaper' => $wallpaper, - 'accent' => $accent, - 'dockSize' => $dock_size, - 'desktopLayout' => $desktop_layout, - 'dockRailRenderer' => $dock_rail_renderer, - 'customGradient' => $custom_gradient, - 'customImage' => $custom_image, - 'libraryHdOnly' => $library_hd_only, - 'ai' => $ai, + 'wallpaper' => $wallpaper, + 'accent' => $accent, + 'dockSize' => $dock_size, + 'desktopLayout' => $desktop_layout, + 'dockRailRenderer' => $dock_rail_renderer, + 'customGradient' => $custom_gradient, + 'customImage' => $custom_image, + 'libraryHdOnly' => $library_hd_only, + 'ai' => $ai, + 'nativePostsEnabled' => $native_posts_enabled, + 'nativePostsHiddenColumns' => $native_posts_hidden_columns, ); } diff --git a/includes/posts-window/bootstrap.php b/includes/posts-window/bootstrap.php new file mode 100644 index 00000000..1997d102 --- /dev/null +++ b/includes/posts-window/bootstrap.php @@ -0,0 +1,29 @@ +` and core's `/wp/v2/posts` REST endpoint, behind + * a per-user opt-in (`OsSettingsState.nativePostsEnabled`, surfaced as + * the "Use the native Posts window" toggle in OS Settings → Features). + * + * Public PHP surface (all filterable): + * + * - desktop_mode_posts_window_user_can_use + * - desktop_mode_posts_window_args + * - desktop_mode_posts_window_template_html + * - desktop_mode_posts_window_query_args + * + * The dock-side swap (Posts tile → native window when opt-in is on) is + * implemented JS-side in `src/dock.ts` via a cancelable + * `desktop-mode-dock-open-request` document event — this module owns + * only the window itself, not the entry point. + * + * @package WPDesktopMode + * @since 0.8.0 + */ + +defined( 'ABSPATH' ) || exit; + +require_once __DIR__ . '/permissions.php'; +require_once __DIR__ . '/window.php'; diff --git a/includes/posts-window/permissions.php b/includes/posts-window/permissions.php new file mode 100644 index 00000000..066125a2 --- /dev/null +++ b/includes/posts-window/permissions.php @@ -0,0 +1,110 @@ + 0 && user_can( $user_id, 'edit_posts' ); + + /** + * Filter whether the current user is eligible to have the native + * Posts window registered. This is the boot-time check; runtime + * "should the dock click use the native window?" is the JS-side + * `nativePostsEnabled` flag. + * + * Returning `false` skips the entire registration — no script + * handle, no template, no entry in the native-window registry. + * + * @since 0.8.0 + * + * @param bool $can Default: `edit_posts` capability. + * @param int $user_id User being checked. + */ + return (bool) apply_filters( + 'desktop_mode_posts_window_user_can_register', + $can, + $user_id + ); +} + +/** + * Whether the user has opted into the native Posts experience. + * Cap-and-opt-in check — kept for any caller that needs the combined + * answer (e.g. analytics, an arrange-menu entry). Boot registration + * uses {@see desktop_mode_posts_window_user_can_register()} instead; + * runtime dock-click swap uses the JS-side snapshot. + * + * @since 0.8.0 + * + * @param int|null $user_id Optional. Defaults to `get_current_user_id()`. + * @return bool + */ +function desktop_mode_posts_window_user_can_use( $user_id = null ) { + $user_id = null === $user_id ? get_current_user_id() : (int) $user_id; + + $cap_ok = desktop_mode_posts_window_user_can_register( $user_id ); + + $opt_in = false; + if ( $cap_ok && function_exists( 'desktop_mode_get_os_settings' ) ) { + $settings = desktop_mode_get_os_settings( $user_id ); + $opt_in = ! empty( $settings['nativePostsEnabled'] ); + } + + $can = $cap_ok && $opt_in; + + /** + * Filter whether the current user has opted into the native Posts + * experience. + * + * Default: `edit_posts` AND the user has flipped the toggle in OS + * Settings → Features. Returning `false` does NOT prevent + * registration (toggle on/off remains live without F5); it only + * affects callers that ask the combined question. + * + * @since 0.8.0 + * + * @param bool $can Default gate result. + * @param int $user_id User being checked. + */ + return (bool) apply_filters( 'desktop_mode_posts_window_user_can_use', $can, $user_id ); +} diff --git a/includes/posts-window/window.php b/includes/posts-window/window.php new file mode 100644 index 00000000..2082527f --- /dev/null +++ b/includes/posts-window/window.php @@ -0,0 +1,525 @@ +` and + * clones it into the window body BEFORE the JS render callback fires. + * The `data-desktop-mode-posts-*` hooks below are the contract the JS + * relies on — keep them intact (or rename via the filter) when + * customizing the layout. + * + * @package WPDesktopMode + * @since 0.8.0 + */ + +defined( 'ABSPATH' ) || exit; + +/** + * Echoes the native Posts window's template body. + * + * @since 0.8.0 + */ +function desktop_mode_posts_window_render_template() { + ob_start(); + ?> +
+ + + + + + + +
+
+ + + +
+ +
+ + + + + + + + + +
+
+
+ +
+ +

+

+ +

+
+
+
+
+
+ +
+
+ + + + + + + + + +
+
+
+ + +
+
+ + +
+
+
+ s.nativePostsEnabled === true` in + // `src/desktop.ts`). Registering for every cap-eligible user is + // cheap — the script + template + REST nonce all live in the + // payload only, the actual fetch only happens when the user + // opens the window. + if ( ! desktop_mode_posts_window_user_can_register() ) { + return; + } + + $window_args = array( + 'title' => __( 'Posts', 'desktop-mode' ), + 'icon' => 'dashicons-admin-post', + 'template' => 'desktop_mode_posts_window_render_template', + 'script' => 'desktop-mode-posts-window', + 'style' => 'desktop-mode-posts-window', + 'width' => 1100, + 'height' => 720, + 'min_width' => 720, + 'min_height' => 480, + // `'none'` — no dock or wallpaper tile from this registration. + // The Posts dock tile lives in WordPress's `$menu` and the + // JS-side dock intercept routes its click to `openById` here + // when the opt-in is on. A separate tile would be a duplicate + // entry point. + 'placement' => 'none', + 'config' => array( + 'restRoot' => esc_url_raw( rest_url() ), + 'restNonce' => wp_create_nonce( 'wp_rest' ), + 'postsUrl' => esc_url_raw( rest_url( 'wp/v2/posts' ) ), + 'editPostUrlBase' => esc_url_raw( admin_url( 'post.php' ) ), + 'newPostUrl' => esc_url_raw( admin_url( 'post-new.php' ) ), + 'usersUrl' => esc_url_raw( rest_url( 'wp/v2/users' ) ), + 'currentUserId' => (int) get_current_user_id(), + 'defaultPerPage' => 20, + 'queryArgs' => desktop_mode_posts_window_default_query_args(), + ), + ); + + /** + * Filter the args used to register the native Posts window. + * + * @since 0.8.0 + * + * @param array $window_args Args passed to `desktop_mode_register_window()`. + */ + $window_args = (array) apply_filters( 'desktop_mode_posts_window_args', $window_args ); + + $registered = desktop_mode_register_window( 'desktop-mode-posts', $window_args ); + if ( is_wp_error( $registered ) ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( '[desktop-mode] Native Posts window registration failed: ' . $registered->get_error_message() ); + } +} +add_action( 'init', 'desktop_mode_posts_window_register_window', 20 ); + +/** + * Default REST query args the JS bundle uses on every list fetch. + * + * Filterable so a plugin can flip the post type to a custom CPT (or + * a list of CPTs) without forking the bundle. The JS bundle merges + * these on top of the page/per-page/search/status/sort args it + * generates per request. + * + * @since 0.8.0 + * + * @return array + */ +function desktop_mode_posts_window_default_query_args() { + $args = array( + // `_embed` pulls author + taxonomy + featured-media side-loads + // into `_embedded`, so the table can render avatars, term + // chips, and thumbnails without N extra round-trips per row. + '_embed' => 'author,wp:term,wp:featuredmedia', + '_fields' => + 'id,title,status,date,date_gmt,modified,modified_gmt,author,categories,tags,comment_status,excerpt,_links,_embedded', + ); + + /** + * Filter the default outbound REST query args for the Posts window. + * + * Drop in a `'post_type' => 'product'` to point the window at a + * CPT, or extend `_fields` to ship more columns. The bundle merges + * these on top of pagination/search/status/sort args. + * + * @since 0.8.0 + * + * @param array $args Default args. + */ + return (array) apply_filters( 'desktop_mode_posts_window_query_args', $args ); +} + +/** + * Switch the post-tag tax_query operator from the WP REST default `IN` + * (any-of, OR) to `AND` (every-of, intersection) when the Posts window + * client opts in via the `desktop_mode_tags_match=all` URL flag. + * + * The flag is sent only when more than one tag is selected — single- + * tag queries are unaffected because AND with one term is identical + * to IN. The filter is scoped to GET `/wp/v2/posts`-style queries so + * other endpoints' tax_query semantics aren't touched. + * + * @since 0.8.0 + * + * @param array $args `WP_Query` args after the REST controller + * prepared them. + * @param WP_REST_Request $request Active REST request. + * @return array Possibly-mutated args. + */ +function desktop_mode_posts_window_tags_and_filter( $args, $request ) { + if ( ! ( $request instanceof WP_REST_Request ) ) { + return $args; + } + $flag = $request->get_param( 'desktop_mode_tags_match' ); + if ( 'all' !== $flag ) { + return $args; + } + if ( empty( $args['tax_query'] ) || ! is_array( $args['tax_query'] ) ) { + return $args; + } + foreach ( $args['tax_query'] as $key => $clause ) { + if ( ! is_array( $clause ) ) { + continue; + } + if ( isset( $clause['taxonomy'] ) && 'post_tag' === $clause['taxonomy'] ) { + $args['tax_query'][ $key ]['operator'] = 'AND'; + } + } + return $args; +} +add_filter( 'rest_post_query', 'desktop_mode_posts_window_tags_and_filter', 10, 2 ); + +/** + * Surface a "non-trashed posts" count alongside core's `count` field + * on category + post_tag REST responses. + * + * Core's `term_taxonomy.count` is updated by + * `_update_post_term_count()`, which only includes posts in the + * `publish` status by default. The Categories + Tags tabs in the + * native Posts window want to surface DRAFT and PENDING posts too, + * so the user can see "this category has 3 unpublished drafts" — a + * detail core's count silently hides. + * + * The field is `desktop_mode_count` and lives on the term object in + * REST view context. The per-term query is one cheap COUNT(*) on a + * pre-indexed join, so 50 terms = 50 light queries — acceptable for + * an admin UI. + * + * @since 0.8.0 + */ +function desktop_mode_posts_window_register_count_field() { + foreach ( array( 'category', 'post_tag' ) as $taxonomy ) { + register_rest_field( + $taxonomy, + 'desktop_mode_count', + array( + 'get_callback' => 'desktop_mode_posts_window_term_count_any', + 'schema' => array( + 'description' => __( 'Number of non-trashed posts (any status) in this term.', 'desktop-mode' ), + 'type' => 'integer', + 'context' => array( 'view', 'embed' ), + 'readonly' => true, + ), + ) + ); + // Mark the taxonomy's "default" term — the fallback that + // receives uncategorised posts. Localised installs rename + // the slug + name so a JS-side "uncategorized" string match + // fails (Spanish: "Sin categoría"); the option is the only + // reliable id. + register_rest_field( + $taxonomy, + 'desktop_mode_is_default', + array( + 'get_callback' => 'desktop_mode_posts_window_term_is_default', + 'schema' => array( + 'description' => __( 'Whether this term is the taxonomy\'s default (fallback) term.', 'desktop-mode' ), + 'type' => 'boolean', + 'context' => array( 'view', 'embed' ), + 'readonly' => true, + ), + ) + ); + } +} +add_action( 'rest_api_init', 'desktop_mode_posts_window_register_count_field' ); + +/** + * Bulk count endpoint — returns `{ term_id: count }` for every + * requested term in one query. The `desktop_mode_count` REST field + * (per-term) is the canonical source, but on installs where the + * field isn't reaching the response (caching, REST middleware, + * stale `_fields` whitelist) the JS calls this endpoint as a + * defensive fallback so node labels never silently show 0. + * + * GET `/desktop-mode/v1/term-counts?taxonomy=category&ids=1,4,7` + * + * @since 0.8.0 + */ +function desktop_mode_posts_window_register_term_counts_route() { + register_rest_route( + 'desktop-mode/v1', + '/term-counts', + array( + 'methods' => 'GET', + 'callback' => 'desktop_mode_posts_window_term_counts_callback', + 'permission_callback' => function () { + return current_user_can( 'edit_posts' ); + }, + 'args' => array( + 'taxonomy' => array( + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_key', + ), + 'ids' => array( + 'required' => true, + 'type' => 'string', + ), + ), + ) + ); +} +add_action( 'rest_api_init', 'desktop_mode_posts_window_register_term_counts_route' ); + +function desktop_mode_posts_window_term_counts_callback( $request ) { + global $wpdb; + $taxonomy = sanitize_key( (string) $request->get_param( 'taxonomy' ) ); + $tax_obj = get_taxonomy( $taxonomy ); + if ( ! $tax_obj ) { + return new WP_Error( + 'desktop_mode_invalid_taxonomy', + __( 'Unknown taxonomy.', 'desktop-mode' ), + array( 'status' => 400 ) + ); + } + $raw = (string) $request->get_param( 'ids' ); + $parts = array_map( 'intval', explode( ',', $raw ) ); + $ids = array_values( array_filter( $parts, function ( $id ) { return $id > 0; } ) ); + if ( count( $ids ) === 0 ) { + return array(); + } + // Cap to avoid runaway IN-clauses if a malicious caller inflates + // the param. 500 is far above any plausible category/tag count. + $ids = array_slice( $ids, 0, 500 ); + + // Mirror WP core's `_update_post_term_count` filtering — limit to + // the taxonomy's `object_type` (e.g. `post` for category) and + // exclude statuses core treats as non-counting: + // - 'trash' + 'auto-draft' → user-not-published-and-never-will-be + // - 'inherit' → attachment-only status; excluded so attachments + // aren't double-counted via parent inheritance + // Everything else (publish, draft, pending, future, private) is + // included so the user sees a "real" post count, not WP's + // publish-only term_taxonomy.count. + $object_types = array_map( + 'sanitize_key', + (array) $tax_obj->object_type + ); + $object_types = array_filter( $object_types, 'post_type_exists' ); + if ( empty( $object_types ) ) { + $object_types = array( 'post' ); + } + $id_placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); + $type_placeholders = implode( ',', array_fill( 0, count( $object_types ), '%s' ) ); + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT tt.term_id, COUNT(p.ID) AS cnt + FROM {$wpdb->term_taxonomy} tt + LEFT JOIN {$wpdb->term_relationships} tr + ON tr.term_taxonomy_id = tt.term_taxonomy_id + LEFT JOIN {$wpdb->posts} p + ON p.ID = tr.object_id + AND p.post_status NOT IN ( 'trash', 'auto-draft', 'inherit' ) + AND p.post_type IN ( $type_placeholders ) + WHERE tt.taxonomy = %s + AND tt.term_id IN ( $id_placeholders ) + GROUP BY tt.term_id", + array_merge( $object_types, array( $taxonomy ), $ids ) + ), + ARRAY_A + ); + $out = array(); + foreach ( (array) $rows as $row ) { + $out[ (string) (int) $row['term_id'] ] = (int) $row['cnt']; + } + foreach ( $ids as $id ) { + if ( ! isset( $out[ (string) $id ] ) ) { + $out[ (string) $id ] = 0; + } + } + return $out; +} + +/** + * REST get_callback for `desktop_mode_is_default`. Reads the + * taxonomy's default-term option (e.g. `default_category`) and + * compares against the current term's id. + * + * @param array $term Term array as serialized by core's REST term controller. + * @return bool + */ +function desktop_mode_posts_window_term_is_default( $term ) { + $taxonomy = isset( $term['taxonomy'] ) ? (string) $term['taxonomy'] : ''; + $term_id = isset( $term['id'] ) ? (int) $term['id'] : 0; + if ( '' === $taxonomy || $term_id <= 0 ) { + return false; + } + $option_key = 'default_' . $taxonomy; // 'default_category', 'default_post_tag', … + $default_id = (int) get_option( $option_key, 0 ); + return $default_id > 0 && $default_id === $term_id; +} + +/** + * REST get_callback for `desktop_mode_count`. Counts every post in + * the term except trashed + auto-draft (mirrors what users + * conceptually mean by "posts in this category"). + * + * @param array $term Term array as serialized by core's REST term controller. + * @return int + */ +function desktop_mode_posts_window_term_count_any( $term ) { + global $wpdb; + $taxonomy = isset( $term['taxonomy'] ) ? (string) $term['taxonomy'] : ''; + $term_id = isset( $term['id'] ) ? (int) $term['id'] : 0; + $tt_id = isset( $term['term_taxonomy_id'] ) ? (int) $term['term_taxonomy_id'] : 0; + if ( $tt_id <= 0 && $term_id > 0 && '' !== $taxonomy ) { + $tt_id = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT term_taxonomy_id FROM {$wpdb->term_taxonomy} WHERE term_id = %d AND taxonomy = %s LIMIT 1", + $term_id, + $taxonomy + ) + ); + } + if ( $tt_id <= 0 ) { + return 0; + } + // Match WP core's `_update_post_term_count` filtering — limit to + // the taxonomy's registered object_type and exclude statuses + // core treats as non-counting (trash / auto-draft / inherit). + $tax_obj = $taxonomy ? get_taxonomy( $taxonomy ) : null; + $object_types = $tax_obj + ? array_filter( (array) $tax_obj->object_type, 'post_type_exists' ) + : array( 'post' ); + if ( empty( $object_types ) ) { + $object_types = array( 'post' ); + } + $type_placeholders = implode( ',', array_fill( 0, count( $object_types ), '%s' ) ); + return (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->term_relationships} tr + JOIN {$wpdb->posts} p ON p.ID = tr.object_id + WHERE tr.term_taxonomy_id = %d + AND p.post_status NOT IN ( 'trash', 'auto-draft', 'inherit' ) + AND p.post_type IN ( $type_placeholders )", + array_merge( array( $tt_id ), $object_types ) + ) + ); +} diff --git a/languages/desktop-mode.pot b/languages/desktop-mode.pot index 5357507b..5eb4241b 100644 --- a/languages/desktop-mode.pot +++ b/languages/desktop-mode.pot @@ -1,38 +1,66 @@ -# Copyright (C) 2026 The WordPress Contributors -# This file is distributed under the GPL-2.0-or-later license. +# Copyright (C) 2026 Daniel López Sánchez +# This file is distributed under the GPLv2 or later. msgid "" msgstr "" -"Project-Id-Version: Desktop Mode 0.4.0\n" -"Report-Msgid-Bugs-To: https://wordpress.org/plugins/desktop-mode/\n" -"POT-Creation-Date: 2026-04-19 20:57+0200\n" +"Project-Id-Version: Desktop Mode 0.7.0\n" +"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/alcazaba-plugin\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"POT-Creation-Date: 2026-05-05T11:01:22+00:00\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"X-Generator: WP-CLI 2.12.0\n" "X-Domain: desktop-mode\n" -#: includes/default-window.php:186 -msgid "Admin URL to open on portal entry, or null to disable." +#. Plugin Name of the plugin +#: desktop-mode.php +msgid "Desktop Mode" msgstr "" -#: includes/default-window.php:230 -msgid "The `url` parameter must be a string or null." +#. Plugin URI of the plugin +#: desktop-mode.php +msgid "https://github.com/WordPress/desktop-mode" msgstr "" -#: includes/default-window.php:239 -msgid "The URL is not a valid same-origin wp-admin URL." +#. Description of the plugin +#: desktop-mode.php +msgid "Renders the WordPress admin as a desktop OS. Admin screens become draggable, resizable, minimizable windows floating on a desktop with a dock. Purely opt-in per user." msgstr "" -#: includes/media-query.php:83 -msgid "Only return images at least this many pixels wide." +#. Author of the plugin +#: desktop-mode.php +msgid "Daniel López Sánchez" msgstr "" -#: includes/media-query.php:88 -msgid "Only return images at least this many pixels tall." +#. Author URI of the plugin +#: desktop-mode.php +msgid "https://github.com/allterraindeveloper" msgstr "" -#: includes/portal.php:76 -msgid "Sorry, you are not allowed to access the WordPress desktop." +#: includes/accents.php:38 +msgid "WordPress Blue" +msgstr "" + +#: includes/accents.php:39 +msgid "Indigo" +msgstr "" + +#: includes/accents.php:40 +msgid "Teal" +msgstr "" + +#: includes/accents.php:41 +msgid "Emerald" +msgstr "" + +#: includes/accents.php:42 +msgid "Amber" +msgstr "" + +#: includes/accents.php:43 +msgid "Rose" msgstr "" #: includes/admin-bar.php:40 @@ -43,416 +71,535 @@ msgstr "" msgid "Switch to Desktop Mode" msgstr "" -#: includes/admin-bar.php:69 +#: includes/admin-bar.php:70 +msgid "Report a bug" +msgstr "" + +#: includes/admin-bar.php:74 +msgid "Open the Bug Report window" +msgstr "" + +#: includes/admin-bar.php:94 +msgid "Ask AI" +msgstr "" + +#: includes/admin-bar.php:98 +msgid "Open AI Assistant (Cmd+K)" +msgstr "" + +#: includes/admin-bar.php:116 msgid "Arrange" msgstr "" -#: includes/admin-bar.php:72 +#: includes/admin-bar.php:119 msgid "Arrange windows" msgstr "" -#: includes/admin-bar.php:81 +#: includes/admin-bar.php:128 msgid "Cascade" msgstr "" -#: includes/admin-bar.php:85 -msgid "" -"Lay all windows out from top-left, offset so every title bar stays visible." +#: includes/admin-bar.php:132 +msgid "Lay all windows out from top-left, offset so every title bar stays visible." msgstr "" -#: includes/admin-bar.php:93 +#: includes/admin-bar.php:140 msgid "Overview" msgstr "" -#: includes/admin-bar.php:97 +#: includes/admin-bar.php:144 msgid "Zoom out to see every window at once. Click one to focus it." msgstr "" -#: includes/admin-bar.php:111 +#: includes/admin-bar.php:158 msgid "Snap to grid" msgstr "" -#: includes/admin-bar.php:115 +#: includes/admin-bar.php:162 msgid "Snap windows to a grid while dragging or resizing." msgstr "" -#: includes/admin-bar.php:123 +#: includes/admin-bar.php:170 msgid "Tile all windows" msgstr "" -#: includes/admin-bar.php:127 +#: includes/admin-bar.php:174 msgid "Pack every window into an evenly tiled grid that fills the desktop." msgstr "" -#: includes/render.php:201 -msgid "Desktop shell" +#: includes/ai-copilot/tools-registry.php:97 +msgid "AI tool registration requires a `name` matching [a-z0-9_]{1,64}." msgstr "" -#: includes/render.php:214 -msgid "Admin navigation" +#: includes/ai-copilot/tools-registry.php:104 +msgid "AI tool registration requires a non-empty `description`." msgstr "" -#: includes/render.php:227 -msgid "Widgets" +#: includes/ai-copilot/tools-registry.php:111 +msgid "AI tool registration requires a callable `handler`." msgstr "" -#: src/settings.ts:84 -msgid "WordPress Blue" +#: includes/ai-copilot/tools-registry.php:118 +msgid "AI tool `parameters` must be a JSON-Schema object." msgstr "" -#: src/settings.ts:86 -msgid "Indigo" +#: includes/commands.php:66 +msgid "Command script registration requires a non-empty script handle." msgstr "" -#: src/settings.ts:88 -msgid "Teal" +#: includes/commands.php:137 +msgid "Command registration requires a non-empty `slug`." msgstr "" -#: src/settings.ts:90 -msgid "Emerald" +#: includes/commands.php:143 +msgid "Command registration requires a non-empty `label`." msgstr "" -#: src/settings.ts:92 -msgid "Amber" +#. translators: %s: the attempted tag name. +#: includes/components.php:98 +#, php-format +msgid "desktop_mode_component() only accepts tags with the wpd- prefix; got \"%s\"." msgstr "" -#: src/settings.ts:94 -msgid "Rose" +#. translators: 1: attribute name, 2: tag name. +#: includes/components.php:147 +#, php-format +msgid "Attribute \"%1$s\" on <%2$s> received a non-scalar value (array/object). Only the `style` attribute accepts an array; other attributes must be strings, booleans, or null. The attribute was skipped." msgstr "" -#: src/settings.ts:104 -msgid "Compact" +#: includes/components.php:404 +msgid "Native window id is required and must be a valid slug." msgstr "" -#: src/settings.ts:106 -msgid "Default" +#. translators: %s: capability slug. +#: includes/components.php:440 +#, php-format +msgid "Current user lacks the %s capability required to register this native window." msgstr "" -#: src/settings.ts:108 -msgid "Large" +#: includes/components.php:452 +msgid "Native window registration requires a non-empty `title`." msgstr "" -#: src/settings.ts:270 -msgid "" -"Personalize your desktop. Changes apply instantly and are saved to this " -"browser." +#: includes/components.php:459 +msgid "Native window registration requires a callable `template` that echoes the template body." msgstr "" -#: src/settings.ts:284 -msgid "Reset to defaults" +#: includes/components.php:611 +msgid "Widget id is required." msgstr "" -#: src/settings.ts:312 -msgid "Custom gradient" +#. translators: %s: capability slug. +#: includes/components.php:638 +#, php-format +msgid "Current user lacks the %s capability required to register this widget." msgstr "" -#: src/settings.ts:334 -msgid "Custom image" +#: includes/components.php:652 +msgid "Widget registration requires a non-empty `label`." msgstr "" -#: src/settings.ts:347 -msgid "Wallpaper" +#: includes/components.php:826 +msgid "Wallpaper id is required." msgstr "" -#: src/settings.ts:349 -msgid "" -"The backdrop behind your windows. Pick a preset, mix your own gradient, or " -"drop in an image." +#. translators: %s: capability slug. +#: includes/components.php:846 +#, php-format +msgid "Current user lacks the %s capability required to register this wallpaper." msgstr "" -#: src/settings.ts:550 -msgid "From" +#: includes/components.php:856 +msgid "Wallpaper registration requires a non-empty `label`." msgstr "" -#: src/settings.ts:556 -msgid "To" +#: includes/components.php:870 +msgid "Canvas wallpaper registration requires a `script` handle that publishes the def." msgstr "" -#: src/settings.ts:569 -msgid "Angle" +#: includes/components.php:1036 +msgid "Desktop icon id is required and must be a valid slug." msgstr "" -#: src/settings.ts:627 -msgid "Or use your own image" +#. translators: %s: capability slug. +#: includes/components.php:1056 +#, php-format +msgid "Current user lacks the %s capability required to register this desktop icon." msgstr "" -#: src/settings.ts:643 -msgid "Upload new" +#: includes/components.php:1067 +msgid "Desktop icon registration requires a non-empty `title`." msgstr "" -#: src/settings.ts:649 -msgid "Media Library" +#: includes/components.php:1077 +msgid "Desktop icon cannot declare both `window` and `url`; pick one target." msgstr "" -#: src/settings.ts:727 -msgid "Search your media" +#: includes/components.php:1084 +msgid "Desktop icon must declare a `window` id or a `url` target." msgstr "" -#: src/settings.ts:729 -msgid "Search media" +#: includes/components.php:1096 +msgid "Desktop icon `url` must be a valid http(s) URL." msgstr "" -#. translators: %1$d is the HD minimum width in px, %2$d is the minimum height. -#: src/settings.ts:741 -#, javascript-format -msgid "Only HD (≥%1$d×%2$d)" +#: includes/components.php:1292 +msgid "Window id is required when registering a tab." msgstr "" -#: src/settings.ts:762 -msgid "Load more" +#. translators: %s: capability slug. +#: includes/components.php:1312 +#, php-format +msgid "Current user lacks the %s capability required to register this window tab." msgstr "" -#. translators: %d is the number of media items currently visible. -#: src/settings.ts:779 -#, javascript-format -msgid "Showing %d" +#: includes/components.php:1331 +msgid "Window tab registration requires a non-empty `value`." msgstr "" -#. translators: %d is the number of images filtered out by the HD toggle. -#: src/settings.ts:784 -#, javascript-format -msgid "%d hidden by HD filter" +#. translators: %s: the invalid value. +#: includes/components.php:1340 +#, php-format +msgid "Window tab `value` \"%s\" must match /^[a-z0-9_-]+(\\/[a-z0-9_-]+)?$/ — lowercase alphanum + hyphen/underscore, with at most one `vendor/sub-id` slash." msgstr "" -#: src/settings.ts:802 -msgid "" -"No HD images found. Try unchecking the filter, or upload a larger image." +#. translators: %s: the reserved value. +#: includes/components.php:1352 +#, php-format +msgid "The tab value \"%s\" is reserved for the window's own template tab." msgstr "" -#: src/settings.ts:805 -msgid "No images in your Media Library yet." +#: includes/components.php:1361 +msgid "Window tab registration requires a non-empty `label`." msgstr "" -#. translators: %s is the browser-supplied error message. -#: src/settings.ts:845 -#, javascript-format -msgid "Couldn’t load your media: %s" +#: includes/components.php:1368 +msgid "Window tab registration requires a callable `template` that echoes the pane body." msgstr "" -#: src/settings.ts:849 -msgid "Couldn’t load your media." +#: includes/default-window.php:200 +msgid "Admin URL to open on portal entry, or null to disable." msgstr "" -#: src/settings.ts:1019 -msgid "Custom image wallpaper" +#: includes/default-window.php:244 +msgid "The `url` parameter must be a string or null." msgstr "" -#: src/settings.ts:1025 -msgid "Remove custom image" +#: includes/default-window.php:253 +msgid "The URL is not a valid same-origin wp-admin URL." msgstr "" -#: src/settings.ts:1026 -msgid "Remove" +#: includes/dock-rail-renderer.php:58 +msgid "Dock rail renderer script registration requires a non-empty script handle." msgstr "" -#: src/settings.ts:1053 -msgid "Drop an image here, or click to upload" +#: includes/helpers.php:352 +msgid "Admin target cannot be empty." msgstr "" -#: src/settings.ts:1057 -msgid "JPEG, PNG, or WebP · goes straight to your Media Library" +#: includes/helpers.php:356 +msgid "Admin target contains invalid path characters." msgstr "" -#: src/settings.ts:1063 -msgid "Upload a wallpaper image" +#: includes/helpers.php:364 +msgid "Admin target must be a plain .php filename." msgstr "" -#: src/settings.ts:1100 -msgid "That file isn’t an image." +#: includes/helpers.php:368 +msgid "Admin target does not exist." msgstr "" -#: src/settings.ts:1109 -msgid "Uploading…" +#. translators: 1: kind ("Command"/"Settings-tab"/"Title-bar button"), 2: handle. +#: includes/helpers.php:1263 +#, php-format +msgid "%1$s script handle \"%2$s\" is not registered with WordPress (no `wp_register_script` call found). The script will not load." msgstr "" -#: src/settings.ts:1129 -msgid "Upload failed." +#: includes/media-query.php:83 +msgid "Only return images at least this many pixels wide." msgstr "" -#: src/settings.ts:1186 -msgid "Accent color" +#: includes/media-query.php:88 +msgid "Only return images at least this many pixels tall." msgstr "" -#: src/settings.ts:1187 -msgid "Used in focused window title bars, buttons, and focus rings." +#: includes/portal.php:76 +msgid "Sorry, you are not allowed to access the WordPress desktop." msgstr "" -#: src/settings.ts:1221 -msgid "Dock size" +#: includes/posts-window/window.php:43 +msgid "Search posts…" msgstr "" -#: src/settings.ts:1222 -msgid "Width of the dock and size of its icons." +#: includes/posts-window/window.php:60 +#: includes/recycle-bin/window.php:62 +msgid "Refresh" msgstr "" -#: src/wallpapers/built-in.ts:99 -msgid "Graphite" +#: includes/posts-window/window.php:65 +msgid "Add New" msgstr "" -#: src/wallpapers/built-in.ts:101 -msgid "Aurora" +#: includes/posts-window/window.php:81 +msgid "No posts found." msgstr "" -#: src/wallpapers/built-in.ts:103 -msgid "Sunset" +#: includes/posts-window/window.php:83 +msgid "Try a different search or change the status filter." msgstr "" -#: src/wallpapers/built-in.ts:105 -msgid "Forest" +#: includes/posts-window/window.php:95 +msgid "Previous" msgstr "" -#: src/wallpapers/built-in.ts:107 -msgid "Mono" +#: includes/posts-window/window.php:98 +msgid "Next" +msgstr "" + +#: includes/posts-window/window.php:102 +msgid "Per page" msgstr "" -#. translators: %d is the number of pending updates / items. -#: src/dock.ts:199 -#, javascript-format -msgid "%d update" -msgid_plural "%d updates" -msgstr[0] "" -msgstr[1] "" +#: includes/posts-window/window.php:160 +#: includes/recycle-bin/window.php:40 +msgid "Posts" +msgstr "" -#. translators: %s is the admin-page title (e.g., "Posts") -#. translators: %s is the window's admin-page name (e.g., "Posts") -#: src/dock.ts:224 src/window.ts:165 -#, javascript-format -msgid "Open another %s" +#: includes/presence.php:418 +msgid "Authentication required." msgstr "" -#. translators: %s is the admin-page title (e.g., "Posts") -#: src/dock.ts:245 -#, javascript-format -msgid "Open new %s" +#: includes/presence.php:425 +msgid "Desktop mode is not enabled for your account." msgstr "" -#. translators: default desktop name — "Desktop 1" -#: src/window-manager.ts:43 -msgid "Desktop 1" +#: includes/recycle-bin/rest.php:148 +msgid "Sorry, you must be logged in." msgstr "" -#. translators: %d is the desktop number (e.g., "Desktop 2") -#: src/window-manager.ts:440 -#, javascript-format -msgid "Desktop %d" +#: includes/recycle-bin/rest.php:155 +msgid "Desktop mode is not enabled for this user." msgstr "" -#: src/window-manager.ts:1225 -msgid "Add new desktop" +#: includes/recycle-bin/store.php:400 +msgid "Anonymous" msgstr "" -#. translators: %s is the desktop label -#: src/window-manager.ts:1254 -#, javascript-format -msgid "Switch to %s" +#. translators: 1: comment author. 2: parent post title. +#: includes/recycle-bin/store.php:405 +#, php-format +msgid "%1$s on %2$s" msgstr "" -#. translators: %s is the desktop label -#: src/window-manager.ts:1286 -#, javascript-format -msgid "Close %s" +#: includes/recycle-bin/store.php:590 +#: includes/recycle-bin/store.php:699 +msgid "Item not found." msgstr "" -#. translators: %d is the number of external sub-tabs open on this window. -#: src/window-manager.ts:1374 -#, javascript-format -msgid "· %d open tab" -msgid_plural "· %d open tabs" -msgstr[0] "" -msgstr[1] "" +#: includes/recycle-bin/store.php:593 +#: includes/recycle-bin/store.php:702 +msgid "Item is not in the trash." +msgstr "" -#: src/window.ts:117 -msgid "Window actions" +#: includes/recycle-bin/store.php:596 +msgid "You are not allowed to restore this item." msgstr "" -#: src/window.ts:146 -msgid "Open on startup" +#: includes/recycle-bin/store.php:611 +msgid "Failed to restore item." msgstr "" -#: src/window.ts:188 -msgid "Minimize" +#: includes/recycle-bin/store.php:642 +#: includes/recycle-bin/store.php:756 +msgid "Comment not found." msgstr "" -#: src/window.ts:193 -msgid "Maximize" +#: includes/recycle-bin/store.php:645 +#: includes/recycle-bin/store.php:759 +msgid "Comment is not in the trash." msgstr "" -#: src/window.ts:198 src/window.ts:1629 -msgid "Enter fullscreen" +#: includes/recycle-bin/store.php:648 +msgid "You are not allowed to restore this comment." msgstr "" -#: src/window.ts:208 -msgid "Detach to new tab" +#: includes/recycle-bin/store.php:663 +msgid "Failed to restore comment." msgstr "" -#: src/window.ts:213 -msgid "Close" +#: includes/recycle-bin/store.php:705 +msgid "You are not allowed to permanently delete this item." msgstr "" -#. translators: %s is the window's admin-page title (e.g., "Posts") -#: src/window.ts:282 -#, javascript-format -msgid "%s sub-pages" +#: includes/recycle-bin/store.php:727 +msgid "Failed to permanently delete item." msgstr "" -#: src/window.ts:864 src/window.ts:865 -msgid "Open in a new browser tab" +#: includes/recycle-bin/store.php:762 +msgid "You are not allowed to permanently delete this comment." msgstr "" -#: src/window.ts:877 src/window.ts:878 -msgid "Close tab" +#: includes/recycle-bin/store.php:778 +msgid "Failed to permanently delete comment." msgstr "" -#: src/window.ts:1081 -#, javascript-format -msgid "Opened \"%s\" in a new browser tab — this site doesn't allow embedding." +#: includes/recycle-bin/window.php:39 +msgid "All" msgstr "" -#: src/window.ts:1086 -msgid "Open" +#: includes/recycle-bin/window.php:41 +msgid "Pages" msgstr "" -#: src/window.ts:1629 -msgid "Exit fullscreen" +#: includes/recycle-bin/window.php:42 +msgid "Media" msgstr "" -#: src/widgets/layer.ts:81 src/widgets/layer.ts:88 src/widgets/picker.ts:52 -#: src/widgets/picker.ts:56 -msgid "Add widget" +#: includes/recycle-bin/window.php:43 +msgid "Comments" msgstr "" -#. translators: %s is the widget label (e.g., "Clock") -#: src/widgets/layer.ts:295 -#, javascript-format -msgid "Remove %s" +#: includes/recycle-bin/window.php:47 +msgid "Search trash…" msgstr "" -#: src/widgets/picker.ts:152 -msgid "" -"No widgets available. Activate a plugin that registers one, or see the docs " -"for the registerWidget API." +#: includes/recycle-bin/window.php:54 +msgid "Restore" +msgstr "" + +#: includes/recycle-bin/window.php:58 +msgid "Delete forever" +msgstr "" + +#: includes/recycle-bin/window.php:67 +msgid "Empty bin" +msgstr "" + +#: includes/recycle-bin/window.php:82 +msgid "The recycle bin is empty." +msgstr "" + +#: includes/recycle-bin/window.php:84 +msgid "Deleted posts, pages, and media show up here. Restoring puts them back where they were." +msgstr "" + +#: includes/recycle-bin/window.php:146 +#: includes/recycle-bin/window.php:174 +msgid "Recycle Bin" +msgstr "" + +#: includes/render.php:325 +msgid "Desktop shell" +msgstr "" + +#: includes/render.php:338 +msgid "Admin navigation" +msgstr "" + +#: includes/render.php:351 +msgid "Widgets" +msgstr "" + +#: includes/settings-tabs.php:61 +msgid "Settings tab script registration requires a non-empty script handle." +msgstr "" + +#: includes/settings-tabs.php:134 +msgid "Settings tab registration requires a non-empty `id` matching [a-z0-9_-]+." +msgstr "" + +#: includes/settings-tabs.php:141 +msgid "Settings tab registration requires a non-empty `label`." +msgstr "" + +#: includes/title-bar-buttons.php:57 +msgid "Title-bar button script registration requires a non-empty script handle." +msgstr "" + +#: includes/toast-types.php:37 +msgid "Success" +msgstr "" + +#: includes/toast-types.php:43 +msgid "Warning" +msgstr "" + +#: includes/toast-types.php:49 +msgid "Error" +msgstr "" + +#: includes/toast-types.php:55 +msgid "Shell error" +msgstr "" + +#: includes/wallpapers.php:33 +msgid "Graphite" +msgstr "" + +#: includes/wallpapers.php:38 +msgid "Aurora" +msgstr "" + +#: includes/wallpapers.php:43 +msgid "Sunset" +msgstr "" + +#: includes/wallpapers.php:48 +msgid "Forest" +msgstr "" + +#: includes/wallpapers.php:53 +msgid "Mono" +msgstr "" + +#: includes/window-chrome.php:58 +msgid "Window theme script registration requires a non-empty script handle." +msgstr "" + +#: includes/window-chrome.php:107 +msgid "Window theme registration requires a non-empty `id`." +msgstr "" + +#: includes/window-chrome.php:113 +msgid "Window theme registration requires a non-empty `tokens` map." +msgstr "" + +#: includes/window-chrome.php:123 +msgid "Window theme tokens must use CSS custom-property keys (start with \"--\")." +msgstr "" + +#: includes/window-chrome.php:316 +msgid "Window control script registration requires a non-empty script handle." +msgstr "" + +#: includes/window-chrome.php:365 +msgid "Window control registration requires a non-empty `id`." +msgstr "" + +#: includes/window-chrome.php:371 +msgid "Window control registration requires a non-empty `label`." +msgstr "" + +#: includes/window-chrome.php:379 +msgid "Window control `placement` must be one of \"left\", \"right\", \"controls\"." msgstr "" -#. translators: %s is the widget label -#: src/widgets/picker.ts:174 -#, javascript-format -msgid "%s (already added)" +#: includes/window-chrome.php:565 +msgid "Window slot script registration requires a non-empty script handle." msgstr "" -#. translators: %s is the widget label -#: src/widgets/picker.ts:177 -#, javascript-format -msgid "Add %s" +#: includes/window-chrome.php:607 +msgid "Window slot registration requires a non-empty `id`." msgstr "" -#: src/widgets/picker.ts:204 -msgid "Added" +#: includes/window-chrome.php:614 +msgid "Window slot registration requires a known `slot` name." msgstr "" -#: src/widgets/built-in.ts:22 -msgid "Clock" +#: includes/window-chrome.php:779 +msgid "Window chrome script registration requires a non-empty script handle." msgstr "" -#: src/widgets/built-in.ts:25 -msgid "Local time and date, refreshed every second." +#: includes/window-chrome.php:822 +msgid "Window chrome registration requires a non-empty `id`." msgstr "" diff --git a/package.json b/package.json index 135d2dd1..62added5 100644 --- a/package.json +++ b/package.json @@ -25,10 +25,11 @@ } }, "scripts": { - "build": "npm run vendor:pixi && npm run build:desktop && npm run build:iframe-bridge && npm run build:recycle-bin && npm run build:pwa-sw", + "build": "npm run vendor:pixi && npm run build:desktop && npm run build:iframe-bridge && npm run build:recycle-bin && npm run build:posts-window && npm run build:pwa-sw", "build:desktop": "vite build --mode development && vite build --mode production", "build:iframe-bridge": "DESKTOP_MODE_TARGET=iframe-bridge vite build --mode development && DESKTOP_MODE_TARGET=iframe-bridge vite build --mode production", "build:recycle-bin": "DESKTOP_MODE_TARGET=recycle-bin vite build --mode development && DESKTOP_MODE_TARGET=recycle-bin vite build --mode production", + "build:posts-window": "DESKTOP_MODE_TARGET=posts-window vite build --mode development && DESKTOP_MODE_TARGET=posts-window vite build --mode production", "build:pwa-sw": "DESKTOP_MODE_TARGET=pwa-sw vite build --mode development && DESKTOP_MODE_TARGET=pwa-sw vite build --mode production", "dev": "vite build --mode development --watch", "vendor:pixi": "node -e \"require('fs').cpSync('node_modules/pixi.js/dist/pixi.min.js', 'assets/vendor/pixi.min.js')\"", diff --git a/src/desktop.ts b/src/desktop.ts index 89b3db80..6b1af59c 100644 --- a/src/desktop.ts +++ b/src/desktop.ts @@ -17,6 +17,11 @@ import { repaintLoadingOverlays, } from './window/loading'; import { Dock, type DockItem, type SystemDockItem } from './dock'; +import { + bindNativeUrlRemap, + registerNativeUrlRemap, + tryNativeUrlRemap, +} from './native-url-remap'; import { renderIcon } from './icon'; import { applyTileClasses, @@ -47,6 +52,7 @@ import { unregisterSettingsTab, listSettingsTabs, type DesktopSettingsTab, + type OsSettingsSnapshot, } from './settings/registry'; import { registerTitleBarButton, @@ -411,6 +417,39 @@ export interface WpDesktopPublicApi { * @since 0.18.0 */ openWindow: ( id: string, opts?: { source?: string } ) => boolean; + /** + * Wrapper around native `fetch()` that attributes the request to + * a desktop window's activity indicator. While the fetch is in + * flight the window's title-bar dot blinks like a modem activity + * LED; on success it flashes "saved", on failure "couldn't save" + * (with the error as a tooltip). + * + * Identical signature to `fetch()` plus one extra options object: + * + * - `windowId?: string` — explicit attribution. Wins over + * `window` when both are passed. + * - `window?: Window` — direct reference to a `Window` + * instance. Use when you have the handle in scope. + * - `silent?: boolean` — track but do NOT pulse the indicator. + * Reserved for background polls (heartbeat, presence) that + * shouldn't blink the title bar every tick. + * + * Default attribution: the focused window at call time. So + * `wp.desktop.fetch( '/wp-json/myapi/v1/save', { method: 'POST' } )` + * inside a click handler "just works" — the click focused the + * window, the fetch attributes to it, the title bar pulses. + * + * Returns the same Response Promise as native `fetch()`. Errors + * propagate unchanged (the indicator just adds a "failed" pulse + * before the rejection bubbles up). + * + * @since 0.8.0 + */ + fetch: ( + input: RequestInfo | URL, + requestInit?: RequestInit, + opts?: { windowId?: string; window?: DesktopWindow; silent?: boolean }, + ) => Promise< Response >; /** * Clone a `