Files
Aislo/A00_Common/spreadsheet/spreadsheet_links.ts
T

566 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* spreadsheet_links.ts (주인 D)
* 아래 알약 막대(표 · 탭 · 합계 줄 아래 맨 끝) — 면적산출(입력값) · 재료산출(자재) 알약을 칸에 연결(PLAN 13장).
* 칸을 고른 채 밝은 알약을 누르면 그 칸에 연결 · 알약을 끌어 칸에 놓아도 연결(40-3) · 칸 하나에 알약 여럿 —
* 이미 다른 알약이 있으면 [함께 잇기] · [바꾸기] · [취소] 물음(`spreadsheet_link_ask`) · 이은 알약 = 키로 정해지는
* 색(`spreadsheet_link_color`) · 이은 칸 = 먼저 이은 알약 색 옅게 + 왼아래 세모 + 여럿이면 오른위 작은 수 ·
* 칸에 마우스를 올리면 이은 알약 이름들 · 이은 알약을 누르면 연결 칸으로 · 우클릭 [연결 풀기] ·
* 자재 모드는 [자재 더하기] → 고르기 창 → 새 알약.
* 자재 모드 알약 목록 = 호스트(M02)가 `항목` 으로 줌(자재 정보 포함 · 저장은 마스터 자재 조합) — [자재 더하기] ·
* 우클릭 [목록에서 빼기] 때 `onItemsChange(목록)` 으로 알림(부른 쪽이 마스터에 적음 · 여기는 안 적음).
* 저장 = 시트 `연결`(계약) · 바꿈은 `시트통째` 명령(되돌리기 짝) · 행열 넣기 · 지우기는 `applyLinked` 가
* 연결 칸을 옮김(지운 칸이면 빠짐). `createSpreadsheet` 가 `links` 선택 또는 연결 있는 문서일 때만 불러옴(크기 예산).
* ========================================================================== */
import "./spreadsheet_links.css";
import { createMapContextMenu } from "@ui/ui_template_context_menu";
import { el, showToast } from "@ui/ui_template_elements";
import { inRange, parseA1, parseRange, quoteSheet, toA1 } from "./spreadsheet_address";
import { applyCommand } from "./spreadsheet_commands";
import { askShare } from "./spreadsheet_link_ask";
import { linkHues } from "./spreadsheet_link_color";
import { linkCommand, linksAt } from "./spreadsheet_link_set";
import type { slotOf } from "./spreadsheet_editor";
import { shiftSpan } from "./spreadsheet_refshift";
import { selectCell } from "./spreadsheet_selection";
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
import type {
CellLink,
Command,
CommandEffect,
LinkMaterial,
Sheet,
Workbook,
} from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
/** 알약 한 개(입력값) — `키` 는 부른 쪽(일위대가 입력값 키) */
export interface LinkItem {
키: string;
이름: string;
단위?: string;
/** 자재 알약 — 연결하면 이 정보가 칸 연결에 들어감 */
자재?: LinkMaterial;
/** 호스트가 정한 알약 — 자재 모드에서도 [목록에서 빼기]가 안 뜸(예: 선택 입력 카드가 자리를 고른 입력) */
고정?: boolean;
}
export interface LinkOptions {
항목: LinkItem[];
/** 입력 = 항목이 알약 · 자재 = 항목(자재 정보 포함 · 호스트 목록) + [자재 더하기] 로 더한 자재 */
모드: "입력" | "자재";
/** 자재 모드에서 목록이 바뀔 때(더하기 · 빼기) 새 목록을 알림 — 저장은 부른 쪽 몫 */
onItemsChange?: (항목: LinkItem[]) => void;
/** 자재DB 찾기(빈 글 = 처음 목록) — 자재 모드만 */
자재찾기?: (글: string) => Promise<LinkMaterial[]>;
/** 입력 모드 — 호스트가 항목 목록을 믿을 수 있게 받았음(받기 실패 · 빈 목록 구분) · true 일 때만
* 목록에 없는 연결 알약에 ⚠ + [×] */
확정?: boolean;
/** 다른 곳으로 보낸 칸 범위 표시(예: 도면 표) — 시트 id · 범위 · 이름표 · 마우스 올림 글 */
표시범위?: { 시트: string; 범위: string; 이름: string; 설명?: string }[];
}
export interface Links extends PartHandle {
/** `applyCommand` + 연결 따라가기 */
apply(book: Workbook, command: Command): CommandEffect;
}
const TEXT = {
bar: "알약 — 칸을 고른 채 누르거나 끌어 칸에 놓으면 연결 · 색 알약은 누르면 연결 칸으로 · 우클릭 풀기",
free: "칸을 고른 채 누르거나 끌어 칸에 놓으면 이 칸에 연결",
linked: "누르면 연결 칸으로 · 우클릭 연결 풀기",
unlink: "연결 풀기",
remove: "목록에서 빼기",
removed: "목록에서 뺌 — 연결도 풀림",
add: "+ 자재 추가",
search: "자재 이름 · 규격 · 코드",
searching: "찾는 중…",
none: "맞는 자재 없음",
fail: "자재 목록을 못 받음",
picked: "칸을 고른 뒤 새 알약을 누르면 연결",
dup: "이미 목록에 있는 자재",
orphan: "일위대가에 없는 알약 — 연결을 풀면 [저장] 때 남음",
orphanX: "연결 풀기",
};
type RowCol = Extract<Command, { 종류: "행넣기" | "행지우기" | "열넣기" | "열지우기" }>;
const isRowCol = (c: Command): c is RowCol =>
c.종류 === "행넣기" || c.종류 === "행지우기" || c.종류 === "열넣기" || c.종류 === "열지우기";
const flat = (c: Command): Command[] => (c.종류 === "묶음" ? c.명령.flatMap(flat) : [c]);
/** 행열 넣기 · 지우기 한 번에 맞춰 연결 칸을 옮김 — 지운 칸 연결은 뺌(순수 · 시험이 부름) */
export function shiftLinks(links: CellLink[], cmd: RowCol): CellLink[] {
const rows = cmd.종류.startsWith("행");
const insert = cmd.종류.endsWith("넣기");
return links.flatMap((link) => {
const p = parseA1(link.칸);
if (!p) return [link];
const i = rows ? p.r : p.c;
const to = shiftSpan(i, i, insert, cmd.at, cmd.수, rows ? MAX_ROWS : MAX_COLS)?.[0];
if (to === undefined) return [];
return [{ ...link, 칸: rows ? toA1(to, p.c) : toA1(p.r, to) }];
});
}
const setLinks = (s: Sheet, list: CellLink[]): void => {
if (list.length) s.연결 = list;
else delete s.연결;
};
/** 명령 적용 — 행열 명령이 연결 있는 시트를 건드리면 연결도 옮기고 되돌림 짝 끝에 명령 전 시트를 더함
* (A 의 되돌림은 바뀐 시트만 통째로 — 연결만 바뀐 시트도 돌아오게) */
export function applyLinked(book: Workbook, command: Command): CommandEffect {
const ops = flat(command).filter(isRowCol);
const hit = new Set(ops.map((o) => o.시트));
const before = book.시트
.filter((s) => hit.has(s.id) && s.연결?.length)
.map((s) => structuredClone(s));
const eff = applyCommand(book, command);
if (!before.length) return eff;
for (const op of ops) {
const s = book.시트.find((x) => x.id === op.시트);
if (s?.연결) setLinks(s, shiftLinks(s.연결, op));
}
const restore = before.map((시트): Command => ({ 종류: "시트통째", 시트 }));
return { ...eff, undo: { 종류: "묶음", 명령: [eff.undo, ...restore] } };
}
/** 자재 → 알약(키 = 자재 코드) */
const ofMaterial = (m: LinkMaterial): LinkItem => ({
키: m.코드,
이름: m.규격 ? `${m.이름} ${m.규격}` : m.이름,
단위: m.단위,
자재: m,
});
export function mountLinks(
ctx: SpreadsheetContext,
opts: LinkOptions | undefined,
back: () => void,
/** 편집기 `slotOf` — 부른 쪽이 넘김(여기서 끌어오면 편집기가 따로 쪼개져 크기가 늚) */
slot: typeof slotOf,
): Links {
const bar = el("div", { className: "ss-links", attrs: { title: TEXT.bar } });
// 상태줄(합계 줄) 아래 맨 끝(표는 flex 라 막대 높이만큼 줄어듦 — 여러 줄이어도 페이지 스크롤 없음)
if (opts) ctx.root.querySelector(":scope > .ss-statusbar")?.after(bar);
const menu = createMapContextMenu("ss");
ctx.root.append(menu.element);
/** 알약 목록 — 호스트가 준 항목 + 더한 자재(자재 모드 · 바뀌면 호스트에 알림) */
const items: LinkItem[] = opts ? [...opts.항목] : [];
let dots: HTMLElement[] = [];
/** 끄는 중인 알약 · 놓을 칸 표시 */
let dragging: LinkItem | null = null;
const dropMark = el("div", { className: "ss-link-drop" });
let pick: HTMLElement | null = null;
/** 마지막으로 강조를 판정한 칸(시트 id!칸) — 바뀔 때만 반짝 · 스크롤 */
let lastAt = "";
/** 키 → 연결(시트 · 줄) */
function found(): Map<string, { sheet: Sheet; link: CellLink }> {
const out = new Map<string, { sheet: Sheet; link: CellLink }>();
for (const sheet of ctx.book.시트)
for (const link of sheet.연결 ?? []) out.set(link.키, { sheet, link });
return out;
}
/** 시트 연결을 바꿔 넣음 — `시트통째` 라 되돌리기 한 번 */
function put(sheet: Sheet, list: CellLink[]): void {
const next = structuredClone(sheet);
setLinks(next, list);
ctx.dispatch({ 종류: "시트통째", 시트: next });
}
/** 알약을 칸에 이음 — `at` 없으면 지금 고른 칸 · 그 칸에 다른 알약이 있으면 함께 · 바꾸기 · 취소 물음 */
async function link(item: LinkItem, at?: string): Promise<void> {
if (ctx.readOnly) return;
const sheet = ctx.sheet();
const 칸 = at ?? toA1(ctx.selection.활성.r, ctx.selection.활성.c);
const others = linksAt(ctx.book, sheet.id, 칸, item.키);
const choice = others.length
? await askShare(
칸,
others.map((l) => l.이름 ?? l.키),
)
: "함께";
if (!choice) return back();
const info = { 이름: item.이름, 단위: item.단위, 자재: item.자재 };
const command = linkCommand(ctx.book, sheet.id, item.키, 칸, info, choice === "바꾸기");
if (command) ctx.dispatch(command);
back();
}
/** 그 칸으로 가서 눈에 띄게 깜빡임(굵은 테두리 + 채움 · 테마 색 · 끝나면 스스로 사라짐) */
function goTo(sheet: Sheet, a1: string): void {
const p = parseA1(a1);
if (!p) return;
if (sheet.id !== ctx.selection.시트) ctx.showSheet(sheet.id);
ctx.select(selectCell(ctx.sheet(), p.r, p.c), true);
back();
requestAnimationFrame(() => {
const { layer, box } = slot(ctx, p.r, p.c);
if (!box.w || !box.h) return;
const f = el("div", { className: "ss-link-flash" });
f.style.cssText = `left:${box.x}px;top:${box.y}px;width:${box.w}px;height:${box.h}px`;
f.addEventListener("animationend", () => f.remove());
layer.append(f);
});
}
const commit = (): void => opts?.onItemsChange?.(items.map((i) => ({ ...i })));
function pill(
item: LinkItem,
hit: { sheet: Sheet; link: CellLink } | undefined,
hue: number | undefined,
orphan: boolean,
here: string,
) {
const where = hit
? hit.sheet.id === ctx.selection.시트
? hit.link.칸
: `${quoteSheet(hit.sheet.이름)}!${hit.link.칸}`
: "";
const b = el("button", {
className: `ss-link${hit ? " is-linked" : ""}${orphan ? " is-orphan" : ""}${
here ? ` is-here${here === "new" ? " is-pop" : ""}` : ""
}`,
attrs: {
type: "button",
title: orphan ? TEXT.orphan : hit ? TEXT.linked : TEXT.free,
"data-key": item.키,
},
children: [
el("span", { text: orphan ? `⚠ ${item.이름}` : item.이름 }),
...(item.단위 ? [el("small", { text: item.단위 })] : []),
...(where ? [el("code", { text: where })] : []),
],
});
if (hue !== undefined) b.style.setProperty("--ss-link-h", String(hue));
if (orphan && !ctx.readOnly) {
const x = el("span", {
className: "ss-link__x",
text: "×",
attrs: { role: "button", title: TEXT.orphanX },
});
x.addEventListener("click", (e) => (e.stopPropagation(), unlink(), back()));
b.append(x);
}
b.draggable = !ctx.readOnly;
b.addEventListener("dragstart", (e) => {
dragging = item;
e.dataTransfer?.setData("text/plain", item.키);
if (e.dataTransfer) e.dataTransfer.effectAllowed = "link";
});
b.addEventListener("dragend", () => {
dragging = null;
dropMark.remove();
});
// 초점은 격자에 둠 — 끌 수 있는 알약은 막으면 끌기가 안 돼 그대로(누른 뒤 `back` 이 격자로 돌림)
if (!b.draggable) b.addEventListener("mousedown", (e) => e.preventDefault());
b.addEventListener("click", () => (hit ? goTo(hit.sheet, hit.link.칸) : void link(item)));
function unlink(): void {
if (hit)
put(
hit.sheet,
(hit.sheet.연결 ?? []).filter((l) => l.키 !== item.키),
);
}
b.addEventListener("contextmenu", (e) => {
e.preventDefault();
const inList = opts?.모드 === "자재" && !item.고정 && items.some((i) => i.키 === item.키);
if (ctx.readOnly || !(hit || inList)) return;
const o = ctx.root.getBoundingClientRect(); // 메뉴는 부품 안 절대 위치 — 누른 자리에 뜨게
menu.open(e.clientX - o.left, e.clientY - o.top, [
...(hit ? [[TEXT.unlink, () => (unlink(), back())] as [string, () => void]] : []),
...(inList
? [
[
TEXT.remove,
() => {
unlink();
items.splice(0, items.length, ...items.filter((i) => i.키 !== item.키));
commit();
renderBar();
if (hit) showToast(TEXT.removed, "info");
back();
},
] as [string, () => void],
]
: []),
]);
});
return b;
}
/** 옛 키(로직 키 등)로 저장된 연결 — 키가 알약 목록에 없으면 이름 + 단위가 같은 알약으로 이어
* 연결 키를 새 키로 바꿔 둠(다음 저장 때 새 키로 나감 · 안 맞으면 그대로 두어 늘 보임) */
function align(): void {
if (opts?.모드 !== "입력") return;
const keys = new Set(items.map((i) => i.키));
const taken = new Set<string>();
for (const sheet of ctx.book.시트)
for (const l of sheet.연결 ?? []) if (keys.has(l.키)) taken.add(l.키);
for (const sheet of ctx.book.시트)
for (const l of sheet.연결 ?? []) {
if (keys.has(l.키)) continue;
const hit = items.find(
(i) => !taken.has(i.키) && i.이름 === l.이름 && (i.단위 ?? "") === (l.단위 ?? ""),
);
if (!hit) continue;
l.키 = hit.키;
taken.add(hit.키);
}
}
function renderBar(): void {
if (!opts) return;
align();
const map = found();
const shown: LinkItem[] = [];
const seen = new Set<string>();
const add = (item: LinkItem) => {
if (seen.has(item.키)) return;
seen.add(item.키);
shown.push(item);
};
for (const item of items) add(item);
// 문서에 있는 연결(목록에서 빠진 것) — 풀 수 있게 늘 보임
for (const { link } of map.values())
add(
link.자재
? ofMaterial(link.자재)
: { 키: link.키, 이름: link.이름 ?? link.키, 단위: link.단위 },
);
const hues = linkHues(map.keys());
const listed = new Set(items.map((i) => i.키));
const sure = opts.모드 === "입력" && opts.확정 === true;
// 칸을 고르면 그 칸에 이은 알약 강조 — 칸이 바뀐 첫 그림만 한 번 반짝("new")
const at = `${ctx.selection.시트}!${toA1(ctx.selection.활성.r, ctx.selection.활성.c)}`;
const fresh = at !== lastAt;
lastAt = at;
const pills = shown.map((item) => {
const hit = map.get(item.키);
const here = hit && `${hit.sheet.id}!${hit.link.칸}` === at ? (fresh ? "new" : "same") : "";
return pill(item, hit, hues.get(item.키), sure && !!hit && !listed.has(item.키), here);
});
if (opts.모드 === "자재" && opts.자재찾기 && !ctx.readOnly) {
const more = el("button", {
className: "ss-link ss-link--add",
text: TEXT.add,
attrs: { type: "button" },
});
more.addEventListener("click", () => openPick(more));
pills.push(more);
}
bar.replaceChildren(...pills);
if (fresh)
bar.querySelector(".is-here")?.scrollIntoView?.({ block: "nearest", inline: "nearest" });
}
/** 이은 칸 — 알약 색 옅은 배경 + 왼아래 작은 세모 · 칸이 든 판 층에 얹어 스크롤을 따라감 */
function placeDots(): void {
for (const d of dots) d.remove();
dots = [];
const hues = linkHues(found().keys());
const listed = new Set(items.map((i) => i.키));
const sure = opts?.모드 === "입력" && opts.확정 === true;
// 칸마다 이은 알약(이은 차례) — 색 = 먼저 이은 알약 · 둘 넘으면 수
const byCell = new Map<string, CellLink[]>();
for (const l of ctx.sheet().연결 ?? []) byCell.set(l.칸, [...(byCell.get(l.칸) ?? []), l]);
for (const [a1, list] of byCell) {
const p = parseA1(a1);
if (!p) continue;
const { layer, box } = slot(ctx, p.r, p.c);
if (!box.w || !box.h) continue; // 숨긴 행열
const first = list[0];
const tint = el("div", { className: "ss-link-cell", attrs: { "data-key": first.키 } });
tint.style.cssText = `left:${box.x}px;top:${box.y}px;width:${box.w}px;height:${box.h}px`;
const dot = el("div", { className: "ss-link-dot" });
dot.style.left = `${box.x}px`;
dot.style.top = `${box.y + box.h - 7}px`;
const marks = [tint, dot];
if (sure && !listed.has(first.키)) tint.classList.add("is-orphan");
if (list.length > 1) {
const count = el("div", { className: "ss-link-count", text: String(list.length) });
count.style.left = `${box.x + box.w}px`;
count.style.top = `${box.y}px`;
marks.push(count);
}
for (const d of marks) d.style.setProperty("--ss-link-h", String(hues.get(first.키) ?? 0));
layer.append(...marks);
dots.push(...marks);
}
// 보낸 범위(도면 표 등) — 채움 없는 점선 테두리 + 왼위 이름표(알약 색 채움과 안 겹침)
for (const mark of opts?.표시범위 ?? []) {
const g = mark.시트 === ctx.sheet().id ? parseRange(mark.범위) : null;
if (!g) continue;
const { layer, box } = slot(ctx, g.r0, g.c0);
const end = slot(ctx, g.r1, g.c1).box;
if (!box.w || !box.h) continue;
const frame = el("div", {
className: "ss-link-range",
attrs: { "data-range": mark.범위 },
children: [el("span", { className: "ss-link-range__tag", text: mark.이름 })],
});
frame.style.cssText = `left:${box.x}px;top:${box.y}px;width:${Math.max(box.w, end.x + end.w - box.x)}px;height:${Math.max(box.h, end.y + end.h - box.y)}px`;
layer.append(frame);
dots.push(frame);
}
}
/** 칸에 마우스를 올리면 이은 알약 이름들(격자 title) — 이은 칸이 아니면 지움 */
let tipped = false;
const onHover = (e: MouseEvent): void => {
const hit = ctx.grid.hitTest(e.clientX, e.clientY);
const a1 = hit && hit.r >= 0 && hit.c >= 0 ? toA1(hit.r, hit.c) : null;
const names = a1
? (ctx.sheet().연결 ?? []).filter((l) => l.칸 === a1).map((l) => l.이름 ?? l.키)
: [];
if (hit && hit.r >= 0 && hit.c >= 0)
for (const m of opts?.표시범위 ?? []) {
const g = m.시트 === ctx.sheet().id ? parseRange(m.범위) : null;
if (g && inRange(g, hit.r, hit.c)) names.push(m.설명 ?? m.이름);
}
if (names.length) ctx.grid.root.title = names.join(" · ");
else if (tipped) ctx.grid.root.removeAttribute("title");
tipped = names.length > 0;
};
// ── 알약 끌어 칸에 놓기(40-3) ──────────────────────────────────────────
/** 끄는 알약 아래 칸(A1) — 머리 · 격자 밖이면 null */
function cellAt(e: DragEvent): { r: number; c: number } | null {
const hit = ctx.grid.hitTest(e.clientX, e.clientY);
return hit && hit.r >= 0 && hit.c >= 0 ? { r: hit.r, c: hit.c } : null;
}
const onDragOver = (e: DragEvent): void => {
const at = dragging && !ctx.readOnly ? cellAt(e) : null;
if (!at) return void dropMark.remove();
e.preventDefault(); // 놓을 수 있음
if (e.dataTransfer) e.dataTransfer.dropEffect = "link";
const { layer, box } = slot(ctx, at.r, at.c);
dropMark.style.cssText = `left:${box.x}px;top:${box.y}px;width:${box.w}px;height:${box.h}px`;
if (dropMark.parentElement !== layer) layer.append(dropMark);
};
const onDragLeave = (e: DragEvent): void => {
if (!ctx.grid.root.contains(e.relatedTarget as Node | null)) dropMark.remove();
};
const onDrop = async (e: DragEvent): Promise<void> => {
const item = dragging;
const at = item && !ctx.readOnly ? cellAt(e) : null;
dropMark.remove();
dragging = null;
if (!item || !at) return;
e.preventDefault();
ctx.select(selectCell(ctx.sheet(), at.r, at.c), true);
await link(item, toA1(at.r, at.c));
};
// ── 자재 고르기 창 ──────────────────────────────────────────────────────
function closePick(): void {
pick?.remove();
pick = null;
document.removeEventListener("mousedown", onOutside, true);
}
const onOutside = (e: MouseEvent): void => {
if (pick && !pick.contains(e.target as Node)) closePick();
};
function openPick(anchor: HTMLElement): void {
closePick();
const input = el("input", {
className: "ss-links__search",
attrs: { type: "search", placeholder: TEXT.search },
}) as HTMLInputElement;
const list = el("div", { className: "ss-links__list" });
const note = (text: string) => el("div", { className: "ss-links__note", text });
pick = el("div", { className: "ss-links__pick", children: [input, list] });
const a = anchor.getBoundingClientRect();
const o = ctx.root.getBoundingClientRect();
pick.style.left = `${a.left - o.left}px`;
pick.style.bottom = `${o.bottom - bar.getBoundingClientRect().top + 4}px`; // 막대 위로 엶
ctx.root.append(pick);
document.addEventListener("mousedown", onOutside, true);
let seq = 0;
let timer = 0;
const choose = (m: LinkMaterial) => {
closePick();
if (items.some((i) => i.키 === m.코드)) showToast(TEXT.dup, "info");
else {
items.push(ofMaterial(m));
commit();
renderBar();
showToast(TEXT.picked, "info");
}
back();
};
const search = async (): Promise<void> => {
const mine = ++seq;
list.replaceChildren(note(TEXT.searching));
try {
const got = await opts!.자재찾기!(input.value.trim());
if (mine !== seq) return;
list.replaceChildren(
...(got.length
? got.map((m) => {
const row = el("button", {
className: "ss-links__row",
attrs: { type: "button", title: m.코드 },
children: [
el("span", { text: m.이름 }),
el("span", { text: m.규격 }),
el("small", { text: m.단위 }),
],
});
row.addEventListener("click", () => choose(m));
return row;
})
: [note(TEXT.none)]),
);
} catch {
if (mine === seq) list.replaceChildren(note(TEXT.fail));
}
};
input.addEventListener("input", () => {
clearTimeout(timer);
timer = window.setTimeout(() => void search(), 250);
});
input.addEventListener("keydown", (e) => {
if (e.key === "Escape") (e.stopPropagation(), closePick(), back());
if (e.key === "Enter") list.querySelector<HTMLElement>(".ss-links__row")?.click();
});
input.focus();
void search();
}
const onZoom = (): void => placeDots();
ctx.grid.root.addEventListener("aislo-grid-zoom", onZoom);
const onDropEvent = (e: DragEvent): void => void onDrop(e);
ctx.grid.root.addEventListener("dragover", onDragOver);
ctx.grid.root.addEventListener("dragleave", onDragLeave);
ctx.grid.root.addEventListener("drop", onDropEvent);
ctx.grid.root.addEventListener("mousemove", onHover);
return {
root: opts ? bar : null,
apply: applyLinked,
refresh() {
renderBar();
placeDots();
},
destroy() {
ctx.grid.root.removeEventListener("aislo-grid-zoom", onZoom);
ctx.grid.root.removeEventListener("dragover", onDragOver);
ctx.grid.root.removeEventListener("dragleave", onDragLeave);
ctx.grid.root.removeEventListener("drop", onDropEvent);
ctx.grid.root.removeEventListener("mousemove", onHover);
dropMark.remove();
closePick();
for (const d of dots) d.remove();
menu.element.remove();
bar.remove();
},
};
}