From f13d8157c16ad83c0e0cd0547cee018a878932f0 Mon Sep 17 00:00:00 2001 From: iamgeo92 Date: Mon, 10 Aug 2026 01:43:01 +0000 Subject: [PATCH] slides: a right-click menu that belongs to the deck MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The editor had no context menu at all, so a right-click on the canvas produced the browser's own — Back, Reload, View source — which is precisely the wrong set of verbs for a slide, and the one gesture every user of every other editor tries first. Three menus, chosen by what was actually clicked. An element offers Edit text, Cut, Copy, Duplicate, Bring to front, Send to back, Group / Ungroup and Delete. A slide thumbnail offers New slide, Duplicate slide and Delete slide. The canvas beside the slide offers Paste and the slide's own verbs. Aiming at something outside the selection selects it first, the way every editor does, so the verbs always describe what they are about to act on. The browser's menu is deliberately left where it is the better one: form fields, links, and text mid-edit, where the system carries spelling, dictation, look-up and a real paste. That last case needs the question asked on the PRESS — a right-click commits the edit before contextmenu is dispatched — and answered by geometry, because Moveable's control box sits on top of the caret and swallows the target. ctxmenu.ts renders and dismisses; it knows nothing about slides. Paste shares the text path with the paste EVENT rather than growing a second copy of it, which is why wirePaste is split. --- slides/src/editor/canvas.ts | 18 +++ slides/src/editor/ctxmenu.ts | 158 ++++++++++++++++++++++++ slides/src/editor/editor.ts | 225 +++++++++++++++++++++++++++++------ slides/src/editor/panels.ts | 3 +- slides/src/i18n/de.ts | 8 ++ slides/src/i18n/es.ts | 8 ++ slides/src/i18n/fr.ts | 8 ++ slides/src/i18n/it.ts | 8 ++ slides/src/i18n/ja.ts | 8 ++ slides/src/i18n/packed.ts | 8 ++ slides/src/i18n/pt.ts | 8 ++ slides/src/i18n/zh-Hans.ts | 8 ++ slides/src/i18n/zh-Hant.ts | 8 ++ slides/src/styles.css | 49 ++++++++ 14 files changed, 486 insertions(+), 39 deletions(-) create mode 100644 slides/src/editor/ctxmenu.ts diff --git a/slides/src/editor/canvas.ts b/slides/src/editor/canvas.ts index 38835226..bfa7ae43 100644 --- a/slides/src/editor/canvas.ts +++ b/slides/src/editor/canvas.ts @@ -1034,6 +1034,17 @@ export class SlideCanvas { // --- text editing ----------------------------------------------------------- + /** Open the inline editor for an element by id — the entry point for callers + * that have an id rather than a node (the context menu). A table opens its + * first cell, since a menu has no point to aim at. */ + editElement(id: string) { + const node = this.surface?.querySelector(`[data-el-id="${CSS.escape(id)}"]`) + if (!node) return + if (node.classList.contains('bento-el-text')) { this.startTextEdit(node); return } + const td = node.querySelector('td[data-c]') + if (td) this.editCellFromTd(td) + } + startTextEdit(node: HTMLElement) { if (this.store.readOnly) return // live viewer — no inline editing if (this.editing === node) return @@ -1251,6 +1262,13 @@ export class SlideCanvas { return !!this.editing } + /** The element node whose text is open for editing, if any. Callers that run + * on a PRESS need this: the press itself blurs the caret and commits, so by + * the event after it the answer has already changed. */ + get editingNode(): HTMLElement | null { + return this.editing + } + get isDrawing() { return !!this.drawOverlay } diff --git a/slides/src/editor/ctxmenu.ts b/slides/src/editor/ctxmenu.ts new file mode 100644 index 00000000..d01815a4 --- /dev/null +++ b/slides/src/editor/ctxmenu.ts @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Bento authors +// The context menu: one floating list, opened at a point, dismissed by +// anything. Deliberately dumb — it renders items and reports clicks. What the +// items ARE is decided by whoever opens it (editor.ts), because that is where +// the selection, the store and the panels live. +// +// Only ONE can be open at a time, and it is a module-level singleton rather +// than a per-caller instance: a second right-click anywhere must replace the +// first menu, including one opened by a different part of the editor. + +/** A separator, or a row. `hint` is the shortcut shown greyed on the right. */ +export type CtxItem = + | 'sep' + | { + label: string + run: () => void + hint?: string + disabled?: boolean + /** destructive — rendered in red, and always placed last by convention */ + danger?: boolean + } + +let open: HTMLElement | null = null +let detach: (() => void) | null = null + +/** Close the open menu, if any. Safe to call when nothing is open. */ +export function closeCtxMenu() { + detach?.() + detach = null + open?.remove() + open = null +} + +export function ctxMenuIsOpen() { + return !!open +} + +/** + * Open a menu at a viewport point. Items with no enabled entries are dropped + * along with any separator that would be left dangling, so callers can build a + * list unconditionally and let the menu decide what is worth showing. + */ +export function openCtxMenu(x: number, y: number, items: CtxItem[]) { + closeCtxMenu() + const rows = tidy(items) + if (!rows.length) return + + const menu = document.createElement('div') + menu.className = 'ed-ctxmenu' + menu.setAttribute('role', 'menu') + for (const item of rows) { + if (item === 'sep') { + const s = document.createElement('div') + s.className = 'ed-ctxmenu-sep' + menu.appendChild(s) + continue + } + const b = document.createElement('button') + b.className = 'ed-ctxmenu-item' + b.setAttribute('role', 'menuitem') + if (item.danger) b.classList.add('ed-ctxmenu-danger') + b.disabled = !!item.disabled + const label = document.createElement('span') + label.textContent = item.label + b.appendChild(label) + if (item.hint) { + const h = document.createElement('kbd') + h.textContent = item.hint + b.appendChild(h) + } + // click, not pointerdown: the dismiss listener below is on pointerdown, and + // acting on the press would run the item and then immediately re-close over + // a menu that had already gone. + b.addEventListener('click', () => { + closeCtxMenu() + item.run() + }) + menu.appendChild(b) + } + + // Measured off-screen first: the flip below needs a real size, and a menu + // built from a variable number of rows has no size until it is in the DOM. + menu.style.visibility = 'hidden' + document.body.appendChild(menu) + place(menu, x, y) + menu.style.visibility = '' + open = menu + + // Dismissal. Capture phase so a press anywhere closes it before that press + // does anything else, which is what makes the menu feel modal without being + // modal. `scroll` is captured too — a menu pinned to the viewport while the + // canvas moves under it would point at the wrong element. + const onDown = (ev: Event) => { + if (menu.contains(ev.target as Node)) return + closeCtxMenu() + } + const onKey = (ev: KeyboardEvent) => { + if (ev.key === 'Escape') { + ev.stopPropagation() // Escape closes THIS, not the selection behind it + closeCtxMenu() + } + } + const onScroll = () => closeCtxMenu() + document.addEventListener('pointerdown', onDown, true) + document.addEventListener('wheel', onScroll, true) + document.addEventListener('scroll', onScroll, true) + window.addEventListener('resize', onScroll) + document.addEventListener('keydown', onKey, true) + detach = () => { + document.removeEventListener('pointerdown', onDown, true) + document.removeEventListener('wheel', onScroll, true) + document.removeEventListener('scroll', onScroll, true) + window.removeEventListener('resize', onScroll) + document.removeEventListener('keydown', onKey, true) + } +} + +/** Drop leading, trailing and doubled separators, and empty menus. */ +function tidy(items: CtxItem[]): CtxItem[] { + const out: CtxItem[] = [] + for (const it of items) { + if (it === 'sep') { + if (!out.length || out[out.length - 1] === 'sep') continue + out.push(it) + continue + } + out.push(it) + } + while (out.length && out[out.length - 1] === 'sep') out.pop() + return out.some((i) => i !== 'sep') ? out : [] +} + +/** + * Put the menu at (x, y), flipped back inside the viewport when it would hang + * off an edge. Flipping rather than clamping keeps the pointer OUTSIDE the + * menu: a clamped menu slides under the finger or cursor, and the first thing + * that happens is a mis-click on whatever row landed there. + */ +function place(menu: HTMLElement, x: number, y: number) { + const m = menu.getBoundingClientRect() + const pad = 8 + const vw = window.innerWidth + const vh = window.innerHeight + let left = x + let top = y + if (left + m.width > vw - pad) left = Math.max(pad, x - m.width) + if (top + m.height > vh - pad) top = Math.max(pad, y - m.height) + // Still taller than the screen (a long menu on a short landscape phone): + // pin it to the top and let it scroll rather than run off the bottom. + if (m.height > vh - pad * 2) { + top = pad + menu.style.maxHeight = `${vh - pad * 2}px` + menu.style.overflowY = 'auto' + } + menu.style.left = `${Math.round(Math.max(pad, left))}px` + menu.style.top = `${Math.round(Math.max(pad, top))}px` +} diff --git a/slides/src/editor/editor.ts b/slides/src/editor/editor.ts index dfef2b58..c860525a 100644 --- a/slides/src/editor/editor.ts +++ b/slides/src/editor/editor.ts @@ -16,6 +16,7 @@ import { CHART_PRESETS } from '../charts' import { renderSlide, renderThumbnail } from '../render' import { SlideCanvas } from './canvas' import { PropsPanel } from './panels' +import { openCtxMenu, type CtxItem } from './ctxmenu' import { startPresentation } from '../present' import { adoptFileHandle, canWriteInPlace, currentFileName, fileBase, hasFileHandle, isEncryptionActive, openedFileName, saveFile, serializeAuto, serializeFile, setEncryptionPassword, writeUpdatedFile, writeUpdatedFileAs } from '../save' import { addVersion, clearRecovery, clearVersions, docContentKey, getRecovery, listVersions, pruneOld, putRecovery, type Snapshot } from '../autosave' @@ -94,6 +95,7 @@ export class Editor { }) this.wireAutosave() this.wirePaste() + this.wireContextMenu() store.on('doc', () => this.syncLinkedCharts()) store.on('doc', () => this.syncConnectors()) document.addEventListener('bento:apply-layout', ((ev: CustomEvent) => { @@ -1919,42 +1921,52 @@ export class Editor { const file = imgItem.getAsFile() if (file) { ev.preventDefault(); this.pasteImageFile(file); return } } - const text = dt.getData('text/plain') - // 2) Bento elements / slides copied from this or another deck - const clip = parseClip(text) - if (clip?.kind === 'elements') { - ev.preventDefault() - let added: SlideElement[] = [] - this.store.commit(() => { added = insertElements(clip, this.store.doc, this.store.slide) }) - if (clip.fonts?.length) injectFonts(this.store.doc) - this.store.select(added.map((e) => e.id)) - this.toast(added.length === 1 ? t('Pasted 1 item') : t('Pasted {n} items', { n: added.length })) - return - } - if (clip?.kind === 'slides') { - ev.preventDefault() - const at = this.store.currentIndex + 1 - let made: Slide[] = [] - this.store.commit(() => { made = insertSlides(clip, this.store.doc, at) }, 'slides') - if (clip.fonts?.length) injectFonts(this.store.doc) - this.rebuildSidebar() - this.store.goTo(at) - this.toast(made.length === 1 ? t('Pasted 1 slide') : t('Pasted {n} slides', { n: made.length })) - return - } - // 3) plain text → a text element - if (text && text.trim()) { - ev.preventDefault() - const esc = text.trim().slice(0, 4000).replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
') - const { width } = this.store.doc.size - const el = defaultText({ html: esc, color: readableInk(this.store.slide.background), x: Math.round(width / 2 - 300), y: 260, w: 600 }) - this.store.commit(() => this.store.slide.elements.push(el)) - this.store.select([el.id]) - this.toast(t('Text pasted')) - } + if (this.pasteFromText(dt.getData('text/plain'))) ev.preventDefault() }) } + /** + * Paste from a plain-text payload: Bento elements, Bento slides, or ordinary + * text that becomes a text box. Returns whether anything was pasted. + * + * Split out of the paste EVENT so the context menu's Paste is the same code + * rather than a second, drifting copy — the menu has to fetch the clipboard + * itself (`readText`), because a click carries no clipboardData. + */ + private pasteFromText(text: string): boolean { + // 2) Bento elements / slides copied from this or another deck + const clip = parseClip(text) + if (clip?.kind === 'elements') { + let added: SlideElement[] = [] + this.store.commit(() => { added = insertElements(clip, this.store.doc, this.store.slide) }) + if (clip.fonts?.length) injectFonts(this.store.doc) + this.store.select(added.map((e) => e.id)) + this.toast(added.length === 1 ? t('Pasted 1 item') : t('Pasted {n} items', { n: added.length })) + return true + } + if (clip?.kind === 'slides') { + const at = this.store.currentIndex + 1 + let made: Slide[] = [] + this.store.commit(() => { made = insertSlides(clip, this.store.doc, at) }, 'slides') + if (clip.fonts?.length) injectFonts(this.store.doc) + this.rebuildSidebar() + this.store.goTo(at) + this.toast(made.length === 1 ? t('Pasted 1 slide') : t('Pasted {n} slides', { n: made.length })) + return true + } + // 3) plain text → a text element + if (text && text.trim()) { + const esc = text.trim().slice(0, 4000).replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
') + const { width } = this.store.doc.size + const el = defaultText({ html: esc, color: readableInk(this.store.slide.background), x: Math.round(width / 2 - 300), y: 260, w: 600 }) + this.store.commit(() => this.store.slide.elements.push(el)) + this.store.select([el.id]) + this.toast(t('Text pasted')) + return true + } + return false + } + private pasteImageFile(file: File) { const reader = new FileReader() reader.onload = () => { @@ -2576,11 +2588,7 @@ export class Editor { if (ev.key === 'Delete' || ev.key === 'Backspace') { if (this.store.selection.length) { ev.preventDefault() - const ids = new Set(this.store.selection) - this.store.commit(() => { - this.store.slide.elements = this.store.slide.elements.filter((e) => !ids.has(e.id)) - }) - this.store.select([]) + this.deleteSelection() } return } @@ -2639,6 +2647,147 @@ export class Editor { this.store.select(clones.map((c) => c.id)) } + /** Remove the selected elements. Shared by ⌫ and the context menu. */ + private deleteSelection() { + if (!this.store.selection.length) return + const ids = new Set(this.store.selection) + this.store.commit(() => { + this.store.slide.elements = this.store.slide.elements.filter((e) => !ids.has(e.id)) + }) + this.store.select([]) + } + + /** Put the selection (or, with nothing selected, the slide) on the system + * clipboard as a Bento payload. Shared by ⌘C and the context menu. */ + private copySelection() { + const text = this.store.selection.length + ? serializeElements(this.store.selectedElements, this.store.doc) + : serializeSlides([this.store.slide], this.store.doc) + void navigator.clipboard?.writeText?.(text).catch(() => {}) + } + + // --- context menu ------------------------------------------------------- + + /** The element id under a viewport point on the canvas, or null. + * Walks the stack rather than taking the topmost node, because once + * something is selected Moveable's control box covers it. */ + private elementIdAtPoint(x: number, y: number): string | null { + for (const n of document.elementsFromPoint(x, y)) { + const el = n.closest('.bento-el') + if (el?.dataset.elId && el.closest('.ed-stage-scale')) return el.dataset.elId + } + return null + } + + private wireContextMenu() { + // A right-click COMMITS a live text edit — the press blurs the caret — + // and it does so BEFORE `contextmenu` is dispatched, so asking the canvas + // then always hears "not editing". The press is the last honest moment. + // Geometry, not DOM containment: Moveable's control box sits ON TOP of the + // text being edited, so a press aimed squarely at the caret is delivered to + // a resize handle and `node.contains(target)` answers false. + let pressInsideEdit = false + document.addEventListener('pointerdown', (ev) => { + if (ev.button !== 2) return + const node = this.canvas.editingNode + if (!node) { pressInsideEdit = false; return } + const r = node.getBoundingClientRect() + pressInsideEdit = + ev.clientX >= r.left && ev.clientX <= r.right && ev.clientY >= r.top && ev.clientY <= r.bottom + }, true) + + document.addEventListener('contextmenu', (ev) => { + // Leave the browser's own menu wherever it is the better one: form + // fields, links, and above all a text element mid-edit, where the native + // menu carries spelling, dictation, look-up and the system paste. + const target = ev.target as HTMLElement | null + if (!target?.closest) return + if (target.closest('input, textarea, a, [contenteditable="true"]')) return + // Text mid-edit belongs to the SYSTEM menu — spelling, dictation, + // look-up and a real paste are all things this menu cannot offer. Only + // when the press landed IN the text being edited, though: a right-click + // elsewhere commits the edit and is a click on whatever it hit. + if (pressInsideEdit) return + if (this.store.readOnly || this.presenting) return + + const thumb = target.closest('.ed-sidebar .ed-thumb') + if (thumb) { + ev.preventDefault() + openCtxMenu(ev.clientX, ev.clientY, this.slideMenuItems(Number(thumb.dataset.index), thumb)) + return + } + if (!target.closest('.ed-scroll')) return // not the canvas — leave it alone + ev.preventDefault() + const id = this.elementIdAtPoint(ev.clientX, ev.clientY) + if (!id) { + openCtxMenu(ev.clientX, ev.clientY, this.canvasMenuItems()) + return + } + // Right-clicking outside the selection moves it there first — the rule + // every editor follows, and the only way the menu's verbs can be honest + // about what they will act on. + if (!this.store.selection.includes(id)) this.store.select([id]) + openCtxMenu(ev.clientX, ev.clientY, this.elementMenuItems()) + }) + } + + private elementMenuItems(): CtxItem[] { + const els = this.store.selectedElements + const one = els.length === 1 ? els[0] : null + const openable = !!one && (one.type === 'text' || one.type === 'table') + const grouped = els.some((e) => e.groupId) + return [ + { label: t('Edit text'), disabled: !openable, run: () => one && this.canvas.editElement(one.id) }, + 'sep', + { label: t('Cut'), hint: '⌘X', run: () => { this.copySelection(); this.deleteSelection() } }, + { label: t('Copy'), hint: '⌘C', run: () => this.copySelection() }, + { label: t('Duplicate'), hint: '⌘D', run: () => this.duplicateSelection() }, + 'sep', + { label: t('Bring to front'), run: () => this.panel.reorder(els, 'front') }, + { label: t('Send to back'), run: () => this.panel.reorder(els, 'back') }, + 'sep', + grouped + ? { label: t('Ungroup'), hint: '⇧⌘G', run: () => this.panel.ungroup(els) } + : { label: t('Group'), hint: '⌘G', disabled: els.length < 2, run: () => this.panel.group(els) }, + 'sep', + { label: t('Delete'), hint: '⌫', danger: true, run: () => this.deleteSelection() }, + ] + } + + /** The slide background: the verbs here act on the SLIDE, which is the thing + * that was actually right-clicked. */ + private canvasMenuItems(): CtxItem[] { + const i = this.store.currentIndex + return [ + { label: t('Paste'), hint: '⌘V', run: () => void this.pasteFromClipboard() }, + 'sep', + { label: t('Duplicate slide'), run: () => this.duplicateSlide(i) }, + { label: t('Delete slide'), danger: true, run: () => this.deleteSlide(i) }, + ] + } + + private slideMenuItems(i: number, thumb: HTMLElement): CtxItem[] { + return [ + { label: t('New slide'), run: () => this.openLayoutPicker(thumb, { kind: 'insert', at: i + 1 }) }, + { label: t('Duplicate slide'), run: () => this.duplicateSlide(i) }, + 'sep', + { label: t('Delete slide'), danger: true, run: () => this.deleteSlide(i) }, + ] + } + + /** Menu Paste. A click carries no clipboardData, so the text has to be + * fetched — and asking can be refused (Safari prompts, Firefox has no + * readText at all), which is a real answer and not an error to swallow. */ + private async pasteFromClipboard() { + let text = '' + try { + text = (await navigator.clipboard?.readText?.()) ?? '' + } catch { + text = '' + } + if (!text || !this.pasteFromText(text)) this.toast(t('Nothing to paste — use ⌘V')) + } + // --- toast ------------------------------------------------------------------ // --- about & updates ------------------------------------------------------ diff --git a/slides/src/editor/panels.ts b/slides/src/editor/panels.ts index 574b374d..0263cabb 100644 --- a/slides/src/editor/panels.ts +++ b/slides/src/editor/panels.ts @@ -1893,7 +1893,8 @@ export class PropsPanel { this.store.select([]) } - private reorder(els: SlideElement[], where: 'front' | 'back') { + /** Also driven by the canvas context menu (editor.ts). */ + reorder(els: SlideElement[], where: 'front' | 'back') { const ids = new Set(els.map((e) => e.id)) this.store.commit(() => { const slide = this.store.slide diff --git a/slides/src/i18n/de.ts b/slides/src/i18n/de.ts index 847e4fb8..98788e3d 100644 --- a/slides/src/i18n/de.ts +++ b/slides/src/i18n/de.ts @@ -4,6 +4,14 @@ import type { Catalog } from '../i18n' export const de: Catalog = { + "Cut": "Ausschneiden", + "Copy": "Kopieren", + "Paste": "Einfügen", + "Bring to front": "In den Vordergrund", + "Send to back": "In den Hintergrund", + "Ungroup": "Gruppierung aufheben", + "Edit text": "Text bearbeiten", + "Nothing to paste — use ⌘V": "Nichts zum Einfügen — ⌘V verwenden", "Insert — text, shapes, images, media, tables, charts": "Einfügen — Text, Formen, Bilder, Medien, Tabellen, Diagramme", "More actions": "Weitere Aktionen", "Slides — show or hide the slide list": "Folien — Folienliste ein- oder ausblenden", diff --git a/slides/src/i18n/es.ts b/slides/src/i18n/es.ts index 0482fea0..66575f73 100644 --- a/slides/src/i18n/es.ts +++ b/slides/src/i18n/es.ts @@ -4,6 +4,14 @@ import type { Catalog } from '../i18n' export const es: Catalog = { + "Cut": "Cortar", + "Copy": "Copiar", + "Paste": "Pegar", + "Bring to front": "Traer al frente", + "Send to back": "Enviar al fondo", + "Ungroup": "Desagrupar", + "Edit text": "Editar texto", + "Nothing to paste — use ⌘V": "No hay nada que pegar: usa ⌘V", "Insert — text, shapes, images, media, tables, charts": "Insertar — texto, formas, imágenes, multimedia, tablas, gráficos", "More actions": "Más acciones", "Slides — show or hide the slide list": "Diapositivas — mostrar u ocultar la lista", diff --git a/slides/src/i18n/fr.ts b/slides/src/i18n/fr.ts index 237e1c84..6cb959e3 100644 --- a/slides/src/i18n/fr.ts +++ b/slides/src/i18n/fr.ts @@ -4,6 +4,14 @@ import type { Catalog } from '../i18n' export const fr: Catalog = { + "Cut": "Couper", + "Copy": "Copier", + "Paste": "Coller", + "Bring to front": "Mettre au premier plan", + "Send to back": "Mettre à l’arrière-plan", + "Ungroup": "Dissocier", + "Edit text": "Modifier le texte", + "Nothing to paste — use ⌘V": "Rien à coller — utilisez ⌘V", "Insert — text, shapes, images, media, tables, charts": "Insérer — texte, formes, images, médias, tableaux, graphiques", "More actions": "Plus d’actions", "Slides — show or hide the slide list": "Diapos — afficher ou masquer la liste", diff --git a/slides/src/i18n/it.ts b/slides/src/i18n/it.ts index a1d57e5c..7523e88a 100644 --- a/slides/src/i18n/it.ts +++ b/slides/src/i18n/it.ts @@ -4,6 +4,14 @@ import type { Catalog } from '../i18n' export const it: Catalog = { + "Cut": "Taglia", + "Copy": "Copia", + "Paste": "Incolla", + "Bring to front": "Porta in primo piano", + "Send to back": "Porta in secondo piano", + "Ungroup": "Separa", + "Edit text": "Modifica testo", + "Nothing to paste — use ⌘V": "Niente da incollare — usa ⌘V", "Insert — text, shapes, images, media, tables, charts": "Inserisci — testo, forme, immagini, media, tabelle, grafici", "More actions": "Altre azioni", "Slides — show or hide the slide list": "Diapositive — mostra o nascondi l’elenco", diff --git a/slides/src/i18n/ja.ts b/slides/src/i18n/ja.ts index 702c09ae..c98bcfb7 100644 --- a/slides/src/i18n/ja.ts +++ b/slides/src/i18n/ja.ts @@ -4,6 +4,14 @@ import type { Catalog } from '../i18n' export const ja: Catalog = { + "Cut": "切り取り", + "Copy": "コピー", + "Paste": "貼り付け", + "Bring to front": "最前面へ移動", + "Send to back": "最背面へ移動", + "Ungroup": "グループ解除", + "Edit text": "テキストを編集", + "Nothing to paste — use ⌘V": "貼り付けるものがありません — ⌘V を使用してください", "Insert — text, shapes, images, media, tables, charts": "挿入 — テキスト、図形、画像、メディア、表、グラフ", "More actions": "その他の操作", "Slides — show or hide the slide list": "スライド — スライド一覧の表示/非表示", diff --git a/slides/src/i18n/packed.ts b/slides/src/i18n/packed.ts index f4f2f310..dc6738b2 100644 --- a/slides/src/i18n/packed.ts +++ b/slides/src/i18n/packed.ts @@ -15,6 +15,14 @@ export const PACKED_LOCALES = ["ja","zh-Hans","zh-Hant","es","fr","de","it","pt" /** English source string -> translations, positional by PACKED_LOCALES. */ export const PACKED: Record> = { + "Cut": ["切り取り","剪切","剪下","Cortar","Couper","Ausschneiden","Taglia","Recortar"], + "Copy": ["コピー","复制","複製","Copiar","Copier","Kopieren","Copia","Copiar"], + "Paste": ["貼り付け","粘贴","貼上","Pegar","Coller","Einfügen","Incolla","Colar"], + "Bring to front": ["最前面へ移動","置于顶层","移至最上層","Traer al frente","Mettre au premier plan","In den Vordergrund","Porta in primo piano","Trazer para a frente"], + "Send to back": ["最背面へ移動","置于底层","移至最下層","Enviar al fondo","Mettre à l’arrière-plan","In den Hintergrund","Porta in secondo piano","Enviar para trás"], + "Ungroup": ["グループ解除","取消组合","取消群組","Desagrupar","Dissocier","Gruppierung aufheben","Separa","Desagrupar"], + "Edit text": ["テキストを編集","编辑文本","編輯文字","Editar texto","Modifier le texte","Text bearbeiten","Modifica testo","Editar texto"], + "Nothing to paste — use ⌘V": ["貼り付けるものがありません — ⌘V を使用してください","没有可粘贴的内容 — 请使用 ⌘V","沒有可貼上的內容 — 請使用 ⌘V","No hay nada que pegar: usa ⌘V","Rien à coller — utilisez ⌘V","Nichts zum Einfügen — ⌘V verwenden","Niente da incollare — usa ⌘V","Nada para colar — use ⌘V"], "Insert — text, shapes, images, media, tables, charts": ["挿入 — テキスト、図形、画像、メディア、表、グラフ","插入 — 文本、形状、图片、媒体、表格、图表","插入 — 文字、形狀、圖片、媒體、表格、圖表","Insertar — texto, formas, imágenes, multimedia, tablas, gráficos","Insérer — texte, formes, images, médias, tableaux, graphiques","Einfügen — Text, Formen, Bilder, Medien, Tabellen, Diagramme","Inserisci — testo, forme, immagini, media, tabelle, grafici","Inserir — texto, formas, imagens, mídia, tabelas, gráficos"], "More actions": ["その他の操作","更多操作","更多操作","Más acciones","Plus d’actions","Weitere Aktionen","Altre azioni","Mais ações"], "Slides — show or hide the slide list": ["スライド — スライド一覧の表示/非表示","幻灯片 — 显示或隐藏幻灯片列表","投影片 — 顯示或隱藏投影片清單","Diapositivas — mostrar u ocultar la lista","Diapos — afficher ou masquer la liste","Folien — Folienliste ein- oder ausblenden","Diapositive — mostra o nascondi l’elenco","Slides — mostrar ou ocultar a lista"], diff --git a/slides/src/i18n/pt.ts b/slides/src/i18n/pt.ts index 20d063bd..88d0c69d 100644 --- a/slides/src/i18n/pt.ts +++ b/slides/src/i18n/pt.ts @@ -7,6 +7,14 @@ import type { Catalog } from '../i18n' export const pt: Catalog = { + "Cut": "Recortar", + "Copy": "Copiar", + "Paste": "Colar", + "Bring to front": "Trazer para a frente", + "Send to back": "Enviar para trás", + "Ungroup": "Desagrupar", + "Edit text": "Editar texto", + "Nothing to paste — use ⌘V": "Nada para colar — use ⌘V", "Insert — text, shapes, images, media, tables, charts": "Inserir — texto, formas, imagens, mídia, tabelas, gráficos", "More actions": "Mais ações", "Slides — show or hide the slide list": "Slides — mostrar ou ocultar a lista", diff --git a/slides/src/i18n/zh-Hans.ts b/slides/src/i18n/zh-Hans.ts index 2f5aeaa7..74fce063 100644 --- a/slides/src/i18n/zh-Hans.ts +++ b/slides/src/i18n/zh-Hans.ts @@ -4,6 +4,14 @@ import type { Catalog } from '../i18n' export const zhHans: Catalog = { + "Cut": "剪切", + "Copy": "复制", + "Paste": "粘贴", + "Bring to front": "置于顶层", + "Send to back": "置于底层", + "Ungroup": "取消组合", + "Edit text": "编辑文本", + "Nothing to paste — use ⌘V": "没有可粘贴的内容 — 请使用 ⌘V", "Insert — text, shapes, images, media, tables, charts": "插入 — 文本、形状、图片、媒体、表格、图表", "More actions": "更多操作", "Slides — show or hide the slide list": "幻灯片 — 显示或隐藏幻灯片列表", diff --git a/slides/src/i18n/zh-Hant.ts b/slides/src/i18n/zh-Hant.ts index 96b68f32..d381fdad 100644 --- a/slides/src/i18n/zh-Hant.ts +++ b/slides/src/i18n/zh-Hant.ts @@ -4,6 +4,14 @@ import type { Catalog } from '../i18n' export const zhHant: Catalog = { + "Cut": "剪下", + "Copy": "複製", + "Paste": "貼上", + "Bring to front": "移至最上層", + "Send to back": "移至最下層", + "Ungroup": "取消群組", + "Edit text": "編輯文字", + "Nothing to paste — use ⌘V": "沒有可貼上的內容 — 請使用 ⌘V", "Insert — text, shapes, images, media, tables, charts": "插入 — 文字、形狀、圖片、媒體、表格、圖表", "More actions": "更多操作", "Slides — show or hide the slide list": "投影片 — 顯示或隱藏投影片清單", diff --git a/slides/src/styles.css b/slides/src/styles.css index db108146..6ba0ba23 100644 --- a/slides/src/styles.css +++ b/slides/src/styles.css @@ -2268,3 +2268,52 @@ input.ed-toggle { width: 16px; height: 16px; accent-color: #5E7699; cursor: poin [dir="rtl"] .ed-chart-json { direction: ltr; } + +/* --- context menu --------------------------------------------------------- + Positioned from JS against the VIEWPORT (ctxmenu.ts), so it is never clipped + by the canvas scroller, the topbar's own scroller, or a side panel. Shares + the dropdown look so a right-click and a bar menu read as the same object. */ +.ed-ctxmenu { + position: fixed; + z-index: 120; /* above the drawers (40) and the bar menus (50) */ + display: flex; + flex-direction: column; + min-width: 184px; + padding: 4px; + background: #fff; + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: 0 12px 32px rgb(20 30 45 / 0.18); + user-select: none; + -webkit-overflow-scrolling: touch; +} +.ed-ctxmenu-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + width: 100%; + /* 40px, not the bar's 44: this list is read top-to-bottom with the finger + already on it, where run length matters more than target area. */ + min-height: 40px; + padding: 0 10px; + border: 0; + border-radius: 7px; + background: transparent; + color: var(--ink); + font: inherit; + font-size: 13px; + text-align: start; + cursor: pointer; +} +.ed-ctxmenu-item:hover:not(:disabled) { background: var(--hover, #f2f5f9); } +.ed-ctxmenu-item:disabled { opacity: 0.4; cursor: default; } +.ed-ctxmenu-item kbd { + font: inherit; + font-size: 11.5px; + color: #8a95a6; + white-space: nowrap; +} +.ed-ctxmenu-danger:not(:disabled) { color: #b4232a; } +.ed-ctxmenu-danger:hover:not(:disabled) { background: #fdeced; } +.ed-ctxmenu-sep { height: 1px; margin: 4px 2px; background: var(--line); }