mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-08-28 09:11:06 +00:00
468 lines
13 KiB
Svelte
468 lines
13 KiB
Svelte
<script lang="ts">
|
|
import { onDestroy } from "svelte";
|
|
import Sortable from "sortablejs";
|
|
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
|
import {
|
|
restoreCustomMenuActive,
|
|
sidebarState,
|
|
} from "./sidebarState.svelte";
|
|
import SidebarItem from "./SidebarItem.svelte";
|
|
import type {
|
|
SidebarDrillFrame,
|
|
SidebarItem as SidebarItemModel,
|
|
} from "./types";
|
|
|
|
type Props = {
|
|
menuEl: HTMLElement;
|
|
};
|
|
|
|
let { menuEl }: Props = $props();
|
|
|
|
let listEl = $state<HTMLElement | null>(null);
|
|
let coverEl: HTMLElement | null = null;
|
|
let sortable: Sortable | null = null;
|
|
let dragging = $state(false);
|
|
|
|
const BACK_SVG = `<svg width="24" height="24" viewBox="0 0 24 24" aria-hidden="true"><g style="fill: currentcolor;"><path d="M15.422 16.078l-1.406 1.406-6-6 6-6 1.406 1.406-4.594 4.594z"></path></g></svg>`;
|
|
|
|
function itemVisible(key: string): boolean {
|
|
return (
|
|
(settingsState.menuitems as Record<string, { toggle?: boolean }>)[key]
|
|
?.toggle !== false
|
|
);
|
|
}
|
|
|
|
function onActivate(item: SidebarItemModel) {
|
|
sidebarState.activateItem(item, menuEl);
|
|
}
|
|
|
|
function closeEdit() {
|
|
sidebarState.setEditMode(false);
|
|
void import("@/seqta/utils/Openers/menuOptionsState").then((mod) => {
|
|
mod.setMenuOptionsOpen(false);
|
|
});
|
|
}
|
|
|
|
const drillFolders = $derived(
|
|
sidebarState.drillStack.map((frame) => ({
|
|
frame,
|
|
folder: sidebarState.findByKey(frame.key),
|
|
})),
|
|
);
|
|
|
|
function onToggleVisible(key: string, visible: boolean) {
|
|
sidebarState.setItemVisibility(key, visible);
|
|
}
|
|
|
|
function destroySortable() {
|
|
sortable?.destroy();
|
|
sortable = null;
|
|
dragging = false;
|
|
document.documentElement.style.removeProperty("--bsplus-drag-width");
|
|
}
|
|
|
|
function syncSortableFromState() {
|
|
if (!sortable || dragging) return;
|
|
const order = sidebarState.editRootItems.map((item) => item.key);
|
|
sortable.sort(order, true);
|
|
}
|
|
|
|
function restoreDefault() {
|
|
sidebarState.restoreDefaultOrder();
|
|
queueMicrotask(syncSortableFromState);
|
|
}
|
|
|
|
$effect(() => {
|
|
const editing = sidebarState.editMode;
|
|
const list = listEl;
|
|
|
|
destroySortable();
|
|
if (!editing || !list) return;
|
|
|
|
sortable = Sortable.create(list, {
|
|
animation: 220,
|
|
easing: "cubic-bezier(0.22, 1, 0.36, 1)",
|
|
draggable: ".bsplus-sidebar-item.draggable",
|
|
filter: ".toggle, .toggle input, .bsplus-sidebar-edit-header, .bsplus-sidebar-edit-actions",
|
|
preventOnFilter: false,
|
|
dataIdAttr: "data-key",
|
|
ghostClass: "bsplus-sortable-ghost",
|
|
chosenClass: "bsplus-sortable-chosen",
|
|
dragClass: "bsplus-sortable-drag",
|
|
forceFallback: true,
|
|
fallbackOnBody: true,
|
|
fallbackTolerance: 3,
|
|
swapThreshold: 0.65,
|
|
direction: "vertical",
|
|
onStart: (evt) => {
|
|
dragging = true;
|
|
const width = evt.item.getBoundingClientRect().width;
|
|
document.documentElement.style.setProperty(
|
|
"--bsplus-drag-width",
|
|
`${Math.round(width)}px`,
|
|
);
|
|
// Fallback clone is created just after onStart; pin size + drop
|
|
// `.active` so theme/active rules don't expand it to full width.
|
|
requestAnimationFrame(() => {
|
|
const dragEl = document.querySelector(
|
|
".bsplus-sortable-drag",
|
|
) as HTMLElement | null;
|
|
if (!dragEl) return;
|
|
dragEl.style.width = `${Math.round(width)}px`;
|
|
dragEl.style.maxWidth = `${Math.round(width)}px`;
|
|
dragEl.classList.remove("active");
|
|
});
|
|
},
|
|
onEnd: (evt) => {
|
|
dragging = false;
|
|
document.documentElement.style.removeProperty("--bsplus-drag-width");
|
|
if (evt.from !== evt.to) return;
|
|
if (evt.oldIndex == null || evt.newIndex == null) return;
|
|
if (evt.oldIndex === evt.newIndex) return;
|
|
sidebarState.applyMenuOrder(sortable?.toArray() ?? []);
|
|
},
|
|
});
|
|
|
|
return () => {
|
|
destroySortable();
|
|
};
|
|
});
|
|
|
|
$effect(() => {
|
|
const editing = sidebarState.editMode;
|
|
const container = document.getElementById("container");
|
|
|
|
if (editing && container && !coverEl) {
|
|
const cover = document.createElement("div");
|
|
cover.className = "notMenuCover bsplus-sidebar-edit-cover";
|
|
cover.addEventListener("click", closeEdit);
|
|
container.appendChild(cover);
|
|
menuEl.style.zIndex = "20";
|
|
menuEl.classList.add("bsplus-sidebar-edit-mode");
|
|
coverEl = cover;
|
|
}
|
|
|
|
if (!editing) {
|
|
coverEl?.remove();
|
|
coverEl = null;
|
|
menuEl.style.removeProperty("z-index");
|
|
menuEl.classList.remove("bsplus-sidebar-edit-mode");
|
|
}
|
|
|
|
return () => {
|
|
coverEl?.remove();
|
|
coverEl = null;
|
|
menuEl.style.removeProperty("z-index");
|
|
};
|
|
});
|
|
|
|
// SEQTA strips `.active` from `#menu li` after clicks — re-apply from state.
|
|
// Do NOT MutationObserver class changes here: restore writes `.active`, SEQTA
|
|
// strips it again, and the feedback loop freezes the tab.
|
|
$effect(() => {
|
|
void sidebarState.activeKey;
|
|
void sidebarState.isDrilling;
|
|
restoreCustomMenuActive();
|
|
});
|
|
|
|
// Drill `.sub` is position:absolute inside this scrollport — if the list was
|
|
// scrolled down (e.g. Folios/Goals near the bottom), the panel sits under the
|
|
// logo until we reset. Also reset when going back up the stack.
|
|
$effect(() => {
|
|
void sidebarState.drillStack.length;
|
|
void sidebarState.enterFrameKey;
|
|
const root = document.getElementById("bsplus-sidebar-root");
|
|
if (!root) return;
|
|
root.scrollTop = 0;
|
|
requestAnimationFrame(() => {
|
|
root.scrollTop = 0;
|
|
});
|
|
});
|
|
|
|
onDestroy(() => {
|
|
destroySortable();
|
|
coverEl?.remove();
|
|
coverEl = null;
|
|
});
|
|
</script>
|
|
|
|
<!--
|
|
Direct `#menu > ul.logo-link` child so theme selectors like
|
|
`#menu > ul > li.item` / `#menu .sub` / `:has(> ul > li.hasChildren.active)` match.
|
|
-->
|
|
<ul
|
|
id="bsplus-sidebar-root"
|
|
class="logo-link bsplus-sidebar-list"
|
|
class:noscroll={sidebarState.isDrilling}
|
|
class:drilling={sidebarState.isDrilling}
|
|
class:drill-entering={sidebarState.enterFrameKey != null}
|
|
class:drill-returning={sidebarState.drillReturning}
|
|
class:compact={sidebarState.compact}
|
|
class:edit-mode={sidebarState.editMode}
|
|
class:is-sorting={dragging}
|
|
aria-label="Main"
|
|
bind:this={listEl}
|
|
>
|
|
{#if sidebarState.editMode}
|
|
<li class="item bsplus-sidebar-edit-header" aria-hidden="true">
|
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
|
<label><span class="label">Edit Sidebar</span></label>
|
|
</li>
|
|
{#each sidebarState.editRootItems as item (item.key)}
|
|
<SidebarItem
|
|
{item}
|
|
active={sidebarState.activeKey === item.key}
|
|
compact={false}
|
|
editMode={true}
|
|
visible={itemVisible(item.key)}
|
|
{onActivate}
|
|
{onToggleVisible}
|
|
/>
|
|
{/each}
|
|
<li class="item bsplus-sidebar-edit-actions">
|
|
<button type="button" class="edit-btn" onclick={restoreDefault}>
|
|
Restore Default
|
|
</button>
|
|
<button type="button" class="edit-btn primary" onclick={closeEdit}>
|
|
Save
|
|
</button>
|
|
</li>
|
|
{:else if drillFolders.length}
|
|
<!-- Keep root rows in the DOM so sidebar-animation.scss can slide siblings.
|
|
Never mark root leaves active while drilling — theme decorations
|
|
(Beach palm/sand) would show through the transparent .sub panel. -->
|
|
{#each sidebarState.visibleRootItems as item (item.key)}
|
|
{#if item.key === drillFolders[0].frame.key}
|
|
{@render drillLevel(drillFolders, 0)}
|
|
{:else}
|
|
<SidebarItem
|
|
{item}
|
|
active={false}
|
|
compact={false}
|
|
editMode={false}
|
|
visible={true}
|
|
{onActivate}
|
|
/>
|
|
{/if}
|
|
{/each}
|
|
{:else}
|
|
{#each sidebarState.visibleRootItems as item (item.key)}
|
|
<SidebarItem
|
|
{item}
|
|
active={sidebarState.activeKey === item.key}
|
|
compact={sidebarState.compact}
|
|
editMode={false}
|
|
visible={true}
|
|
{onActivate}
|
|
/>
|
|
{/each}
|
|
{/if}
|
|
</ul>
|
|
|
|
{#snippet drillLevel(
|
|
frames: Array<{ frame: SidebarDrillFrame; folder: SidebarItemModel | null }>,
|
|
depth: number,
|
|
)}
|
|
{@const current = frames[depth]}
|
|
{@const isLast = depth === frames.length - 1}
|
|
{@const nextKey = isLast ? null : frames[depth + 1].frame.key}
|
|
{@const folder = current.folder}
|
|
<li
|
|
class="item hasChildren bsplus-sidebar-item"
|
|
class:active={true}
|
|
data-key={current.frame.key}
|
|
data-path={folder?.path ?? undefined}
|
|
style:--item-colour={folder?.itemColour || undefined}
|
|
>
|
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
|
<label>
|
|
{#if folder?.iconHtml}
|
|
{@html folder.iconHtml}
|
|
{/if}
|
|
<span class="label">{current.frame.label}</span>
|
|
</label>
|
|
<div
|
|
class="sub"
|
|
class:bsplus-sub-enter={sidebarState.enterFrameKey === current.frame.key}
|
|
onanimationend={(e) => {
|
|
if (e.target !== e.currentTarget) return;
|
|
if (sidebarState.enterFrameKey === current.frame.key) {
|
|
sidebarState.clearEnterFrame(current.frame.key);
|
|
}
|
|
}}
|
|
>
|
|
<div class="nav">
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<div
|
|
class="back"
|
|
role="button"
|
|
tabindex="0"
|
|
aria-label="Back"
|
|
onclick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
sidebarState.goBack();
|
|
}}
|
|
onkeydown={(e) => {
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
sidebarState.goBack();
|
|
}
|
|
}}
|
|
>
|
|
{@html BACK_SVG}
|
|
<div class="backLabel">{current.frame.label}</div>
|
|
</div>
|
|
</div>
|
|
<ul>
|
|
{#if isLast}
|
|
{#each current.frame.items as item (item.key)}
|
|
<SidebarItem
|
|
{item}
|
|
active={sidebarState.activeKey === item.key}
|
|
compact={false}
|
|
editMode={false}
|
|
visible={true}
|
|
drillEnter={sidebarState.enterFrameKey === current.frame.key}
|
|
{onActivate}
|
|
/>
|
|
{/each}
|
|
{:else}
|
|
{#each current.frame.items as item (item.key)}
|
|
{#if item.key === nextKey}
|
|
{@render drillLevel(frames, depth + 1)}
|
|
{:else}
|
|
<SidebarItem
|
|
{item}
|
|
active={sidebarState.activeKey === item.key}
|
|
compact={false}
|
|
editMode={false}
|
|
visible={true}
|
|
{onActivate}
|
|
/>
|
|
{/if}
|
|
{/each}
|
|
{/if}
|
|
</ul>
|
|
</div>
|
|
</li>
|
|
{/snippet}
|
|
|
|
<style>
|
|
.bsplus-sidebar-list {
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: 0 0 16px;
|
|
flex: 1 1 auto;
|
|
min-height: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
overflow-y: auto;
|
|
overflow-x: hidden;
|
|
position: relative;
|
|
box-sizing: border-box;
|
|
color: var(--text-color, #fff);
|
|
scrollbar-width: thin;
|
|
z-index: 2;
|
|
}
|
|
|
|
.bsplus-sidebar-list.noscroll,
|
|
.bsplus-sidebar-list.drilling {
|
|
overflow: hidden;
|
|
}
|
|
|
|
.bsplus-sidebar-list.compact {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
}
|
|
|
|
.bsplus-sidebar-list.is-sorting {
|
|
cursor: grabbing;
|
|
user-select: none;
|
|
}
|
|
|
|
.bsplus-sidebar-edit-header {
|
|
pointer-events: none;
|
|
width: 85%;
|
|
margin: 8px auto 4px;
|
|
font-weight: 700;
|
|
font-size: 16px;
|
|
}
|
|
|
|
.bsplus-sidebar-edit-header > label {
|
|
display: block;
|
|
padding: 12px 16px 4px;
|
|
}
|
|
|
|
.bsplus-sidebar-edit-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
width: calc(100% - 12px);
|
|
margin: 12px 6px 16px;
|
|
padding: 0;
|
|
list-style: none;
|
|
cursor: default;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.edit-btn {
|
|
flex: 1 1 0;
|
|
min-width: 0;
|
|
padding: 10px 8px;
|
|
border: none;
|
|
border-radius: 10px;
|
|
background: rgba(0, 0, 0, 0.2);
|
|
color: inherit;
|
|
font-family: inherit;
|
|
font-size: 13px;
|
|
font-weight: 700;
|
|
line-height: 1.2;
|
|
white-space: nowrap;
|
|
cursor: pointer;
|
|
transition:
|
|
background-color 0.2s ease,
|
|
transform 0.2s ease;
|
|
}
|
|
|
|
.edit-btn:hover {
|
|
background: rgba(0, 0, 0, 0.32);
|
|
transform: scale(1.02);
|
|
}
|
|
|
|
.edit-btn:active {
|
|
transform: scale(0.98);
|
|
}
|
|
|
|
.edit-btn.primary {
|
|
background: rgba(0, 0, 0, 0.4);
|
|
}
|
|
|
|
/* SortableJS classes (applied to our items; must be :global). */
|
|
.bsplus-sidebar-list :global(.bsplus-sortable-ghost) {
|
|
opacity: 0.35 !important;
|
|
background: rgba(255, 255, 255, 0.08) !important;
|
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.18);
|
|
}
|
|
|
|
.bsplus-sidebar-list :global(.bsplus-sortable-chosen) {
|
|
background: rgba(0, 0, 0, 0.28) !important;
|
|
}
|
|
|
|
.bsplus-sidebar-list :global(.bsplus-sortable-drag),
|
|
:global(.bsplus-sortable-drag) {
|
|
opacity: 1 !important;
|
|
cursor: grabbing !important;
|
|
z-index: 10000 !important;
|
|
width: var(--bsplus-drag-width, 240px) !important;
|
|
max-width: var(--bsplus-drag-width, 240px) !important;
|
|
box-sizing: border-box !important;
|
|
box-shadow:
|
|
0 14px 32px rgba(0, 0, 0, 0.35),
|
|
0 0 0 1px rgba(255, 255, 255, 0.12) !important;
|
|
transform: scale(1.03);
|
|
background: rgba(0, 0, 0, 0.45) !important;
|
|
}
|
|
</style>
|