mirror of
https://github.com/penpot/penpot.git
synced 2026-04-25 11:18:36 +00:00
242 lines
9.3 KiB
Clojure
242 lines
9.3 KiB
Clojure
;; This Source Code Form is subject to the terms of the Mozilla Public
|
|
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
;;
|
|
;; Copyright (c) KALEIDOS INC
|
|
|
|
(ns app.render-wasm.text-editor-input
|
|
"Contenteditable DOM element for WASM text editor input"
|
|
(:require
|
|
[app.common.geom.shapes :as gsh]
|
|
[app.main.data.workspace.texts :as dwt]
|
|
[app.main.store :as st]
|
|
[app.render-wasm.api :as wasm.api]
|
|
[app.render-wasm.text-editor :as text-editor]
|
|
[app.util.dom :as dom]
|
|
[app.util.object :as obj]
|
|
[cuerdas.core :as str]
|
|
[goog.events :as events]
|
|
[rumext.v2 :as mf])
|
|
(:import goog.events.EventType))
|
|
|
|
(def caret-blink-interval-ms 250)
|
|
|
|
(defn- sync-wasm-text-editor-content!
|
|
"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)]
|
|
(st/emit! (dwt/v2-update-text-shape-content
|
|
shape-id content
|
|
:update-name? true
|
|
:finalize? finalize?))))
|
|
|
|
(mf/defc text-editor-input
|
|
"Contenteditable element positioned over the text shape to capture input events."
|
|
{::mf/wrap-props false}
|
|
[props]
|
|
(let [shape (obj/get props "shape")
|
|
zoom (obj/get props "zoom")
|
|
vbox (obj/get props "vbox")
|
|
|
|
contenteditable-ref (mf/use-ref nil)
|
|
composing? (mf/use-state false)
|
|
|
|
;; Calculate screen position from shape bounds
|
|
shape-bounds (gsh/shape->rect shape)
|
|
screen-x (* (- (:x shape-bounds) (:x vbox)) zoom)
|
|
screen-y (* (- (:y shape-bounds) (:y vbox)) zoom)
|
|
screen-w (* (:width shape-bounds) zoom)
|
|
screen-h (* (:height shape-bounds) zoom)]
|
|
|
|
;; Focus contenteditable on mount
|
|
(mf/use-effect
|
|
(fn []
|
|
(when-let [node (mf/ref-val contenteditable-ref)]
|
|
(.focus node))
|
|
js/undefined))
|
|
|
|
(mf/use-effect
|
|
(fn []
|
|
(let [timeout-id (atom nil)
|
|
schedule-blink (fn schedule-blink []
|
|
(when (text-editor/text-editor-is-active?)
|
|
(wasm.api/request-render "cursor-blink"))
|
|
(reset! timeout-id (js/setTimeout schedule-blink caret-blink-interval-ms)))]
|
|
(schedule-blink)
|
|
(fn []
|
|
(when @timeout-id
|
|
(js/clearTimeout @timeout-id))))))
|
|
|
|
;; Document-level keydown handler for control keys
|
|
(mf/use-effect
|
|
(fn []
|
|
(let [on-doc-keydown
|
|
(fn [e]
|
|
(when (and (text-editor/text-editor-is-active?)
|
|
(not @composing?))
|
|
(let [key (.-key e)
|
|
ctrl? (or (.-ctrlKey e) (.-metaKey e))
|
|
shift? (.-shiftKey e)]
|
|
(cond
|
|
;; Escape: finalize and stop
|
|
(= key "Escape")
|
|
(do
|
|
(dom/prevent-default e)
|
|
(sync-wasm-text-editor-content! :finalize? true)
|
|
(text-editor/text-editor-stop))
|
|
|
|
;; Ctrl+A: select all (key is "a" or "A" depending on platform)
|
|
(and ctrl? (= (str/lower key) "a"))
|
|
(do
|
|
(dom/prevent-default e)
|
|
(text-editor/text-editor-select-all)
|
|
(wasm.api/request-render "text-select-all"))
|
|
|
|
;; Enter
|
|
(= key "Enter")
|
|
(do
|
|
(dom/prevent-default e)
|
|
(text-editor/text-editor-insert-paragraph)
|
|
(sync-wasm-text-editor-content!)
|
|
(wasm.api/request-render "text-paragraph"))
|
|
|
|
;; Backspace
|
|
(= key "Backspace")
|
|
(do
|
|
(dom/prevent-default e)
|
|
(text-editor/text-editor-delete-backward)
|
|
(sync-wasm-text-editor-content!)
|
|
(wasm.api/request-render "text-delete-backward"))
|
|
|
|
;; Delete
|
|
(= key "Delete")
|
|
(do
|
|
(dom/prevent-default e)
|
|
(text-editor/text-editor-delete-forward)
|
|
(sync-wasm-text-editor-content!)
|
|
(wasm.api/request-render "text-delete-forward"))
|
|
|
|
;; Arrow keys
|
|
(= key "ArrowLeft")
|
|
(do
|
|
(dom/prevent-default e)
|
|
(text-editor/text-editor-move-cursor 0 shift?)
|
|
(wasm.api/request-render "text-cursor-move"))
|
|
|
|
(= key "ArrowRight")
|
|
(do
|
|
(dom/prevent-default e)
|
|
(text-editor/text-editor-move-cursor 1 shift?)
|
|
(wasm.api/request-render "text-cursor-move"))
|
|
|
|
(= key "ArrowUp")
|
|
(do
|
|
(dom/prevent-default e)
|
|
(text-editor/text-editor-move-cursor 2 shift?)
|
|
(wasm.api/request-render "text-cursor-move"))
|
|
|
|
(= key "ArrowDown")
|
|
(do
|
|
(dom/prevent-default e)
|
|
(text-editor/text-editor-move-cursor 3 shift?)
|
|
(wasm.api/request-render "text-cursor-move"))
|
|
|
|
(= key "Home")
|
|
(do
|
|
(dom/prevent-default e)
|
|
(text-editor/text-editor-move-cursor 4 shift?)
|
|
(wasm.api/request-render "text-cursor-move"))
|
|
|
|
(= key "End")
|
|
(do
|
|
(dom/prevent-default e)
|
|
(text-editor/text-editor-move-cursor 5 shift?)
|
|
(wasm.api/request-render "text-cursor-move"))
|
|
|
|
;; Let contenteditable handle text input via on-input
|
|
:else nil))))]
|
|
(events/listen js/document EventType.KEYDOWN on-doc-keydown true)
|
|
(fn []
|
|
(events/unlisten js/document EventType.KEYDOWN on-doc-keydown true)))))
|
|
|
|
;; Composition and input events
|
|
(let [on-composition-start
|
|
(mf/use-fn
|
|
(fn [_event]
|
|
(reset! composing? true)))
|
|
|
|
on-composition-end
|
|
(mf/use-fn
|
|
(fn [^js event]
|
|
(reset! composing? false)
|
|
(let [data (.-data event)]
|
|
(when data
|
|
(text-editor/text-editor-insert-text data)
|
|
(sync-wasm-text-editor-content!)
|
|
(wasm.api/request-render "text-composition"))
|
|
(when-let [node (mf/ref-val contenteditable-ref)]
|
|
(set! (.-textContent node) "")))))
|
|
|
|
on-paste
|
|
(mf/use-fn
|
|
(fn [^js event]
|
|
(dom/prevent-default event)
|
|
(let [clipboard-data (.-clipboardData event)
|
|
text (.getData clipboard-data "text/plain")]
|
|
(when (and text (seq text))
|
|
(text-editor/text-editor-insert-text text)
|
|
(sync-wasm-text-editor-content!)
|
|
(wasm.api/request-render "text-paste"))
|
|
(when-let [node (mf/ref-val contenteditable-ref)]
|
|
(set! (.-textContent node) "")))))
|
|
|
|
on-copy
|
|
(mf/use-fn
|
|
(fn [^js event]
|
|
(when (text-editor/text-editor-is-active?)
|
|
(dom/prevent-default event)
|
|
(when (text-editor/text-editor-get-selection)
|
|
(let [text (text-editor/text-editor-export-selection)]
|
|
(.setData (.-clipboardData event) "text/plain" text))))))
|
|
|
|
on-input
|
|
(mf/use-fn
|
|
(fn [^js event]
|
|
(let [native-event (.-nativeEvent event)
|
|
input-type (.-inputType native-event)
|
|
data (.-data native-event)]
|
|
;; Skip composition-related input events - composition-end handles those
|
|
(when (and (not @composing?)
|
|
(not= input-type "insertCompositionText"))
|
|
(when (and data (seq data))
|
|
(text-editor/text-editor-insert-text data)
|
|
(sync-wasm-text-editor-content!)
|
|
(wasm.api/request-render "text-input"))
|
|
(when-let [node (mf/ref-val contenteditable-ref)]
|
|
(set! (.-textContent node) ""))))))]
|
|
|
|
[:div
|
|
{:ref contenteditable-ref
|
|
:contentEditable true
|
|
:suppressContentEditableWarning true
|
|
:on-composition-start on-composition-start
|
|
:on-composition-end on-composition-end
|
|
:on-input on-input
|
|
:on-paste on-paste
|
|
:on-copy on-copy
|
|
;; FIXME on-click
|
|
;; :on-click on-click
|
|
:id "text-editor-wasm-input"
|
|
;; FIXME
|
|
:style {:position "absolute"
|
|
:left (str screen-x "px")
|
|
:top (str screen-y "px")
|
|
:width (str screen-w "px")
|
|
:height (str screen-h "px")
|
|
:opacity 0
|
|
:overflow "hidden"
|
|
:white-space "pre"
|
|
:cursor "text"
|
|
:z-index 10}}])))
|