mirror of
https://github.com/penpot/penpot.git
synced 2026-09-16 15:51:59 -04:00
🎉 Add context menu for v3 editor (#11677)
* 🎉 Add a context menu to the v3 text editor Right-clicking while editing a text shape now opens a menu with Cut, Copy, Paste and Select all, so those actions stop being reachable only from the keyboard. Cut and Copy are disabled when nothing is selected, and with no selection the caret first moves to the click position. The menu is a new `:text` kind of the workspace context menu, so it inherits the existing positioning, styling and shortcut hints. Its entries emit events that drive the WASM editor through the same calls the keyboard path uses, so a menu edit commits exactly like a keystroke: same undo step, same layer rename. A secondary click has to leave the text alone until the menu acts on it. The pointer handlers now ignore it, both the right button and the macOS Ctrl+Click that reports button 0, so it can no longer start a drag-selection over what was selected. Pressing the mouse on the menu would blur the capture surface and end the session, so the menu cancels the default action of `mousedown` while a text is being edited. Paste reads the system clipboard through `navigator.clipboard`, since a menu click carries no clipboard event. Closes #10914 AI-assisted-by: claude-opus-5 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * ✨ Add Playwright tests for the text editor context menu Eight tests under "Text Editor context menu" in the v3 editor spec. They load the existing lorem ipsum fixture rather than drawing a shape, enter edit mode, and drive the menu with a real right click. They cover the four entries, that the right click keeps the selection (copy returns the whole text), that a right button drag no longer replaces it, that moving the caret abandons a staged typography, and that cut and copy are disabled at a collapsed caret. Typing right after a menu action also shows the editor kept the focus while the menu was open. AI-assisted-by: claude-opus-5 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * ♻️ Use a test id instead of a DOM id in the v3 editor spec AI-assisted-by: claude-opus-5 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8d69374386
commit
91b433985a
7 files changed
+503
-98
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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 %))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}]))]]]))
|
||||
@@ -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
|
||||
|
||||
@@ -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")))
|
||||
Reference in new issue
Block a user