From 2ebc888f03022a7f26a87972d0111ade4d5fa94d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:31:40 +0900 Subject: [PATCH] =?UTF-8?q?feat(spreadsheet):=20=EC=9A=B0=ED=81=B4?= =?UTF-8?q?=EB=A6=AD=20=EB=A9=94=EB=89=B4=20=ED=95=AD=EB=AA=A9=20=EB=AA=A8?= =?UTF-8?q?=EC=9D=8C=20=C2=B7=20=EA=B3=B5=EC=9A=A9=20=EB=A9=94=EB=89=B4=20?= =?UTF-8?q?=EA=B5=AC=EB=B6=84=EC=84=A0=20=C2=B7=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=B0=BD=20=EC=86=90=EC=9E=A1=EC=9D=B4(81-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - spreadsheet_menu_items.ts 새 파일 — 붙여넣기(보안 문맥만) · 선택하여 붙여넣기 ▸ · 행 · 열 · 셀 삽입/삭제 ▸ · 크기 · 데이터에 맞추기 · 시트 정렬 · 필터 · 고정 ▸ · 그룹 ▸ · 셀 작업 더보기 ▸ · 읽기 전용 복사 - ui_template_context_menu 항목에 구분선 "-" 덧붙임(맨 앞 · 맨 뒤 · 겹침 안 그림) - ctx.dataMenu — [데이터 ▾] 와 같은 패널 창 · 필터를 우클릭에서 부름 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s --- .../spreadsheet/spreadsheet_data_menu.ts | 10 + .../spreadsheet/spreadsheet_menu_items.ts | 441 ++++++++++++++++++ .../spreadsheet/spreadsheet_view_types.ts | 2 + ui_template/ui_template_context_menu.css | 7 + ui_template/ui_template_context_menu.ts | 16 +- 5 files changed, 474 insertions(+), 2 deletions(-) create mode 100644 A00_Common/spreadsheet/spreadsheet_menu_items.ts diff --git a/A00_Common/spreadsheet/spreadsheet_data_menu.ts b/A00_Common/spreadsheet/spreadsheet_data_menu.ts index 63390c33b..e27d0402a 100644 --- a/A00_Common/spreadsheet/spreadsheet_data_menu.ts +++ b/A00_Common/spreadsheet/spreadsheet_data_menu.ts @@ -97,6 +97,16 @@ export function mountDataMenu(ctx: SpreadsheetContext, back: () => void): DataMe back(); }; const act = mountActions(ctx, back); + ctx.dataMenu = (kind) => { + if (kind === "filter") return (toggleFilter(), back()); + if (kind === "condfmt") return void openPanel(TEXT.condfmt, async () => createCondFmtPanel); + if (kind === "validation") + return void openPanel(TEXT.validation, async () => createValidationPanel); + void openPanel( + TEXT.names, + async () => (await import("./spreadsheet_names")).createNameManagerPanel, + ); + }; const menu = createMapContextMenu("ss"); ctx.root.append(menu.element); let closeSort: (() => void) | null = null; diff --git a/A00_Common/spreadsheet/spreadsheet_menu_items.ts b/A00_Common/spreadsheet/spreadsheet_menu_items.ts new file mode 100644 index 000000000..649f8ed01 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_menu_items.ts @@ -0,0 +1,441 @@ +/* ============================================================================= + * spreadsheet_menu_items.ts (81-3) + * 우클릭 메뉴에 덧붙이는 항목 — 구글 시트 우클릭 가운데 기능이 이미 있는 것만(조사_A 3 표 (a)). + * 붙여넣기 · 선택하여 붙여넣기 ▸ · 행 · 열 · 셀 삽입/삭제 ▸ · 행 · 열 크기 · 데이터에 맞추기 · + * 시트 정렬 · 필터 · 고정 ▸ · 그룹 ▸ · 셀 작업 더보기 ▸(드롭다운 · 조건부 서식 · 범위 이름). + * 안 만듦 = 하이퍼링크 · 댓글 · 범위/시트 보호 · 다른 파일로 복사. + * `spreadsheet_menu.ts` 가 이 목록을 펼쳐 붙임. 몸통 창 · 명령 모듈은 누를 때 `import()`(크기 예산). + * 붙여넣기는 보안 문맥(localhost · https)에서만 클립보드를 읽어 실제로 붙임 — http 는 Ctrl+V 안내. + * ========================================================================== */ + +import type { MapContextMenuItem } from "@ui/ui_template_context_menu"; +import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements"; +import { dialogKeys, type Act } from "./spreadsheet_actions"; +import { parseA1 } from "./spreadsheet_address"; +import { parseClipboard } from "./spreadsheet_clipboard"; +import type { Editor } from "./spreadsheet_editor"; +import { + buildColGeometry, + buildRowGeometry, + MAX_COL_CHARS, + pxToColChars, + pxToPt, +} from "./spreadsheet_headers"; +import type { PasteSpecialMode } from "./spreadsheet_paste_special"; +import { st } from "./spreadsheet_text"; +import type { CellRange, Command, Sheet } from "./spreadsheet_types"; +import type { SpreadsheetContext } from "./spreadsheet_view_types"; + +const TEXT = { + paste: "붙여넣기", + pasteSpecial: "선택하여 붙여넣기", + pasteValues: "값만 붙여넣기", + pasteFormat: "서식만 붙여넣기", + pasteSpecialHint: "선택하여 붙여넣기는 Ctrl+Shift+V 를 쓸 것", + pasteFailed: "붙여넣을 내용이 없음", + insertLines: "행 · 열 삽입", + rowsAbove: (n: number) => `위에 ${n}행 삽입`, + rowsBelow: (n: number) => `아래에 ${n}행 삽입`, + colsLeft: (n: number) => `왼쪽에 ${n}열 삽입`, + colsRight: (n: number) => `오른쪽에 ${n}열 삽입`, + insertCells: "셀 삽입", + shiftRight: "셀 삽입 후 오른쪽으로 밀기", + shiftDown: "셀 삽입 후 아래로 밀기", + deleteLines: "행 · 열 삭제", + deleteRows: (n: number) => `${n}행 삭제`, + deleteCols: (n: number) => `${n}열 삭제`, + deleteCells: "셀 삭제", + shiftLeft: "셀 삭제 후 왼쪽으로 당기기", + shiftUp: "셀 삭제 후 위로 당기기", + rowSize: "행 크기 조절…", + colSize: "열 크기 조절…", + rowSizeLabel: "행 높이(px)", + colSizeLabel: "열 너비(px)", + autofit: "데이터에 맞추기", + sortAsc: "시트 정렬 A → Z", + sortDesc: "시트 정렬 Z → A", + filterOn: "필터 만들기", + filterOff: "필터 삭제", + freeze: "고정", + freezeRow: "첫 행 고정", + freezeCol: "첫 열 고정", + freezeAt: "현재 칸까지 고정", + unfreeze: "고정 풀기", + group: "그룹", + groupOn: "그룹으로 묶기", + groupOff: "그룹 풀기", + more: "셀 작업 더보기", + dropdown: "드롭다운…", + condfmt: "조건부 서식…", + nameRange: "범위 이름 지정…", + nameLabel: "이름", + nameEmpty: "이름을 넣을 것", + ok: "확인", + cancel: "취소", + badNumber: "1 이상 숫자를 넣을 것", +}; + +type Item = [string, () => void]; +const SEP = "-" as const; + +/** 메뉴를 닫으면 누른 단추가 지워져 초점이 문서 밖으로 감 — 격자 입력칸으로 되돌림 */ +function back(ctx: SpreadsheetContext): void { + (ctx.editor as Editor).focus?.(); +} + +/** 잘라내기 · 복사 — 초점을 격자로 되돌린 뒤 불러야 부품의 copy · cut 듣기가 받음(빈 복사 막음) */ +export function copyCut(ctx: SpreadsheetContext, kind: "cut" | "copy"): void { + back(ctx); + document.execCommand(kind); +} + +/** 읽기 전용 · 미리보기 시트 — 「복사」 한 줄 */ +export function copyOnlyItems(ctx: SpreadsheetContext): MapContextMenuItem[] { + return [[st("MenuCopy"), () => copyCut(ctx, "copy")]]; +} + +const id = (ctx: SpreadsheetContext): string => ctx.selection.시트; + +/** 메뉴로 클립보드를 읽을 수 있는가 — 보안 문맥(localhost · https)만 */ +export function canMenuPaste(): boolean { + return globalThis.isSecureContext === true && typeof navigator?.clipboard?.read === "function"; +} + +async function readClipboard(): Promise<{ html: string; text: string } | null> { + if (!canMenuPaste()) return null; + try { + let html = ""; + let text = ""; + for (const item of await navigator.clipboard.read()) { + if (!html && item.types.includes("text/html")) + html = await (await item.getType("text/html")).text(); + if (!text && item.types.includes("text/plain")) + text = await (await item.getType("text/plain")).text(); + } + return { html, text }; + } catch { + return null; // 권한 거절 — 안내로 + } +} + +/** 메뉴 붙여넣기 — 보통은 부품의 paste 듣기로(잘라내기 옮김 · 엑셀 경고 그대로) · 골라 붙이기는 바로 */ +export async function menuPaste(ctx: SpreadsheetContext, mode?: PasteSpecialMode): Promise { + const clip = await readClipboard(); + if (!clip) return showToast(mode ? TEXT.pasteSpecialHint : st("MenuPasteHint"), "info"); + back(ctx); + if (!mode) { + const data = new DataTransfer(); + data.setData("text/html", clip.html); + data.setData("text/plain", clip.text); + ctx.root.dispatchEvent( + new ClipboardEvent("paste", { clipboardData: data, bubbles: true, cancelable: true }), + ); + return; + } + const at = { ...ctx.selection.활성 }; + const block = parseClipboard(clip.html, clip.text, at); + if (!block) return showToast(TEXT.pasteFailed, "error"); + const source = + block.출처 === "aislo" && block.원점 ? { 시트: id(ctx), ...block.원점 } : undefined; + (await import("./spreadsheet_paste_special")).pasteSpecial(ctx, block, at, mode, source); +} + +export function pasteItems(ctx: SpreadsheetContext): MapContextMenuItem[] { + return [ + [TEXT.paste, () => void menuPaste(ctx)], + { + label: TEXT.pasteSpecial, + children: [ + [TEXT.pasteValues, () => void menuPaste(ctx, "값")], + [TEXT.pasteFormat, () => void menuPaste(ctx, "서식")], + ], + }, + ]; +} + +/** 행 · 열 넣기 — 위 · 왼쪽은 `at` · 아래 · 오른쪽은 `at + 수`(고른 줄 수만큼) */ +export function insertLines( + ctx: SpreadsheetContext, + rows: boolean, + at: number, + 수: number, + after: boolean, +): void { + ctx.dispatch({ 종류: rows ? "행넣기" : "열넣기", 시트: id(ctx), at: after ? at + 수 : at, 수 }); +} + +function deleteLines(ctx: SpreadsheetContext, rows: boolean, at: number, 수: number): void { + ctx.dispatch({ 종류: rows ? "행지우기" : "열지우기", 시트: id(ctx), at, 수 }); +} + +export function rowInsertItems(ctx: SpreadsheetContext, at: number, 수: number): Item[] { + return [ + [TEXT.rowsAbove(수), () => insertLines(ctx, true, at, 수, false)], + [TEXT.rowsBelow(수), () => insertLines(ctx, true, at, 수, true)], + ]; +} + +export function colInsertItems(ctx: SpreadsheetContext, at: number, 수: number): Item[] { + return [ + [TEXT.colsLeft(수), () => insertLines(ctx, false, at, 수, false)], + [TEXT.colsRight(수), () => insertLines(ctx, false, at, 수, true)], + ]; +} + +const shifts = () => import("./spreadsheet_insert_dialog"); + +/** 칸 메뉴 — 행 · 열 · 셀 삽입/삭제 ▸ */ +export function cellInsertItems(ctx: SpreadsheetContext, g: CellRange): MapContextMenuItem[] { + const rows = g.r1 - g.r0 + 1; + const cols = g.c1 - g.c0 + 1; + return [ + { + label: TEXT.insertLines, + children: [...rowInsertItems(ctx, g.r0, rows), ...colInsertItems(ctx, g.c0, cols)], + }, + { + label: TEXT.insertCells, + children: [ + [TEXT.shiftRight, () => void shifts().then((m) => m.insertShiftRight(ctx, g))], + [TEXT.shiftDown, () => void shifts().then((m) => m.insertShiftDown(ctx, g))], + ], + }, + { + label: TEXT.deleteLines, + children: [ + [TEXT.deleteRows(rows), () => deleteLines(ctx, true, g.r0, rows)], + [TEXT.deleteCols(cols), () => deleteLines(ctx, false, g.c0, cols)], + ], + }, + { + label: TEXT.deleteCells, + children: [ + [TEXT.shiftLeft, () => void shifts().then((m) => m.deleteShiftLeft(ctx, g))], + [TEXT.shiftUp, () => void shifts().then((m) => m.deleteShiftUp(ctx, g))], + ], + }, + ]; +} + +/** 몸통은 [데이터 ▾] 와 같은 늦은 길(`spreadsheet_actions_run.ts`) */ +function run(ctx: SpreadsheetContext, act: Act): void { + void import("./spreadsheet_actions_run").then((m) => m.run(ctx, () => back(ctx), act)); +} + +function freezeGroupItems(ctx: SpreadsheetContext): MapContextMenuItem[] { + return [ + { + label: TEXT.freeze, + children: [ + [TEXT.freezeRow, () => run(ctx, "freezeRow")], + [TEXT.freezeCol, () => run(ctx, "freezeCol")], + [TEXT.freezeAt, () => run(ctx, "freezeAt")], + [TEXT.unfreeze, () => run(ctx, "unfreeze")], + ], + }, + { + label: TEXT.group, + children: [ + [TEXT.groupOn, () => run(ctx, "group")], + [TEXT.groupOff, () => run(ctx, "ungroup")], + ], + }, + ]; +} + +/** 쓴 칸을 모두 덮는 범위(없으면 null) */ +export function usedRange(sheet: Sheet): CellRange | null { + let g: CellRange | null = null; + for (const key of Object.keys(sheet.칸)) { + const p = parseA1(key); + if (!p) continue; + g = g + ? { + r0: Math.min(g.r0, p.r), + c0: Math.min(g.c0, p.c), + r1: Math.max(g.r1, p.r), + c1: Math.max(g.c1, p.c), + } + : { r0: p.r, c0: p.c, r1: p.r, c1: p.c }; + } + return g; +} + +/** 열 머리에서 — 쓴 범위를 잠깐 골라 `job`(정렬 · 필터) 뒤 고름을 되돌림. 빈 열이면 안 함 */ +async function onUsed( + ctx: SpreadsheetContext, + c: number, + job: () => void | Promise, +): Promise { + const used = usedRange(ctx.sheet()); + if (!used || c < used.c0 || c > used.c1) return; + const keep = ctx.selection; + const top = { r: used.r0, c }; + ctx.select({ 시트: id(ctx), 범위: [used], 활성: top, 기준: top }); + try { + await job(); + } finally { + ctx.select(keep); + } +} + +/** 시트 정렬 — 쓴 범위 전체를 이 열 기준으로(열 하나만 섞이지 않게) */ +export function sortByColumn(ctx: SpreadsheetContext, c: number, desc: boolean): Promise { + return onUsed(ctx, c, async () => + (await import("./spreadsheet_sort_dialog")).sortSelection(ctx, desc), + ); +} + +function filterItem(ctx: SpreadsheetContext, c?: number): Item { + const on = !!ctx.sheet().필터; + const go = (): void => ctx.dataMenu?.("filter"); + return [ + on ? TEXT.filterOff : TEXT.filterOn, + () => (on || c === undefined ? go() : void onUsed(ctx, c, go)), + ]; +} + +/** 숫자 · 글 하나 받는 작은 창 — 확인이 거르면(오류 글) 창이 남음 */ +function askValue( + ctx: SpreadsheetContext, + title: string, + label: string, + value: string, + submit: (text: string) => string | null | Promise, +): void { + const overlay = el("div", { className: "ui-confirm" }); + const close = (): void => { + overlay.remove(); + back(ctx); + }; + const field = createInputField({ label, value }); + const error = el("p", { text: "" }); + error.style.cssText = "color:var(--color-danger, red);font-size:0.8rem;min-height:1em;"; + const ok = async (): Promise => { + const fail = await submit(field.input.value.trim()); + if (fail) error.textContent = fail; + else close(); + }; + field.input.addEventListener("keydown", (e) => { + if (e.key === "Enter") void ok(); + }); + overlay.append( + el("div", { + className: "ui-confirm__panel", + children: [ + el("h3", { text: title }), + field.root, + error, + el("div", { + className: "ui-confirm__actions", + children: [ + createButton({ label: TEXT.cancel, variant: "ghost", onClick: close }), + createButton({ label: TEXT.ok, variant: "filled", onClick: () => void ok() }), + ], + }), + ], + }), + ); + document.body.append(overlay); + dialogKeys(overlay, close); + field.input.focus(); + field.input.select(); +} + +/** 행 높이 · 열 너비 바꾸기(px) — 고른 줄 모두 한 명령(한 번에 되돌림) · 잘못된 값이면 오류 글 */ +export function resizeLines( + ctx: SpreadsheetContext, + rows: boolean, + lines: number[], + text: string, +): string | null { + const px = Number(text); + if (!Number.isFinite(px) || px < 1) return TEXT.badNumber; + ctx.dispatch( + rows + ? { 종류: "행높이", 시트: id(ctx), 행: lines, 높이: Math.min(pxToPt(px), 409) } + : { 종류: "열폭", 시트: id(ctx), 열: lines, 폭: Math.min(pxToColChars(px), MAX_COL_CHARS) }, + ); + return null; +} + +function sizeItem(ctx: SpreadsheetContext, rows: boolean, lines: number[]): Item { + return [ + rows ? TEXT.rowSize : TEXT.colSize, + () => { + const geo = (rows ? buildRowGeometry : buildColGeometry)(ctx.book, ctx.sheet()); + askValue( + ctx, + rows ? TEXT.rowSize : TEXT.colSize, + rows ? TEXT.rowSizeLabel : TEXT.colSizeLabel, + String(Math.round(geo.size(lines[0]))), + (text) => resizeLines(ctx, rows, lines, text), + ); + }, + ]; +} + +/** 열 너비를 데이터에 맞춤 — 고른 열 모두 한 명령 */ +export async function autofitColumns(ctx: SpreadsheetContext, cols: number[]): Promise { + const { estimateColumnWidth } = await import("./spreadsheet_autofit"); + const 명령: Command[] = cols.map((c) => ({ + 종류: "열폭", + 시트: id(ctx), + 열: [c], + 폭: estimateColumnWidth(ctx.book, id(ctx), c), + })); + ctx.dispatch({ 종류: "묶음", 명령 }); +} + +/** 행 머리 끝 — 크기 · 고정 ▸ · 그룹 ▸ */ +export function rowMoreItems(ctx: SpreadsheetContext, rows: number[]): MapContextMenuItem[] { + return [SEP, sizeItem(ctx, true, rows), SEP, ...freezeGroupItems(ctx)]; +} + +/** 열 머리 끝 — 크기 · 데이터에 맞추기 · 시트 정렬 · 필터 · 고정 ▸ · 그룹 ▸ */ +export function colMoreItems(ctx: SpreadsheetContext, cols: number[]): MapContextMenuItem[] { + const c = cols[0]; + return [ + SEP, + sizeItem(ctx, false, cols), + [TEXT.autofit, () => void autofitColumns(ctx, cols)], + SEP, + [TEXT.sortAsc, () => void sortByColumn(ctx, c, false)], + [TEXT.sortDesc, () => void sortByColumn(ctx, c, true)], + filterItem(ctx, c), + SEP, + ...freezeGroupItems(ctx), + ]; +} + +/** 칸 메뉴 끝 — 고정 ▸ · 그룹 ▸ · 셀 작업 더보기 ▸ */ +export function cellMoreItems(ctx: SpreadsheetContext, g: CellRange): MapContextMenuItem[] { + return [ + SEP, + ...freezeGroupItems(ctx), + { + label: TEXT.more, + children: [ + [TEXT.dropdown, () => ctx.dataMenu?.("validation")], + [TEXT.condfmt, () => ctx.dataMenu?.("condfmt")], + [ + TEXT.nameRange, + () => + askValue(ctx, TEXT.nameRange, TEXT.nameLabel, "", (name) => nameRange(ctx, name, g)), + ], + filterItem(ctx), + ], + }, + SEP, + ]; +} + +/** 범위 이름 — `spreadsheet_names.defineName`(오류 글 · 성공 null) */ +export async function nameRange( + ctx: SpreadsheetContext, + name: string, + g: CellRange, +): Promise { + if (!name) return TEXT.nameEmpty; + return (await import("./spreadsheet_names")).defineName(ctx, name, g); +} diff --git a/A00_Common/spreadsheet/spreadsheet_view_types.ts b/A00_Common/spreadsheet/spreadsheet_view_types.ts index 1f5ccb96a..d39a6ed0e 100644 --- a/A00_Common/spreadsheet/spreadsheet_view_types.ts +++ b/A00_Common/spreadsheet/spreadsheet_view_types.ts @@ -112,6 +112,8 @@ export interface SpreadsheetContext { /** 칸 메뉴 끝에 붙일 줄 — 칸 메뉴(`spreadsheet_menu.ts`)가 빈 것으로 세움(= 칸 메뉴 있음) · * 조건 만들기(`spreadsheet_condition.ts`)가 채움 · 없으면 칸 메뉴 없는 편집기 */ cellMenuExtra?: (r: number, c: number) => Array<[label: string, action: () => void]>; + /** [데이터 ▾] 와 같은 창 · 필터를 우클릭 메뉴에서 — 데이터 메뉴(`spreadsheet_data_menu.ts`)가 채움 */ + dataMenu?: (kind: "filter" | "condfmt" | "validation" | "names") => void; } /** E 부품 — D 가 `mountToolbar(ctx)` 등으로 붙이고 고름 · 문서가 바뀌면 `refresh()`. */ diff --git a/ui_template/ui_template_context_menu.css b/ui_template/ui_template_context_menu.css index 2c0bd6eef..7b11ab018 100644 --- a/ui_template/ui_template_context_menu.css +++ b/ui_template/ui_template_context_menu.css @@ -64,3 +64,10 @@ .ui-ctx-menu__item.is-sub { padding-left: var(--spacing-24); } + +/* 구분선(`"-"` 항목) */ +.ui-ctx-menu__sep { + height: 1px; + margin: var(--spacing-4) 0; + background: var(--color-border); +} diff --git a/ui_template/ui_template_context_menu.ts b/ui_template/ui_template_context_menu.ts index 48874fbb6..3b787d4d3 100644 --- a/ui_template/ui_template_context_menu.ts +++ b/ui_template/ui_template_context_menu.ts @@ -14,10 +14,12 @@ import "./ui_template_context_menu.css"; /** 평면 항목 = [문구, 동작]. 하위 메뉴 항목 = {label, children} — 누르면 바로 아래로 - * 펼쳐지는 아코디언(구조물군 → 종류, 2026-08-18 B05 우클릭 메뉴 일원화). */ + * 펼쳐지는 아코디언(구조물군 → 종류, 2026-08-18 B05 우클릭 메뉴 일원화). + * 구분선 = `"-"` — 맨 앞 · 맨 뒤 · 겹친 것은 안 그림. */ export type MapContextMenuItem = | [label: string, action: () => void] - | { label: string; children: Array<[label: string, action: () => void]> }; + | { label: string; children: Array<[label: string, action: () => void]> } + | "-"; export interface MapContextMenu { /** 뷰포트에 append할 요소. */ @@ -118,6 +120,14 @@ export function createMapContextMenu(blockClass: string): MapContextMenu { enterMenu(registryEntry); element.replaceChildren(); items.forEach((item) => { + if (item === "-") { + const last = element.lastElementChild; + if (last && !last.classList.contains("ui-ctx-menu__sep")) + element.append( + Object.assign(document.createElement("div"), { className: "ui-ctx-menu__sep" }), + ); + return; + } if (Array.isArray(item)) { element.append(buildLeaf(item[0], item[1])); return; @@ -145,6 +155,8 @@ export function createMapContextMenu(blockClass: string): MapContextMenu { }); element.append(head, body); }); + if (element.lastElementChild?.classList.contains("ui-ctx-menu__sep")) + element.lastElementChild.remove(); element.style.left = `${x}px`; element.style.top = `${y}px`; element.hidden = false;