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); }