feat: add selection & batch operations #79

Merged
thm merged 5 commits from feat/webui-selection into main 2023-10-22 16:21:35 +02:00
8 changed files with 503 additions and 33 deletions

View File

@ -2,12 +2,11 @@
import { createEventDispatcher, onMount, tick } from "svelte";
import { normUrl } from "../util/history";
import Inspect from "./Inspect.svelte";
import IconButton from "./utils/IconButton.svelte";
import { selected } from "./EntitySelect.svelte";
const dispatch = createEventDispatcher();
export let address: string;
export let index: number;
export let detachUrl: string | undefined = undefined;
export let only: boolean;
let detail = only;
@ -19,9 +18,12 @@
// Required to make detail mode detection work in Browse
dispatch("detail", detail);
});
$: if ($selected.length) {
detail = false;
}
function visit() {
window.open(normUrl(`/browse/${address}`), "_blank");
window.open(normUrl(detachUrl), "_blank");
}
let width = 460;
@ -48,9 +50,11 @@
<div class="browse-column" class:detail>
<div class="view" style="--width: {width}px">
<header>
<IconButton name="link" on:click={() => visit()} disabled={only}>
Detach
</IconButton>
{#if detachUrl}
<IconButton name="link" on:click={() => visit()} disabled={only}>
Detach
</IconButton>
{/if}
<IconButton
name={detail ? "zoom-out" : "zoom-in"}
on:click={() => {
@ -69,7 +73,7 @@
Close
</IconButton>
</header>
<Inspect {address} {index} {detail} on:resolved on:close />
<slot {detail} />
</div>
<div class="resizeHandle" on:mousedown|preventDefault={drag} />
</div>

View File

@ -0,0 +1,178 @@
<script lang="ts" context="module">
import { writable } from "svelte/store";
import api from "../lib/api";
export const selected = writable<string[]>([]);
export async function selectGroup(address: string) {
const result = await api.query(
new Query().matches(undefined, ATTR_IN, `@${address}`),
);
result.entities
.map((e) => e.replace(/^@/, ""))
.forEach((entity) => {
selected.update((selected) => {
if (!selected.includes(entity)) {
return [...selected, entity];
} else {
return selected;
}
});
});
}
</script>
<script lang="ts">
import { onMount } from "svelte";
import { i18n } from "../i18n";
import { Query } from "@upnd/upend";
import { ATTR_IN } from "@upnd/upend/constants";
let canvas: HTMLCanvasElement;
onMount(() => {
const ctx = canvas.getContext("2d");
function resizeCanvas() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
window.addEventListener("resize", resizeCanvas);
resizeCanvas();
let selecting = false;
let selectAllArea: DOMRect | undefined = undefined;
let selectAllAddress: string | undefined = undefined;
let addressesToRemove = new Set();
document.addEventListener("mousedown", (ev) => {
if (ev.ctrlKey) {
ev.preventDefault();
selecting = true;
addressesToRemove = new Set();
const el = document.elementFromPoint(
ev.clientX,
ev.clientY,
) as HTMLElement;
const groupElement = el.closest("[data-address-group]") as
| HTMLElement
| undefined;
if (groupElement) {
const banner = groupElement.querySelector("h2 .banner");
if (banner) {
const rect = banner.getBoundingClientRect();
selectAllArea = rect;
selectAllAddress = groupElement.dataset.addressGroup;
ctx.rect(rect.left, rect.top, rect.width, rect.height);
ctx.fillStyle = "#dc322f33";
ctx.fill();
ctx.fillStyle = "#dc322f77";
ctx.font = `bold ${rect.height / 2}px Inter`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(
$i18n.t("Select All"),
rect.left + rect.width / 2,
rect.top + rect.height / 2,
);
}
}
ctx.strokeStyle = "#dc322f77";
ctx.lineWidth = 7;
ctx.beginPath();
ctx.moveTo(ev.clientX, ev.clientY);
}
});
document.addEventListener("mousemove", (ev) => {
if (selecting) {
ev.preventDefault();
if (selectAllArea) {
if (
ev.clientX > selectAllArea.left &&
ev.clientX < selectAllArea.right &&
ev.clientY > selectAllArea.top &&
ev.clientY < selectAllArea.bottom
) {
selectGroup(selectAllAddress);
stop();
}
}
const el = document.elementFromPoint(
ev.clientX,
ev.clientY,
) as HTMLElement;
const addressElement = el.closest("[data-address]") as
| HTMLElement
| undefined;
if (addressElement) {
const address = addressElement.dataset.address;
const selectMode = addressElement.dataset.selectMode;
if (selectMode === "add" || selectMode === undefined) {
selected.update((selected) => {
if (!selected.includes(address)) {
return [...selected, address];
} else {
return selected;
}
});
} else if (selectMode === "remove") {
addressesToRemove.add(address);
}
}
ctx.lineTo(ev.clientX, ev.clientY);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(ev.clientX, ev.clientY);
}
});
document.addEventListener("mouseup", () => {
stop();
});
function stop() {
selectAllArea = undefined;
selectAllAddress = undefined;
selecting = false;
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const address of addressesToRemove) {
selected.update((selected) => {
return selected.filter((a) => a !== address);
});
}
}
});
</script>
<div class="selectIndicator">
<canvas bind:this={canvas}></canvas>
</div>
<style lang="scss">
.selectIndicator {
position: absolute;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
pointer-events: none;
canvas {
width: 100%;
height: 100%;
}
}
</style>

View File

@ -9,6 +9,7 @@
icon?: string;
components: (input: {
entries: UpEntry[];
entities: string[];
group?: string;
address?: string;
}) => Array<WidgetComponent>;
@ -25,7 +26,8 @@
import LabelBorder from "./utils/LabelBorder.svelte";
const dispatch = createEventDispatcher();
export let entries: UpEntry[];
export let entries: UpEntry[] = [];
export let entities: string[] = [];
export let widgets: Widget[] | undefined = undefined;
export let initialWidget: string | undefined = undefined;
export let title: string | undefined = undefined;
@ -43,18 +45,23 @@
let availableWidgets: Widget[] = [];
$: {
availableWidgets = [
{
name: "Entry List",
icon: "table",
components: ({ entries }) => [
{
component: EntryList,
props: { entries, columns: "entity, attribute, value" },
},
],
},
];
availableWidgets = [];
if (entries.length) {
availableWidgets = [
...availableWidgets,
{
name: "Entry List",
icon: "table",
components: ({ entries }) => [
{
component: EntryList,
props: { entries, columns: "entity, attribute, value" },
},
],
},
];
}
if (widgets?.length) {
availableWidgets = [...widgets, ...availableWidgets];
@ -71,11 +78,11 @@
$: {
components = availableWidgets
.find((w) => w.name === currentWidget)
.components({ entries, group, address });
.components({ entries, entities, group, address });
}
</script>
<LabelBorder hide={entries.length === 0}>
<LabelBorder hide={entries.length === 0 && entities.length === 0}>
<svelte:fragment slot="header-full">
<h3 class:highlighted>
{#if group}

View File

@ -17,7 +17,12 @@
import EntryList from "./widgets/EntryList.svelte";
import api from "../lib/api";
import EntityList from "./widgets/EntityList.svelte";
import { ATTR_IN, ATTR_LABEL, ATTR_KEY, ATTR_OF } from "@upnd/upend/constants";
import {
ATTR_IN,
ATTR_LABEL,
ATTR_KEY,
ATTR_OF,
} from "@upnd/upend/constants";
import InspectGroups from "./InspectGroups.svelte";
import InspectTypeEditor from "./InspectTypeEditor.svelte";
import LabelBorder from "./utils/LabelBorder.svelte";
@ -281,7 +286,7 @@
],
},
{
name: "EntityList",
name: "Entity List",
icon: "image",
components: ({ entries, address }) => [
{
@ -355,7 +360,12 @@
});
</script>
<div class="inspect" class:detail class:blob={blobHandled}>
<div
class="inspect"
class:detail
class:blob={blobHandled}
data-address-group={address}
>
<header>
<h2>
{#if $entity}

View File

@ -0,0 +1,163 @@
<script lang="ts">
import UpObjectDisplay from "./display/UpObject.svelte";
import Selector from "./utils/Selector.svelte";
import IconButton from "./utils/IconButton.svelte";
import type { IValue } from "@upnd/upend/types";
import api from "../lib/api";
import { ATTR_IN } from "@upnd/upend/constants";
import { i18n } from "../i18n";
import LabelBorder from "./utils/LabelBorder.svelte";
import { Query, UpListing } from "@upnd/upend";
export let entities: string[];
let adding = false;
let groupSelector: Selector;
$: if (adding && groupSelector) groupSelector.focus();
let groups = [];
let groupListing: UpListing | undefined = undefined;
async function updateGroups() {
const currentEntities = entities.concat();
const allGroups = await api.query(
new Query().matches(
currentEntities.map((e) => `@${e}`),
ATTR_IN,
undefined,
),
);
const commonGroups = new Set(
allGroups.values
.filter((v) => v.t == "Address")
.map((v) => v.c)
.filter((groupAddr) => {
return Object.values(allGroups.objects).every((obj) => {
return obj.attr[ATTR_IN].some((v) => v.value.c === groupAddr);
});
}),
);
if (entities.toString() == currentEntities.toString()) {
groups = Array.from(commonGroups);
groupListing = allGroups;
}
}
$: entities && updateGroups();
let groupToAdd: IValue | undefined;
async function addGroup() {
if (!groupToAdd) {
return;
}
await api.putEntry(
entities.map((entity) => ({
entity,
attribute: ATTR_IN,
value: {
t: "Address",
c: String(groupToAdd.c),
},
})),
);
groupToAdd = undefined;
await updateGroups();
}
async function removeGroup(groupAddress: string) {
if (confirm($i18n.t("Are you sure you want to remove this group?"))) {
await Promise.all(
entities.map((entity) =>
api.deleteEntry(
groupListing.objects[entity].attr[ATTR_IN].find(
(v) => v.value.c === groupAddress,
).address,
),
),
);
await updateGroups();
}
}
</script>
<LabelBorder>
<span slot="header">{$i18n.t("Common groups")}</span>
{#if adding}
<div class="selector">
<Selector
bind:this={groupSelector}
type="value"
valueTypes={["Address"]}
bind:value={groupToAdd}
on:input={addGroup}
on:focus={(ev) => {
if (!ev.detail) adding = false;
}}
placeholder={$i18n.t("Choose an entity...")}
/>
</div>
{/if}
<div class="body">
<div class="group-list">
{#each groups as groupAddress}
<div class="group">
<UpObjectDisplay address={groupAddress} link />
<IconButton
subdued
name="x-circle"
on:click={() => removeGroup(groupAddress)}
/>
</div>
{:else}
<div class="no-groups">
{$i18n.t("Entities have no groups in common.")}
</div>
{/each}
</div>
{#if !adding}
<div class="add-button">
<IconButton
outline
small
name="folder-plus"
on:click={() => (adding = true)}
/>
</div>
{/if}
</div>
</LabelBorder>
<style lang="scss">
.group-list {
display: flex;
flex-wrap: wrap;
gap: 0.25rem 0.2rem;
align-items: center;
}
.group {
display: inline-flex;
align-items: center;
}
.body {
display: flex;
align-items: start;
.group-list {
flex-grow: 1;
}
}
.selector {
width: 100%;
margin-bottom: 0.5rem;
}
.no-groups {
opacity: 0.66;
}
</style>

View File

@ -0,0 +1,72 @@
<script lang="ts">
import { i18n } from "../i18n";
import { selected } from "./EntitySelect.svelte";
import EntryView from "./EntryView.svelte";
import MultiGroupEditor from "./MultiGroupEditor.svelte";
import Icon from "./utils/Icon.svelte";
import EntityList from "./widgets/EntityList.svelte";
const selectedWidgets = [
{
name: "List",
icon: "list-check",
components: ({ entities }) => [
{
component: EntityList,
props: {
entities,
thumbnails: false,
select: "remove",
},
},
],
},
{
name: "EntityList",
icon: "image",
components: ({ entities }) => [
{
component: EntityList,
props: {
entities,
thumbnails: true,
select: "remove",
},
},
],
},
];
</script>
<div class="view">
<h2><Icon plain name="select-multiple" /> {$i18n.t("Selected")}: {$selected.length}</h2>
<div class="actions">
<MultiGroupEditor entities={$selected} />
</div>
<div class="entities">
<EntryView
title={$i18n.t("Selected entities")}
entities={$selected}
widgets={selectedWidgets}
/>
</div>
</div>
<style lang="scss">
.view {
display: flex;
flex-direction: column;
height: 100%;
}
h2 {
text-align: center;
margin: 0;
}
.entities {
flex-grow: 1;
overflow-y: auto;
height: 0;
}
</style>

View File

@ -7,17 +7,18 @@
import UpObjectCard from "../display/UpObjectCard.svelte";
import { ATTR_LABEL } from "@upnd/upend/constants";
import { i18n } from "../../i18n";
import Icon from "../utils/Icon.svelte";
import IconButton from "../utils/IconButton.svelte";
import Selector from "../utils/Selector.svelte";
import { createEventDispatcher } from "svelte";
import type { WidgetChange } from "src/types/base";
import debug from "debug";
import { selected } from "../EntitySelect.svelte";
const dispatch = createEventDispatcher();
const dbg = debug(`kestrel:EntityList`);
export let entities: Address[];
export let thumbnails = true;
export let select: "add" | "remove" = "add";
export let sort = true;
export let address: Address | undefined = undefined;
@ -169,7 +170,13 @@
{/if}
<div class="items">
{#each sortedEntities as entity (entity)}
<div data-address={entity} use:observe class="item">
<div
data-address={entity}
data-select-mode={select}
use:observe
class="item"
class:selected={select === "add" && $selected.includes(entity)}
>
{#if visible.has(entity)}
{#if thumbnails}
<UpObjectCard
@ -242,6 +249,8 @@
</div>
<style lang="scss">
@use "../../styles/colors";
.items {
gap: 4px;
}
@ -327,4 +336,9 @@
.entitylist.style-grid .add {
grid-column: 1 / -1;
}
.selected {
margin: 0.12rem;
box-shadow: 0 0 0.1rem 0.11rem colors.$red;
}
</style>

View File

@ -3,6 +3,9 @@
import BrowseAdd from "../components/BrowseAdd.svelte";
import BrowseColumn from "../components/BrowseColumn.svelte";
import { updateTitle } from "../util/title";
import EntitySelect, { selected } from "../components/EntitySelect.svelte";
import SelectedColumn from "../components/SelectedColumn.svelte";
import Inspect from "../components/Inspect.svelte";
const navigate = useNavigate();
const params = useParams();
@ -51,12 +54,16 @@
}
let detailMode = false;
function onDetailChanged(index: number, ev: CustomEvent<boolean>) {
function onDetailChanged(
index: number | "selected",
ev: CustomEvent<boolean>,
) {
if (ev.detail) {
scrollToVisible(index);
}
detailMode = addresses.length === 1 && ev.detail;
}
$: only = addresses.length === 1 && !$selected.length;
$: updateTitle("Browse", identities.join(" / "));
</script>
@ -69,15 +76,28 @@
{#each addresses as address, index}
<div class="column" data-index={index}>
<BrowseColumn
{address}
{index}
only={addresses.length === 1}
detachUrl="/browse/{address}"
{only}
on:close={() => close(index)}
on:resolved={(ev) => onIdentified(index, ev)}
on:detail={(ev) => onDetailChanged(index, ev)}
/>
let:detail
>
<Inspect {address} {index} {detail} on:resolved on:close />
</BrowseColumn>
</div>
{/each}
{#if $selected.length}
<div class="column" data-index="selected">
<BrowseColumn
{only}
on:close={() => selected.set([])}
on:detail={(ev) => onDetailChanged("selected", ev)}
>
<SelectedColumn />
</BrowseColumn>
</div>
{/if}
{#if !detailMode}
{#key addresses}
<div class="column" data-index="add">
@ -90,6 +110,8 @@
{/if}
</div>
<EntitySelect />
<style lang="scss">
.browser {
height: 100%;