diff --git a/frontend/playwright/ui/specs/text-editor-v3.spec.js b/frontend/playwright/ui/specs/text-editor-v3.spec.js index 21d7d97909..9f0f0d7323 100644 --- a/frontend/playwright/ui/specs/text-editor-v3.spec.js +++ b/frontend/playwright/ui/specs/text-editor-v3.spec.js @@ -33,7 +33,7 @@ test("Typography at a collapsed caret only styles newly typed text", async ({ await workspace.waitForFirstRender(); const fontSize = workspace.textEditor.fontSize; - const editorInput = page.locator("#text-editor-wasm-input"); + const editorInput = page.getByTestId("text-editor-container"); // Draw a text box, focus it, and type some text; the caret ends up collapsed // after it. @@ -407,3 +407,197 @@ test.describe("BUG 10934 - Double-clicking a text side handle sets auto-size", ( .toBeLessThan(initialHeight); }); }); + +test.describe("Text Editor context menu", () => { + // Loads a file that already holds a text shape and enters edit mode, which + // leaves the whole text selected. + async function editText(page) { + const workspace = new WasmWorkspacePage(page, { textEditor: true }); + await workspace.setupEmptyFile(); + await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json"); + await workspace.goToWorkspace(); + await workspace.waitForFirstRender(); + + await workspace.clickLeafLayer("Lorem ipsum"); + await workspace.textEditor.startEditing(); + + return workspace; + } + + // Right-clicks over the first characters of the text. The capture surface + // starts at the shape's top left corner, so its own box gives us the point. + async function rightClickOnText(workspace) { + const box = await workspace.page + .getByTestId("text-editor-container") + .boundingBox(); + await workspace.page.mouse.click(box.x + 10, box.y + box.height / 2, { + button: "right", + }); + } + + function contextMenu(page) { + return page.getByTestId("context-menu"); + } + + function menuItem(page, name) { + return contextMenu(page).getByRole("listitem").filter({ hasText: name }); + } + + function readClipboard(page) { + return page.evaluate(() => navigator.clipboard.readText()); + } + + test("Right-clicking the text being edited opens the text menu", async ({ + page, + }) => { + const workspace = await editText(page); + + await rightClickOnText(workspace); + + await expect(menuItem(page, "Cut")).toBeVisible(); + await expect(menuItem(page, "Copy")).toBeVisible(); + await expect(menuItem(page, "Paste")).toBeVisible(); + await expect(menuItem(page, "Select all")).toBeVisible(); + + // The menu acts on the text, so the shape entries are not offered. + await expect(menuItem(page, "Duplicate")).toHaveCount(0); + }); + + test("Copying from the menu copies the selected text", async ({ page }) => { + const workspace = await editText(page); + + // Entering the editor selects the whole text, and the right click keeps it. + await rightClickOnText(workspace); + await menuItem(page, "Copy").click(); + + await expect.poll(() => readClipboard(page)).toBe("Lorem ipsum"); + }); + + test("Cutting from the menu removes the selected text", async ({ page }) => { + const workspace = await editText(page); + + await rightClickOnText(workspace); + await menuItem(page, "Cut").click(); + + await expect.poll(() => readClipboard(page)).toBe("Lorem ipsum"); + await expect(contextMenu(page)).toBeHidden(); + + // The text is gone, so what we type now is all the shape has left. Typing + // also shows the editor kept the focus while the menu was open. + await page.keyboard.type("ok"); + await workspace.textEditor.stopEditing(); + + await workspace.layers.getByTestId("layer-row").first().click(); + await workspace.waitForSelectedShapeName("ok"); + }); + + test("Pasting from the menu replaces the selection", async ({ page }) => { + const workspace = await editText(page); + + await page.evaluate(() => navigator.clipboard.writeText("pasted")); + + // The whole text is selected, so the paste replaces it. + await rightClickOnText(workspace); + await menuItem(page, "Paste").click(); + await expect(contextMenu(page)).toBeHidden(); + + await workspace.textEditor.stopEditing(); + + await workspace.layers.getByTestId("layer-row").first().click(); + await workspace.waitForSelectedShapeName("pasted"); + }); + + test("Selecting all from the menu selects the whole text", async ({ + page, + }) => { + const workspace = await editText(page); + + // Collapse the selection the editor starts with. + await page.keyboard.press("End"); + + await rightClickOnText(workspace); + await menuItem(page, "Select all").click(); + await expect(contextMenu(page)).toBeHidden(); + + // Everything is selected again, so typing replaces the whole text. + await page.keyboard.type("ok"); + await workspace.textEditor.stopEditing(); + + await workspace.layers.getByTestId("layer-row").first().click(); + await workspace.waitForSelectedShapeName("ok"); + }); + + // Presses a button over the text, moves and releases: the gesture that used to + // start a drag-selection and destroy whatever was selected. + async function dragOverText(workspace, button) { + const { page } = workspace; + const box = await page.getByTestId("text-editor-container").boundingBox(); + const y = box.y + box.height / 2; + + await page.mouse.move(box.x + 10, y); + await page.mouse.down({ button }); + // Steps, so the moves are actually delivered to the page. + await page.mouse.move(box.x + 40, y, { steps: 10 }); + await page.mouse.up({ button }); + } + + // Copies with the keyboard and resolves with what reached the clipboard. The + // sentinel keeps a value left by an earlier test from passing as a result. + async function copiedText(page) { + await page.evaluate(() => navigator.clipboard.writeText("sentinel")); + await page.keyboard.press("ControlOrMeta+C"); + return readClipboard(page); + } + + test("The selection survives a right button drag", async ({ page }) => { + const workspace = await editText(page); + + await dragOverText(workspace, "right"); + + // The whole text is still selected, so the copy takes all of it. + await expect.poll(() => copiedText(page)).toBe("Lorem ipsum"); + }); + + test("Moving the caret with a right click drops the pending style", async ({ + page, + }) => { + const workspace = await editText(page); + const fontSize = workspace.textEditor.fontSize; + + // Collapse the caret at the end and stage a font size for whatever is typed + // next, which is what the sidebar does at a collapsed caret. + await page.keyboard.press("End"); + const originalSize = await fontSize.inputValue(); + const newSize = String(Number(originalSize) + 20); + await workspace.textEditor.changeFontSize(newSize); + + // The right click moves the caret, which abandons that staged size. The menu + // is left open on purpose: Escape would clear the staged size by itself, and + // the assertion below would then hold whether or not the caret move did. + await rightClickOnText(workspace); + await expect(contextMenu(page)).toBeVisible(); + + await page.keyboard.type("X"); + + // The typed character takes the size of the text around it, not the one + // staged for the caret position we left behind. + await page.keyboard.press("Shift+ArrowLeft"); + await expect(fontSize).toHaveValue(originalSize); + }); + + test("Cut and copy are disabled when there is no selection", async ({ + page, + }) => { + const workspace = await editText(page); + + // Collapse the selection the editor starts with. + await page.keyboard.press("End"); + + await rightClickOnText(workspace); + + await expect(menuItem(page, "Cut")).toHaveAttribute("disabled"); + await expect(menuItem(page, "Copy")).toHaveAttribute("disabled"); + await expect(menuItem(page, "Paste")).not.toHaveAttribute("disabled"); + await expect(menuItem(page, "Select all")).not.toHaveAttribute("disabled"); + }); +}); diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index aa8ddcd7c8..c0bc3b3604 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -1323,6 +1323,16 @@ (-> params (assoc :kind :guide :guide guide))))))) +(defn show-text-context-menu + "Context menu for the text being edited. Unlike the shape menu it leaves the + shape selection alone; `has-selection?` is captured at right-click time." + [{:keys [position] :as params}] + (dm/assert! (gpt/point? position)) + (ptk/reify ::show-text-context-menu + ptk/WatchEvent + (watch [_ _ _] + (rx/of (show-context-menu (assoc params :kind :text)))))) + (def hide-context-menu (ptk/reify ::hide-context-menu ptk/UpdateEvent diff --git a/frontend/src/app/main/data/workspace/clipboard.cljs b/frontend/src/app/main/data/workspace/clipboard.cljs index 58d4404241..d05c3466af 100644 --- a/frontend/src/app/main/data/workspace/clipboard.cljs +++ b/frontend/src/app/main/data/workspace/clipboard.cljs @@ -302,37 +302,13 @@ (->> (rx/from (.text blob)) (rx/map paste-text)))))) -(defn- clipboard-permission-error? - "Check if the given error is a clipboard permission error - (NotAllowedError DOMException)." - [cause] - (and (instance? js/DOMException cause) - (= (.-name cause) "NotAllowedError"))) - -(defn- clipboard-unavailable-error? - "Check if the given error is a clipboard API unavailable error - (thrown when navigator.clipboard is undefined, e.g. on insecure - origins per the W3C Secure Contexts spec)." - [cause] - (and (instance? js/Error cause) - (str/starts-with? (.-message cause) "Clipboard API is unavailable."))) - (defn- on-clipboard-permission-error [cause] - (cond - (clipboard-permission-error? cause) - (rx/of (ntf/show {:content (tr "errors.clipboard-permission-denied") + (if-let [message (clipboard/error-message cause)] + (rx/of (ntf/show {:content message :type :toast :level :warning :timeout 5000})) - - (clipboard-unavailable-error? cause) - (rx/of (ntf/show {:content (tr "errors.clipboard-api-unavailable") - :type :toast - :level :warning - :timeout 5000})) - - :else (rx/throw cause))) (defn paste-from-clipboard @@ -529,26 +505,16 @@ (-> entry t/decode-str paste-transit-props)) (on-error [cause] - (cond - (clipboard-permission-error? cause) - (rx/of (ntf/show {:content (tr "errors.clipboard-permission-denied") + (if-let [message (clipboard/error-message cause)] + (rx/of (ntf/show {:content message :type :toast :level :warning :timeout 5000})) - - (clipboard-unavailable-error? cause) - (rx/of (ntf/show {:content (tr "errors.clipboard-api-unavailable") - :type :toast - :level :warning - :timeout 5000})) - - (:not-implemented (ex-data cause)) - (rx/of (ntf/warn (tr "errors.clipboard-not-implemented"))) - - :else - (do - (js/console.error "Clipboard error:" cause) - (rx/empty))))] + (if (:not-implemented (ex-data cause)) + (rx/of (ntf/warn (tr "errors.clipboard-not-implemented"))) + (do + (js/console.error "Clipboard error:" cause) + (rx/empty)))))] (->> (clipboard/from-navigator default-options) (rx/mapcat #(.text %)) diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 1056c085e0..6877610caf 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -24,6 +24,7 @@ [app.main.data.changes :as dch] [app.main.data.event :as ev] [app.main.data.helpers :as dsh] + [app.main.data.notifications :as ntf] [app.main.data.workspace :as-alias dw] [app.main.data.workspace.common :as dwc] [app.main.data.workspace.libraries :as dwl] @@ -43,6 +44,7 @@ [app.render-wasm.api :as wasm.api] [app.render-wasm.api.fonts :as wasm.fonts] [app.render-wasm.text-editor :as wasm.text-editor] + [app.util.clipboard :as clipboard] [app.util.text-editor :as ted] [app.util.text.content :as tc] [app.util.text.content.styles :as styles] @@ -1479,6 +1481,111 @@ (gsh/transform-shape (ctm/change-size shape width height)))))) {:undo-group (when new-shape? id)})))))))) +(defn v3-sync-editor-content + "Event pushing the WASM editor content back into the shape, or nil when there is + nothing to sync. Every text edit commits through it, menu or keystroke alike." + [& {:keys [finalize?]}] + (when-let [{:keys [shape-id content]} (wasm.text-editor/text-editor-sync-content)] + (let [text (txt/content->text content) + name (when (not= text "") + (txt/generate-shape-name text))] + (v2-update-text-shape-content shape-id content + :update-name? true + :name name + :finalize? finalize?)))) + +(defn- sync-editor-content-stream + "Stream of the sync event for `reason`, after asking WASM to repaint." + [reason] + (let [event (v3-sync-editor-content)] + (wasm.api/request-render-preserving-target reason) + (if (some? event) + (rx/of event) + (rx/empty)))) + +(defn- editor-selected-text + "Plain text of the current WASM editor selection, or nil when there is none." + [] + (when (and (wasm.text-editor/text-editor-has-focus?) + (wasm.text-editor/text-editor-has-selection?)) + (let [text (wasm.text-editor/text-editor-export-selection)] + (when (seq text) text)))) + +(defn- write-selection-to-clipboard + "Write `text` as plain text and HTML; Windows apps often prefer CF_HTML." + [text] + (clipboard/to-clipboard-multi {"text/plain" text + "text/html" (clipboard/plain-text->html text)})) + +(defn- on-clipboard-error + [cause] + (if-let [message (clipboard/error-message cause)] + (rx/of (ntf/show {:content message + :type :toast + :level :warning + :timeout 5000})) + (do + (js/console.error "Clipboard error:" cause) + (rx/empty)))) + +(defn v3-copy-selection + "Copy the text editor selection to the system clipboard." + [] + (ptk/reify ::v3-copy-selection + ptk/WatchEvent + (watch [_ _ _] + (if-let [text (editor-selected-text)] + (->> (rx/from (write-selection-to-clipboard text)) + (rx/ignore) + (rx/catch on-clipboard-error)) + (rx/empty))))) + +(defn v3-cut-selection + "Copy the text editor selection to the system clipboard and remove it." + [] + (ptk/reify ::v3-cut-selection + ptk/WatchEvent + (watch [_ _ _] + (if-let [text (editor-selected-text)] + (->> (rx/from (write-selection-to-clipboard text)) + (rx/mapcat (fn [_] + ;; Delete only once the text is safely on the clipboard, + ;; so a refused clipboard cannot lose the selection. + (wasm.text-editor/text-editor-delete-backward) + (sync-editor-content-stream "text-cut"))) + (rx/catch on-clipboard-error)) + (rx/empty))))) + +(defn v3-paste-text + "Insert the system clipboard text at the caret, replacing the selection." + [] + (ptk/reify ::v3-paste-text + ptk/WatchEvent + (watch [_ _ _] + (if-not (wasm.text-editor/text-editor-has-focus?) + (rx/empty) + (->> (rx/from (clipboard/read-text)) + (rx/mapcat (fn [text] + (if (seq text) + (do + ;; Pasted text keeps the surrounding style. + (wasm.text-editor/clear-pending-caret-styles!) + (wasm.text-editor/text-editor-insert-text text) + (sync-editor-content-stream "text-paste")) + (rx/empty)))) + (rx/catch on-clipboard-error)))))) + +(defn v3-select-all + "Select every character of the text being edited." + [] + (ptk/reify ::v3-select-all + ptk/EffectEvent + (effect [_ _ _] + (when (wasm.text-editor/text-editor-has-focus?) + (wasm.text-editor/clear-pending-caret-styles!) + (wasm.text-editor/text-editor-select-all) + (wasm.api/render-text-editor-overlay!))))) + (defn replace-layer-names-in-shapes [ids search replacement] (ptk/reify ::replace-layer-names-in-shapes diff --git a/frontend/src/app/main/ui/workspace/context_menu.cljs b/frontend/src/app/main/ui/workspace/context_menu.cljs index 4e910f3624..7cb59b75e5 100644 --- a/frontend/src/app/main/ui/workspace/context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/context_menu.cljs @@ -28,6 +28,7 @@ [app.main.data.workspace.shape-layout :as dwsl] [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.shortcuts :as sc] + [app.main.data.workspace.texts :as dwt] [app.main.data.workspace.variants :as dwv] [app.main.features :as features] [app.main.refs :as refs] @@ -1040,6 +1041,41 @@ [:> menu-entry* {:title (tr "workspace.context-menu.guides.remove") :on-click do-remove-guide}]])) +(mf/defc text-context-menu* + "Menu shown while a text shape is being edited: it acts on the text, never on the shape." + {::mf/private true} + [{:keys [mdata]}] + (let [custom-shortcuts (mf/deref refs/custom-shortcuts) + get-tt #(sc/get-effective-tooltip % custom-shortcuts) + + has-selection? (get mdata :has-selection?) + + do-cut (mf/use-fn #(st/emit! (dwt/v3-cut-selection))) + do-copy (mf/use-fn #(st/emit! (dwt/v3-copy-selection))) + do-paste (mf/use-fn #(st/emit! (dwt/v3-paste-text))) + do-select-all (mf/use-fn #(st/emit! (dwt/v3-select-all)))] + + [:* + [:> menu-entry* {:title (tr "workspace.shape.menu.cut") + :shortcut (get-tt :cut) + :shortcut-key :cut + :disabled (not has-selection?) + :on-click do-cut}] + [:> menu-entry* {:title (tr "workspace.shape.menu.copy") + :shortcut (get-tt :copy) + :shortcut-key :copy + :disabled (not has-selection?) + :on-click do-copy}] + [:> menu-entry* {:title (tr "workspace.shape.menu.paste") + :shortcut (get-tt :paste) + :shortcut-key :paste + :on-click do-paste}] + [:> menu-separator* {}] + [:> menu-entry* {:title (tr "workspace.header.menu.select-all") + :shortcut (get-tt :select-all) + :shortcut-key :select-all + :on-click do-select-all}]])) + ;; FIXME: optimize because it is rendered always (mf/defc context-menu* @@ -1048,7 +1084,20 @@ top (- (get-in mdata [:position :y]) 20) left (get-in mdata [:position :x]) dropdown-ref (mf/use-ref) - read-only? (mf/use-ctx ctx/workspace-read-only?)] + read-only? (mf/use-ctx ctx/workspace-read-only?) + + ;; The text menu floats over a live editing session, which ends as soon + ;; as the capture surface loses the focus. + text-menu? (= :text (:kind mdata)) + + ;; `mousedown` is what moves the focus, so cancelling it keeps the editor + ;; alive; `click` still fires and the entries work. + on-mouse-down + (mf/use-fn + (mf/deps text-menu?) + (fn [event] + (when ^boolean text-menu? + (dom/prevent-default event))))] (mf/with-effect [mdata] (when-let [dropdown (mf/ref-val dropdown-ref)] @@ -1067,6 +1116,8 @@ :ref dropdown-ref :style {:top top :left left} :data-testid "context-menu" + :data-keep-editing-on-blur (when ^boolean text-menu? true) + :on-mouse-down on-mouse-down :on-context-menu prevent-default} [:ul {:class (stl/css :menu)} @@ -1078,4 +1129,5 @@ :grid-track [:> grid-track-context-menu* {:mdata mdata}] :grid-cells [:> grid-cells-context-menu* {:mdata mdata}] :guide [:> guide-color-context-menu* {:mdata mdata}] + :text [:> text-context-menu* {:mdata mdata}] [:> viewport-context-menu* {:mdata mdata}]))]]])) diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs index fe0dc8a03b..1c95406ef5 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs @@ -10,6 +10,7 @@ (:require [app.common.data.macros :as dm] [app.common.types.text :as txt] + [app.config :as cf] [app.main.data.helpers :as dsh] [app.main.data.workspace :as dw] [app.main.data.workspace.texts :as dwt] @@ -22,11 +23,15 @@ [app.util.clipboard :as clipboard] [app.util.dom :as dom] [app.util.keyboard :as kbd] + [app.util.timers :as ts] [cuerdas.core :as str] [rumext.v2 :as mf])) (def caret-blink-interval-ms 250) +;; The open workspace context menu, if any (see the Escape handler below). +(def ^:private menu-selector "[data-testid='context-menu']") + ;; Elements carrying this attr keep the edit alive when focus moves onto them (see `keep-editing-on-blur?`). (def ^:private keep-editing-selector "[data-keep-editing-on-blur]") @@ -45,17 +50,8 @@ "Sync WASM text editor content back to the shape via the standard commit pipeline. Called after every text-modifying input." [& {:keys [finalize?]}] - (when-let [{:keys [shape-id content]} - (text-editor/text-editor-sync-content)] - ;; Derive the layer name from the text so it tracks the content. - (let [text (txt/content->text content) - name (when (not= text "") - (txt/generate-shape-name text))] - (st/emit! (dwt/v2-update-text-shape-content - shape-id content - :update-name? true - :name name - :finalize? finalize?))))) + (when-let [event (dwt/v3-sync-editor-content :finalize? finalize?)] + (st/emit! event))) ;; Keys that move/reset the caret (or delete): pressing any abandons the pending ;; caret style. Plain character keys instead reach `on-input`, which consumes it. @@ -159,6 +155,19 @@ (or (.-isComposing native) (= 229 (.-keyCode event))))) +(defn- secondary-button? + "True for a secondary click, which opens the context menu: the right button, + or the macOS Ctrl+Click that stands in for it and reports button 0." + [^js event] + (or (= 2 (.-button event)) + (and (cf/check-platform? :macos) (kbd/ctrl? event)))) + +(defn- primary-button-pressed? + "True while the left button is held. `buttons` is a bitmask: `pos?` would also + match the right button." + [^js event] + (pos? (bit-and (.-buttons event) 1))) + (defn- double-click? [^js native-event] (= (.-detail native-event) 2)) @@ -464,26 +473,27 @@ on-pointer-down (mf/use-fn (fn [^js event] - (let [native-event (dom/event->native-event event) - off-pt (dom/get-offset-position native-event)] - ;; Repositioning the caret abandons the pending caret style (also - ;; covers click and double-click, which fire pointer-down first). - (text-editor/clear-pending-caret-styles!) - (if (.-shiftKey event) - (do - (mf/set-ref-val! dragging-ref true) - (wasm.api/text-editor-pointer-down-extend off-pt) - ;; Repaint the caret over the cached tiles instead of a full - ;; render, which flashes at high zoom. - (wasm.api/render-text-editor-overlay!)) - (mf/set-ref-val! deferred-press-ref off-pt))))) + (when-not (secondary-button? event) + (let [native-event (dom/event->native-event event) + off-pt (dom/get-offset-position native-event)] + ;; Repositioning the caret abandons the pending caret style (also + ;; covers click and double-click, which fire pointer-down first). + (text-editor/clear-pending-caret-styles!) + (if (.-shiftKey event) + (do + (mf/set-ref-val! dragging-ref true) + (wasm.api/text-editor-pointer-down-extend off-pt) + ;; Repaint the caret over the cached tiles instead of a full + ;; render, which flashes at high zoom. + (wasm.api/render-text-editor-overlay!)) + (mf/set-ref-val! deferred-press-ref off-pt)))))) on-pointer-move (mf/use-fn (fn [^js event] (let [native-event (dom/event->native-event event) off-pt (dom/get-offset-position native-event)] - (when-let [pressed-pt (and (pos? (.-buttons native-event)) + (when-let [pressed-pt (and (primary-button-pressed? native-event) (mf/ref-val deferred-press-ref))] (mf/set-ref-val! deferred-press-ref nil) (mf/set-ref-val! dragging-ref true) @@ -497,38 +507,40 @@ on-pointer-up (mf/use-fn (fn [^js event] - (let [native-event (dom/event->native-event event) - off-pt (dom/get-offset-position native-event) - dragging? (mf/ref-val dragging-ref)] - (mf/set-ref-val! dragging-ref false) - (mf/set-ref-val! deferred-press-ref nil) - (wasm.api/text-editor-pointer-up off-pt) - ;; Without a drag there is no pointer selection to close; the - ;; caret is placed by `on-click`. - (when dragging? - (wasm.api/render-text-editor-overlay!))))) + (when-not (secondary-button? event) + (let [native-event (dom/event->native-event event) + off-pt (dom/get-offset-position native-event) + dragging? (mf/ref-val dragging-ref)] + (mf/set-ref-val! dragging-ref false) + (mf/set-ref-val! deferred-press-ref nil) + (wasm.api/text-editor-pointer-up off-pt) + ;; Without a drag there is no pointer selection to close; the + ;; caret is placed by `on-click`. + (when dragging? + (wasm.api/render-text-editor-overlay!)))))) on-click (mf/use-fn (fn [^js event] - (let [native-event (dom/event->native-event event) - off-pt (dom/get-offset-position native-event)] - (cond - (triple-click? native-event) - (do - (wasm.api/text-editor-select-paragraph off-pt) - (wasm.api/render-text-editor-overlay!)) + (when-not (secondary-button? event) + (let [native-event (dom/event->native-event event) + off-pt (dom/get-offset-position native-event)] + (cond + (triple-click? native-event) + (do + (wasm.api/text-editor-select-paragraph off-pt) + (wasm.api/render-text-editor-overlay!)) - ;; `dblclick` selects the word right after. Shift+click still goes - ;; through: WASM consumes its skip-click flag there. - (and (double-click? native-event) - (not (.-shiftKey event))) - nil + ;; `dblclick` selects the word right after. Shift+click still goes + ;; through: WASM consumes its skip-click flag there. + (and (double-click? native-event) + (not (.-shiftKey event))) + nil - :else - (do - (wasm.api/text-editor-set-cursor-from-offset off-pt) - (wasm.api/render-text-editor-overlay!)))))) + :else + (do + (wasm.api/text-editor-set-cursor-from-offset off-pt) + (wasm.api/render-text-editor-overlay!))))))) on-double-click (mf/use-fn @@ -538,6 +550,32 @@ (wasm.api/text-editor-select-word-boundary off-pt) (wasm.api/render-text-editor-overlay!)))) + on-context-menu + (mf/use-fn + (mf/deps shape-id) + (fn [^js event] + (dom/prevent-default event) + ;; Without this the viewport handler opens the shape menu instead. + (dom/stop-propagation event) + (let [position (dom/get-client-position event) + has-selection? (boolean (text-editor/text-editor-has-selection?))] + ;; With nothing selected the caret goes where the user pointed, so a + ;; paste from the menu lands there. + (when-not has-selection? + (let [off-pt (dom/get-offset-position (dom/event->native-event event))] + ;; Moving the caret abandons the pending caret style, as it does + ;; on every other path that moves it. + (text-editor/clear-pending-caret-styles!) + (wasm.api/text-editor-set-cursor-from-offset off-pt) + (wasm.api/render-text-editor-overlay!))) + ;; Deferred: the dropdown closes itself on a document `contextmenu`, + ;; which would close the menu this very event is opening. + (ts/schedule + #(st/emit! (dw/show-text-context-menu + {:position position + :shape-id shape-id + :has-selection? has-selection?})))))) + on-focus (mf/use-fn (fn [^js _event] @@ -566,7 +604,11 @@ (let [on-key-up (fn [event] (when (kbd/esc? event) (dom/stop-propagation event) - (st/emit! (dw/clear-edition-mode))))] + ;; With the menu open, Escape only closes it (checked + ;; in the DOM: the store may already be cleared). + (if (some? (dom/query menu-selector)) + (st/emit! dw/hide-context-menu) + (st/emit! (dw/clear-edition-mode)))))] (.addEventListener js/document "keyup" on-key-up) #(.removeEventListener js/document "keyup" on-key-up)))) @@ -600,7 +642,8 @@ ;; it was not being reliable (timing issues, Firefox issues…) (fn [] (on-blur) - (st/emit! (dwu/commit-undo-transaction shape-id)) + (st/emit! dw/hide-context-menu + (dwu/commit-undo-transaction shape-id)) (text-editor/text-editor-dispose) (wasm.api/request-render-preserving-target "text-editor-dispose")))) @@ -639,6 +682,7 @@ :on-pointer-down on-pointer-down :on-pointer-move on-pointer-move :on-pointer-up on-pointer-up + :on-context-menu on-context-menu :class (stl/css :text-editor) :style style} [:div diff --git a/frontend/src/app/util/clipboard.cljs b/frontend/src/app/util/clipboard.cljs index a23473b964..3199a6f093 100644 --- a/frontend/src/app/util/clipboard.cljs +++ b/frontend/src/app/util/clipboard.cljs @@ -9,6 +9,7 @@ ["./clipboard.js" :as impl] [app.common.transit :as t] [app.util.dom :as dom] + [app.util.i18n :refer [tr]] [beicon.v2.core :as rx] [cuerdas.core :as str])) @@ -214,3 +215,34 @@ :else (unavailable-error)))) + +(defn read-text + "Read the system clipboard as plain text. Always returns a Promise, rejecting + like `to-clipboard` when the asynchronous Clipboard API is not exposed." + [] + (let [clipboard (get-clipboard)] + (if (and clipboard (unchecked-get clipboard "readText")) + (.readText ^js clipboard) + (unavailable-error)))) + +(defn permission-error? + "True for the `NotAllowedError` DOMException raised when access is denied." + [cause] + (and (instance? js/DOMException cause) + (= (.-name cause) "NotAllowedError"))) + +(defn unavailable-error? + "True when `navigator.clipboard` is undefined, e.g. on an insecure origin." + [cause] + (and (instance? js/Error cause) + (str/starts-with? (.-message cause) "Clipboard API is unavailable."))) + +(defn error-message + "Translated message for a clipboard failure, or nil for any other error." + [cause] + (cond + (permission-error? cause) + (tr "errors.clipboard-permission-denied") + + (unavailable-error? cause) + (tr "errors.clipboard-api-unavailable")))