mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-11-04 11:10:33 +08:00
Add repo pinning
This commit is contained in:
@@ -44,6 +44,7 @@
|
||||
| "disconnect"
|
||||
| "document"
|
||||
| "download"
|
||||
| "drag-handle"
|
||||
| "edit"
|
||||
| "ellipsis"
|
||||
| "ellipsis-vertical"
|
||||
@@ -350,6 +351,19 @@
|
||||
d="M7.5 2.5H8.5V9.79297L11 7.29297L11.707 8L8 11.707L4.29297 8L5 7.29297L7.5 9.79297V2.5Z" />
|
||||
<path
|
||||
d="M3.5 11L3.5 13.5L12.5 13.5L12.5 11L13.5 11L13.5 14.5L2.5 14.5L2.5 11L3.5 11Z" />
|
||||
{:else if name === "drag-handle"}
|
||||
<path
|
||||
d="M5.75 4.375C6.09518 4.375 6.375 4.09518 6.375 3.75C6.375 3.40482 6.09518 3.125 5.75 3.125C5.40482 3.125 5.125 3.40482 5.125 3.75C5.125 4.09518 5.40482 4.375 5.75 4.375Z" />
|
||||
<path
|
||||
d="M10.25 4.375C10.5952 4.375 10.875 4.09518 10.875 3.75C10.875 3.40482 10.5952 3.125 10.25 3.125C9.90482 3.125 9.625 3.40482 9.625 3.75C9.625 4.09518 9.90482 4.375 10.25 4.375Z" />
|
||||
<path
|
||||
d="M5.75 8.625C6.09518 8.625 6.375 8.34518 6.375 8C6.375 7.65482 6.09518 7.375 5.75 7.375C5.40482 7.375 5.125 7.65482 5.125 8C5.125 8.34518 5.40482 8.625 5.75 8.625Z" />
|
||||
<path
|
||||
d="M10.25 8.625C10.5952 8.625 10.875 8.34518 10.875 8C10.875 7.65482 10.5952 7.375 10.25 7.375C9.90482 7.375 9.625 7.65482 9.625 8C9.625 8.34518 9.90482 8.625 10.25 8.625Z" />
|
||||
<path
|
||||
d="M5.75 12.875C6.09518 12.875 6.375 12.5952 6.375 12.25C6.375 11.9048 6.09518 11.625 5.75 11.625C5.40482 11.625 5.125 11.9048 5.125 12.25C5.125 12.5952 5.40482 12.875 5.75 12.875Z" />
|
||||
<path
|
||||
d="M10.25 12.875C10.5952 12.875 10.875 12.5952 10.875 12.25C10.875 11.9048 10.5952 11.625 10.25 11.625C9.90482 11.625 9.625 11.9048 9.625 12.25C9.625 12.5952 9.90482 12.875 10.25 12.875Z" />
|
||||
{:else if name === "edit"}
|
||||
<path
|
||||
d="M14.707 5L6.20703 13.5H2.5V9.79297L11 1.29297L14.707 5ZM3.5 10.207V12.5H5.79297L11.293 7L9 4.70703L3.5 10.207ZM9.70703 4L12 6.29297L13.293 5L11 2.70703L9.70703 4Z" />
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import type { RepoSummary } from "@bindings/repo/RepoSummary";
|
||||
|
||||
import { onMount } from "svelte";
|
||||
import { boolean } from "zod";
|
||||
import { flip } from "svelte/animate";
|
||||
import { crossfade } from "svelte/transition";
|
||||
import { array, boolean, string } from "zod";
|
||||
|
||||
import { nodeRunning } from "@app/lib/events";
|
||||
import { dynamicInterval, resetDynamicInterval } from "@app/lib/interval";
|
||||
@@ -17,6 +19,10 @@
|
||||
import Icon from "@app/components/Icon.svelte";
|
||||
import RepoAvatar from "@app/components/RepoAvatar.svelte";
|
||||
import ScrollArea from "@app/components/ScrollArea.svelte";
|
||||
import usePinnedDragReorder, {
|
||||
DRAG_RID_ATTRIBUTE,
|
||||
PINNED_LIST_CLASS,
|
||||
} from "@app/components/usePinnedDragReorder.svelte";
|
||||
|
||||
interface Props {
|
||||
initialRepos: RepoSummary[];
|
||||
@@ -104,6 +110,64 @@
|
||||
!window.localStorage,
|
||||
);
|
||||
|
||||
const pinnedRepoIds = useLocalStorage<string[]>(
|
||||
"sidebarPinnedRepos",
|
||||
array(string()),
|
||||
[],
|
||||
!window.localStorage,
|
||||
);
|
||||
|
||||
const pinnedRepos = $derived.by(() => {
|
||||
const byRid = new Map(repos.map(r => [r.rid, r]));
|
||||
return pinnedRepoIds.value
|
||||
.map(rid => byRid.get(rid))
|
||||
.filter((r): r is RepoSummary => r !== undefined);
|
||||
});
|
||||
|
||||
const unpinnedFilteredRepos = $derived(
|
||||
filteredRepos.filter(r => !pinnedRepoIds.value.includes(r.rid)),
|
||||
);
|
||||
|
||||
const unpinnedReposCount = $derived(
|
||||
repos.filter(r => !pinnedRepoIds.value.includes(r.rid)).length,
|
||||
);
|
||||
|
||||
const ANIMATION_DURATION_MS = 220;
|
||||
let animatingPinnedList = $state(false);
|
||||
let animationTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const animationDuration = $derived(
|
||||
animatingPinnedList ? ANIMATION_DURATION_MS : 0,
|
||||
);
|
||||
const [send, receive] = crossfade({
|
||||
duration: ANIMATION_DURATION_MS,
|
||||
fallback: () => ({ duration: 0 }),
|
||||
});
|
||||
|
||||
function withPinAnimation(fn: () => void) {
|
||||
animatingPinnedList = true;
|
||||
if (animationTimeout !== undefined) clearTimeout(animationTimeout);
|
||||
animationTimeout = setTimeout(() => {
|
||||
animatingPinnedList = false;
|
||||
}, ANIMATION_DURATION_MS);
|
||||
fn();
|
||||
}
|
||||
|
||||
function togglePin(rid: string) {
|
||||
withPinAnimation(() => {
|
||||
if (pinnedRepoIds.value.includes(rid)) {
|
||||
pinnedRepoIds.value = pinnedRepoIds.value.filter(r => r !== rid);
|
||||
} else {
|
||||
pinnedRepoIds.value = [rid, ...pinnedRepoIds.value];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const drag = usePinnedDragReorder({
|
||||
pinnedRepos: () => pinnedRepos,
|
||||
getOrder: () => pinnedRepoIds.value,
|
||||
setOrder: rids => withPinAnimation(() => (pinnedRepoIds.value = rids)),
|
||||
});
|
||||
|
||||
async function reloadRepos() {
|
||||
[repos, seededNotReplicated] = await Promise.all([
|
||||
invoke<RepoSummary[]>("list_repos_summary"),
|
||||
@@ -162,6 +226,12 @@
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
.pinned-list.empty {
|
||||
height: 0;
|
||||
padding: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
font: var(--txt-body-m-regular);
|
||||
font-variant-ligatures: none;
|
||||
@@ -186,64 +256,7 @@
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-radius: var(--border-radius-sm);
|
||||
font: var(--txt-body-m-regular);
|
||||
color: var(--color-text-primary);
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
text-decoration: none;
|
||||
}
|
||||
.nav-item:hover {
|
||||
background-color: var(--color-surface-subtle);
|
||||
}
|
||||
.nav-item.active {
|
||||
background-color: var(--color-surface-subtle);
|
||||
}
|
||||
.nav-item .global-counter-badge {
|
||||
margin-left: auto;
|
||||
}
|
||||
.sub-item {
|
||||
padding-left: 2rem;
|
||||
}
|
||||
.pending-item {
|
||||
color: var(--color-text-secondary);
|
||||
cursor: default;
|
||||
}
|
||||
.pending-avatar {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
.pending-item .remove-icon {
|
||||
display: none;
|
||||
margin-left: auto;
|
||||
color: var(--color-text-tertiary);
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
.pending-item:hover .remove-icon {
|
||||
display: flex;
|
||||
}
|
||||
.pending-item .remove-icon:hover {
|
||||
background-color: var(--color-surface-mid);
|
||||
}
|
||||
.nav-item .copy-rid {
|
||||
visibility: hidden;
|
||||
margin-left: auto;
|
||||
color: var(--color-text-tertiary);
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
.nav-item:hover .copy-rid {
|
||||
visibility: visible;
|
||||
}
|
||||
.nav-item .copy-rid:hover {
|
||||
background-color: var(--color-surface-mid);
|
||||
}
|
||||
|
||||
.filter-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -272,6 +285,146 @@
|
||||
.filter-input::placeholder {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-radius: var(--border-radius-sm);
|
||||
font: var(--txt-body-m-regular);
|
||||
color: var(--color-text-primary);
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
text-decoration: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
.nav-item :global(img),
|
||||
.nav-item :global(svg) {
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
.nav-item:hover {
|
||||
background-color: var(--color-surface-subtle);
|
||||
}
|
||||
.nav-item.active {
|
||||
background-color: var(--color-surface-subtle);
|
||||
}
|
||||
.nav-item .global-counter-badge {
|
||||
margin-left: auto;
|
||||
}
|
||||
.sub-item {
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
.pending-item {
|
||||
color: var(--color-text-secondary);
|
||||
cursor: default;
|
||||
}
|
||||
.pending-avatar {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
.pending-item .remove-icon {
|
||||
display: none;
|
||||
margin-left: auto;
|
||||
color: var(--color-text-tertiary);
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
.pending-item:hover .remove-icon {
|
||||
display: flex;
|
||||
}
|
||||
.pending-item .remove-icon:hover {
|
||||
background-color: var(--color-surface-mid);
|
||||
}
|
||||
|
||||
.nav-item .row-actions {
|
||||
visibility: hidden;
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
.nav-item:hover .row-actions,
|
||||
.nav-item .row-actions:has(:focus-visible) {
|
||||
visibility: visible;
|
||||
}
|
||||
.nav-item .row-actions :global(.clipboard),
|
||||
.nav-item .row-actions .pin-button,
|
||||
.nav-item .row-actions .drag-handle {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: var(--border-radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.nav-item .row-actions :global(.clipboard):hover,
|
||||
.nav-item .row-actions .pin-button:hover {
|
||||
background-color: var(--color-surface-mid);
|
||||
}
|
||||
.pin-button {
|
||||
background: none;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.repo-row-group {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.repo-row-group.drop-before::before,
|
||||
.repo-row-group.drop-after::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background-color: var(--color-border-mid);
|
||||
border-radius: 1px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.repo-row-group.drop-before::before {
|
||||
top: -3px;
|
||||
}
|
||||
.repo-row-group.drop-after::after {
|
||||
bottom: -3px;
|
||||
}
|
||||
.nav-item.dragging {
|
||||
opacity: 0.35;
|
||||
background-color: var(--color-surface-subtle);
|
||||
}
|
||||
:global(body.dragging-pinned-repo),
|
||||
:global(body.dragging-pinned-repo *) {
|
||||
cursor: grabbing !important;
|
||||
}
|
||||
.drag-ghost {
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
background-color: var(--color-surface-strong);
|
||||
border: 1px solid var(--color-border-mid);
|
||||
border-radius: var(--border-radius-sm);
|
||||
font: var(--txt-body-m-regular);
|
||||
color: var(--color-text-primary);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
max-width: 14rem;
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
@@ -319,6 +472,27 @@
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="repos-list {PINNED_LIST_CLASS}"
|
||||
class:empty={pinnedRepos.length === 0}>
|
||||
{#each pinnedRepos as repo (repo.rid)}
|
||||
<div
|
||||
class="repo-row-group"
|
||||
class:drop-before={drag.dropTargetRid === repo.rid &&
|
||||
drag.dropPosition === "before" &&
|
||||
drag.draggingRid !== repo.rid}
|
||||
class:drop-after={drag.dropTargetRid === repo.rid &&
|
||||
drag.dropPosition === "after" &&
|
||||
drag.draggingRid !== repo.rid}
|
||||
{...{ [DRAG_RID_ATTRIBUTE]: repo.rid }}
|
||||
animate:flip={{ duration: animationDuration }}
|
||||
in:receive={{ key: repo.rid, duration: animationDuration }}
|
||||
out:send={{ key: repo.rid, duration: animationDuration }}>
|
||||
{@render repoRowInner(repo, true)}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="section-header"
|
||||
onclick={() => {
|
||||
@@ -358,10 +532,10 @@
|
||||
if (e.key === "Escape") {
|
||||
filterOpen = false;
|
||||
filterQuery = "";
|
||||
} else if (e.key === "Enter" && filteredRepos.length > 0) {
|
||||
} else if (e.key === "Enter" && unpinnedFilteredRepos.length > 0) {
|
||||
void router.push({
|
||||
resource: "repo.home",
|
||||
rid: filteredRepos[0].rid,
|
||||
rid: unpinnedFilteredRepos[0].rid,
|
||||
});
|
||||
filterQuery = "";
|
||||
}
|
||||
@@ -381,7 +555,7 @@
|
||||
<span class="icon"><Icon name="filter" /></span>
|
||||
</button>
|
||||
</span>
|
||||
All Repos{repos.length > 1 ? ` (${repos.length})` : ""}
|
||||
All Repos{unpinnedReposCount > 1 ? ` (${unpinnedReposCount})` : ""}
|
||||
<span class="icon">
|
||||
<Icon name={reposExpanded.value ? "chevron-down" : "chevron-up"} />
|
||||
</span>
|
||||
@@ -398,77 +572,117 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#snippet repoRowInner(repo: RepoSummary, pinned: boolean = false)}
|
||||
{@const pinState = pinnedRepoIds.value.includes(repo.rid)}
|
||||
<a
|
||||
class="nav-item"
|
||||
class:active={isRepoHome(repo.rid)}
|
||||
class:dragging={pinned && drag.draggingRid === repo.rid}
|
||||
draggable="false"
|
||||
onmousedown={pinned ? e => drag.onMouseDown(e, repo.rid) : undefined}
|
||||
onclick={pinned ? drag.onClick : undefined}
|
||||
href={router.routeToPath({ resource: "repo.home", rid: repo.rid })}>
|
||||
<RepoAvatar name={repo.name} rid={repo.rid} styleWidth="1rem" />
|
||||
<span class="txt-overflow">{repo.name}</span>
|
||||
<span
|
||||
class="row-actions"
|
||||
role="none"
|
||||
onclick={e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}>
|
||||
<button
|
||||
class="pin-button"
|
||||
title={pinState ? "Unpin repository" : "Pin repository"}
|
||||
onclick={() => togglePin(repo.rid)}>
|
||||
<Icon name={pinState ? "pin-filled" : "pin-hollow"} />
|
||||
</button>
|
||||
<span title="Copy RID">
|
||||
<Clipboard text={repo.rid} noPopover />
|
||||
</span>
|
||||
{#if pinned}
|
||||
<span class="drag-handle" title="Drag to reorder">
|
||||
<Icon name="drag-handle" />
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
</a>
|
||||
{#if activeRid() === repo.rid}
|
||||
{@const activeProject = activeRepo?.payloads["xyz.radicle.project"]}
|
||||
{@render subItem(
|
||||
router.routeToPath({ resource: "repo.commits", rid: repo.rid }),
|
||||
"branch",
|
||||
"Commits",
|
||||
isCommits(repo.rid),
|
||||
activeCommitCount,
|
||||
)}
|
||||
{@render subItem(
|
||||
router.routeToPath({
|
||||
resource: "repo.issues",
|
||||
rid: repo.rid,
|
||||
status: "open",
|
||||
}),
|
||||
"issue",
|
||||
"Issues",
|
||||
isIssues(repo.rid),
|
||||
activeProject?.meta.issues.open || undefined,
|
||||
)}
|
||||
{@render subItem(
|
||||
router.routeToPath({
|
||||
resource: "repo.patches",
|
||||
rid: repo.rid,
|
||||
status: "open",
|
||||
}),
|
||||
"patch",
|
||||
"Patches",
|
||||
isPatches(repo.rid),
|
||||
activeProject?.meta.patches.open || undefined,
|
||||
)}
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet subItem(
|
||||
href: string,
|
||||
icon: "branch" | "issue" | "patch",
|
||||
label: string,
|
||||
active: boolean,
|
||||
count: number | undefined,
|
||||
)}
|
||||
<a class="nav-item sub-item" class:active {href}>
|
||||
<span class="icon"><Icon name={icon} /></span>
|
||||
{label}
|
||||
{#if count !== undefined}
|
||||
<span class="global-counter-badge">{count}</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/snippet}
|
||||
|
||||
{#if reposExpanded.value}
|
||||
<ScrollArea
|
||||
style="flex: 1; min-height: 0; mask-image: linear-gradient(to bottom, transparent 0, black 0.5rem, black calc(100% - 0.5rem), transparent 100%);">
|
||||
<div class="repos-list">
|
||||
{#each filteredRepos as repo (repo.rid)}
|
||||
<a
|
||||
class="nav-item"
|
||||
class:active={isRepoHome(repo.rid)}
|
||||
href={router.routeToPath({ resource: "repo.home", rid: repo.rid })}>
|
||||
<RepoAvatar name={repo.name} rid={repo.rid} styleWidth="1rem" />
|
||||
<span class="txt-overflow">{repo.name}</span>
|
||||
<span
|
||||
class="copy-rid"
|
||||
role="none"
|
||||
title="Copy RID"
|
||||
onclick={e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}>
|
||||
<Clipboard text={repo.rid} noPopover />
|
||||
</span>
|
||||
</a>
|
||||
{#if activeRid() === repo.rid}
|
||||
{@const activeProject = activeRepo?.payloads["xyz.radicle.project"]}
|
||||
<a
|
||||
class="nav-item sub-item"
|
||||
class:active={isCommits(repo.rid)}
|
||||
href={router.routeToPath({
|
||||
resource: "repo.commits",
|
||||
rid: repo.rid,
|
||||
})}>
|
||||
<span class="icon"><Icon name="branch" /></span>
|
||||
Commits
|
||||
{#if activeCommitCount !== undefined}
|
||||
<span class="global-counter-badge">{activeCommitCount}</span>
|
||||
{/if}
|
||||
</a>
|
||||
<a
|
||||
class="nav-item sub-item"
|
||||
class:active={isIssues(repo.rid)}
|
||||
href={router.routeToPath({
|
||||
resource: "repo.issues",
|
||||
rid: repo.rid,
|
||||
status: "open",
|
||||
})}>
|
||||
<span class="icon"><Icon name="issue" /></span>
|
||||
Issues
|
||||
{#if activeProject && activeProject.meta.issues.open > 0}
|
||||
<span class="global-counter-badge">
|
||||
{activeProject.meta.issues.open}
|
||||
</span>
|
||||
{/if}
|
||||
</a>
|
||||
<a
|
||||
class="nav-item sub-item"
|
||||
class:active={isPatches(repo.rid)}
|
||||
href={router.routeToPath({
|
||||
resource: "repo.patches",
|
||||
rid: repo.rid,
|
||||
status: "open",
|
||||
})}>
|
||||
<span class="icon"><Icon name="patch" /></span>
|
||||
Patches
|
||||
{#if activeProject && activeProject.meta.patches.open > 0}
|
||||
<span class="global-counter-badge">
|
||||
{activeProject.meta.patches.open}
|
||||
</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/if}
|
||||
{#each unpinnedFilteredRepos as repo (repo.rid)}
|
||||
<div
|
||||
class="repo-row-group"
|
||||
animate:flip={{ duration: animationDuration }}
|
||||
in:receive={{ key: repo.rid, duration: animationDuration }}
|
||||
out:send={{ key: repo.rid, duration: animationDuration }}>
|
||||
{@render repoRowInner(repo, false)}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
{/if}
|
||||
|
||||
{#if drag.draggedRepo}
|
||||
<div
|
||||
class="drag-ghost"
|
||||
style:left="{drag.ghostX + 12}px"
|
||||
style:top="{drag.ghostY + 8}px">
|
||||
<RepoAvatar
|
||||
name={drag.draggedRepo.name}
|
||||
rid={drag.draggedRepo.rid}
|
||||
styleWidth="1rem" />
|
||||
<span class="txt-overflow">{drag.draggedRepo.name}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { RepoSummary } from "@bindings/repo/RepoSummary";
|
||||
|
||||
interface Options {
|
||||
pinnedRepos: () => RepoSummary[];
|
||||
getOrder: () => string[];
|
||||
setOrder: (rids: string[]) => void;
|
||||
}
|
||||
|
||||
export const PINNED_LIST_CLASS = "pinned-list";
|
||||
export const DRAG_RID_ATTRIBUTE = "data-drag-rid";
|
||||
|
||||
const THRESHOLD_PX = 4;
|
||||
const BODY_DRAGGING_CLASS = "dragging-pinned-repo";
|
||||
|
||||
export default function usePinnedDragReorder(options: Options) {
|
||||
let dragStart: { rid: string; x: number; y: number } | undefined;
|
||||
let suppressClick = false;
|
||||
let draggingRid = $state<string | undefined>(undefined);
|
||||
let dropTargetRid = $state<string | undefined>(undefined);
|
||||
let dropPosition = $state<"before" | "after" | undefined>(undefined);
|
||||
let ghostX = $state(0);
|
||||
let ghostY = $state(0);
|
||||
|
||||
const draggedRepo = $derived(
|
||||
draggingRid !== undefined
|
||||
? options.pinnedRepos().find(r => r.rid === draggingRid)
|
||||
: undefined,
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (draggingRid !== undefined) {
|
||||
document.body.classList.add(BODY_DRAGGING_CLASS);
|
||||
return () => document.body.classList.remove(BODY_DRAGGING_CLASS);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onWindowMouseMove);
|
||||
window.removeEventListener("mouseup", onWindowMouseUp);
|
||||
};
|
||||
});
|
||||
|
||||
function onMouseDown(e: MouseEvent, rid: string) {
|
||||
if (e.button !== 0) return;
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest("button, .clipboard")) return;
|
||||
suppressClick = false;
|
||||
dragStart = { rid, x: e.clientX, y: e.clientY };
|
||||
window.addEventListener("mousemove", onWindowMouseMove);
|
||||
window.addEventListener("mouseup", onWindowMouseUp);
|
||||
}
|
||||
|
||||
function onWindowMouseMove(e: MouseEvent) {
|
||||
if (!dragStart) return;
|
||||
if (!draggingRid) {
|
||||
const dx = e.clientX - dragStart.x;
|
||||
const dy = e.clientY - dragStart.y;
|
||||
if (Math.hypot(dx, dy) < THRESHOLD_PX) return;
|
||||
draggingRid = dragStart.rid;
|
||||
}
|
||||
ghostX = e.clientX;
|
||||
ghostY = e.clientY;
|
||||
updateDropTarget(e.clientX, e.clientY);
|
||||
}
|
||||
|
||||
function updateDropTarget(x: number, y: number) {
|
||||
const el = document.elementFromPoint(x, y) as HTMLElement | null;
|
||||
const list = el?.closest(`.${PINNED_LIST_CLASS}`) as HTMLElement | null;
|
||||
if (!list) {
|
||||
dropTargetRid = undefined;
|
||||
dropPosition = undefined;
|
||||
return;
|
||||
}
|
||||
let row = el?.closest(`[${DRAG_RID_ATTRIBUTE}]`) as HTMLElement | null;
|
||||
if (!row) {
|
||||
const rows = Array.from(
|
||||
list.querySelectorAll(`[${DRAG_RID_ATTRIBUTE}]`),
|
||||
) as HTMLElement[];
|
||||
let nearestDistance = Infinity;
|
||||
for (const r of rows) {
|
||||
const rect = r.getBoundingClientRect();
|
||||
const distance = Math.abs(y - (rect.top + rect.height / 2));
|
||||
if (distance < nearestDistance) {
|
||||
nearestDistance = distance;
|
||||
row = r;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!row) return;
|
||||
const rid = row.getAttribute(DRAG_RID_ATTRIBUTE);
|
||||
if (!rid) return;
|
||||
const rect = row.getBoundingClientRect();
|
||||
dropTargetRid = rid;
|
||||
dropPosition = y - rect.top < rect.height / 2 ? "before" : "after";
|
||||
}
|
||||
|
||||
function onWindowMouseUp() {
|
||||
window.removeEventListener("mousemove", onWindowMouseMove);
|
||||
window.removeEventListener("mouseup", onWindowMouseUp);
|
||||
|
||||
const dragging = draggingRid;
|
||||
const target = dropTargetRid;
|
||||
const position = dropPosition;
|
||||
|
||||
if (dragging && target && position && dragging !== target) {
|
||||
const without = options.getOrder().filter(r => r !== dragging);
|
||||
const targetIdx = without.indexOf(target);
|
||||
if (targetIdx !== -1) {
|
||||
const insertAt = position === "after" ? targetIdx + 1 : targetIdx;
|
||||
without.splice(insertAt, 0, dragging);
|
||||
options.setOrder(without);
|
||||
}
|
||||
}
|
||||
|
||||
suppressClick = dragging !== undefined;
|
||||
dragStart = undefined;
|
||||
draggingRid = undefined;
|
||||
dropTargetRid = undefined;
|
||||
dropPosition = undefined;
|
||||
}
|
||||
|
||||
function onClick(e: MouseEvent) {
|
||||
if (suppressClick) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
suppressClick = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get draggingRid() {
|
||||
return draggingRid;
|
||||
},
|
||||
get dropTargetRid() {
|
||||
return dropTargetRid;
|
||||
},
|
||||
get dropPosition() {
|
||||
return dropPosition;
|
||||
},
|
||||
get draggedRepo() {
|
||||
return draggedRepo;
|
||||
},
|
||||
get ghostX() {
|
||||
return ghostX;
|
||||
},
|
||||
get ghostY() {
|
||||
return ghostY;
|
||||
},
|
||||
onMouseDown,
|
||||
onClick,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user