mirror of
https://github.com/penpot/penpot.git
synced 2026-01-27 07:42:03 -05:00
Compare commits
2 Commits
staging-re
...
elenatorro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0463db5f96 | ||
|
|
29247c75e7 |
@@ -21,6 +21,9 @@
|
||||
[app.main.data.workspace.specialized-panel :as-alias dwsp]
|
||||
[app.main.refs :as refs]
|
||||
[app.main.store :as st]
|
||||
[app.main.features :as features]
|
||||
[app.render-wasm.api :as wasm.api]
|
||||
[app.render-wasm.wasm :as wasm.wasm]
|
||||
[app.main.ui.workspace.sidebar.assets.components :as wsac]
|
||||
[app.main.ui.workspace.viewport.viewport-ref :as uwvv]
|
||||
[app.util.dom :as dom]
|
||||
@@ -91,7 +94,12 @@
|
||||
::dwsp/interrupt)
|
||||
|
||||
(when (and (not= edition id) (or text-editing? grid-editing?))
|
||||
(st/emit! (dw/clear-edition-mode)))
|
||||
(st/emit! (dw/clear-edition-mode))
|
||||
;; Stop WASM text editor when exiting edit mode
|
||||
(when (and text-editing?
|
||||
(features/active-feature? @st/state "render-wasm/v1")
|
||||
wasm.wasm/context-initialized?)
|
||||
(wasm.api/text-editor-stop)))
|
||||
|
||||
(when (and (not text-editing?)
|
||||
(not blocked)
|
||||
@@ -184,6 +192,19 @@
|
||||
(not drawing-tool))
|
||||
(st/emit! (dw/select-shape (:id @hover) shift?)))
|
||||
|
||||
;; If clicking on a text shape and wasm render is enabled, forward cursor position
|
||||
(when (and hovering?
|
||||
(not @space?)
|
||||
edition ;; Only when already in edit mode
|
||||
(not drawing-path?)
|
||||
(not drawing-tool))
|
||||
(let [hover-shape @hover]
|
||||
(when (and (= :text (:type hover-shape))
|
||||
(features/active-feature? @st/state "render-wasm/v1")
|
||||
wasm.wasm/context-initialized?)
|
||||
(let [raw-pt (dom/get-client-position event)]
|
||||
(wasm.api/text-editor-set-cursor-from-point (.-x raw-pt) (.-y raw-pt))))))
|
||||
|
||||
(when (and @z?
|
||||
(not @space?)
|
||||
(not edition)
|
||||
@@ -223,8 +244,17 @@
|
||||
(when (and (not drawing-path?) shape)
|
||||
(cond
|
||||
(and editable? (not= id edition) (not read-only?))
|
||||
(st/emit! (dw/select-shape id)
|
||||
(dw/start-editing-selected))
|
||||
(do
|
||||
(st/emit! (dw/select-shape id)
|
||||
(dw/start-editing-selected))
|
||||
;; If using wasm text-editor, notify WASM to start editing this shape
|
||||
;; and set cursor position from the double-click location
|
||||
(when (and (= type :text)
|
||||
(features/active-feature? @st/state "render-wasm/v1")
|
||||
wasm.wasm/context-initialized?)
|
||||
(let [raw-pt (dom/get-client-position event)]
|
||||
(wasm.api/text-editor-start id)
|
||||
(wasm.api/text-editor-set-cursor-from-point (.-x raw-pt) (.-y raw-pt)))))
|
||||
|
||||
(some? selected-shape)
|
||||
(do
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
[app.main.ui.workspace.viewport.utils :as utils]
|
||||
[app.main.worker :as mw]
|
||||
[app.render-wasm.api :as wasm.api]
|
||||
[app.render-wasm.wasm :as wasm.wasm]
|
||||
[app.util.debug :as dbg]
|
||||
[app.util.dom :as dom]
|
||||
[app.util.globals :as globals]
|
||||
@@ -165,6 +166,55 @@
|
||||
(when-not ^boolean value
|
||||
(reset! z* false))))
|
||||
|
||||
;; Route keyboard events to WASM text-editor when it's active
|
||||
(hooks/use-stream ms/keyboard
|
||||
(fn [kevent]
|
||||
(when (and (features/active-feature? @st/state "render-wasm/v1")
|
||||
wasm.wasm/context-initialized?
|
||||
(wasm.api/text-editor-is-active?))
|
||||
(let [key (:key kevent)
|
||||
t (:type kevent)
|
||||
native (:native-event kevent)
|
||||
ctrl? (kbd/mod? native)
|
||||
shift? (kbd/shift? native)]
|
||||
(when (= t :down)
|
||||
;; Prevent default to stop other handlers from processing
|
||||
(dom/prevent-default native)
|
||||
(dom/stop-propagation native)
|
||||
(let [handled?
|
||||
(cond
|
||||
;; Escape to exit text editing
|
||||
(kbd/esc? kevent)
|
||||
(do (wasm.api/text-editor-stop) true)
|
||||
;; Ctrl+A for select all
|
||||
(and ctrl? (= key "a"))
|
||||
(do (wasm.api/text-editor-select-all) true)
|
||||
(kbd/backspace? kevent)
|
||||
(do (wasm.api/text-editor-delete-backward) true)
|
||||
(kbd/delete? kevent)
|
||||
(do (wasm.api/text-editor-delete-forward) true)
|
||||
(kbd/enter? kevent)
|
||||
(do (wasm.api/text-editor-insert-paragraph) true)
|
||||
(kbd/left-arrow? kevent)
|
||||
(do (wasm.api/text-editor-move-cursor 0 shift?) true)
|
||||
(kbd/right-arrow? kevent)
|
||||
(do (wasm.api/text-editor-move-cursor 1 shift?) true)
|
||||
(kbd/up-arrow? kevent)
|
||||
(do (wasm.api/text-editor-move-cursor 2 shift?) true)
|
||||
(kbd/down-arrow? kevent)
|
||||
(do (wasm.api/text-editor-move-cursor 3 shift?) true)
|
||||
(kbd/home? kevent)
|
||||
(do (wasm.api/text-editor-move-cursor 4 shift?) true)
|
||||
(= key "End")
|
||||
(do (wasm.api/text-editor-move-cursor 5 shift?) true)
|
||||
;; printable character insertion
|
||||
(and (string? key) (= (count key) 1) (not ctrl?))
|
||||
(do (wasm.api/text-editor-insert-text key) true)
|
||||
:else false)]
|
||||
;; Request immediate render after any text editing action
|
||||
(when handled?
|
||||
(wasm.api/request-render "text-editor-keystroke"))))))))
|
||||
|
||||
(hooks/use-stream kbd-zoom-s
|
||||
(fn [kevent]
|
||||
(dom/prevent-default kevent)
|
||||
|
||||
@@ -452,7 +452,9 @@
|
||||
:height (max 0 (- (:height vbox) rule-area-size))}]]]
|
||||
|
||||
[:g {:style {:pointer-events (if disable-events? "none" "auto")}}
|
||||
(when show-text-editor?
|
||||
;; When render-wasm/v1 is active, text editing is handled by WASM - no DOM editor needed
|
||||
(when (and show-text-editor?
|
||||
(not (features/active-feature? @st/state "render-wasm/v1")))
|
||||
(if (features/active-feature? @st/state "text-editor/v2")
|
||||
[:& editor-v2/text-editor {:shape editing-shape
|
||||
:canvas-ref canvas-ref
|
||||
|
||||
@@ -109,11 +109,28 @@
|
||||
(mf/element object-svg #js {:shape shape})
|
||||
(rds/renderToStaticMarkup)))
|
||||
|
||||
;; forward declare editor helpers so render can call them
|
||||
(declare text-editor-update-blink text-editor-render-overlay text-editor-poll-event request-render)
|
||||
|
||||
;; This should never be called from the outside.
|
||||
(defn- render
|
||||
[timestamp]
|
||||
(when (and wasm/context-initialized? (not @wasm/context-lost?))
|
||||
(h/call wasm/internal-module "_render" timestamp)
|
||||
|
||||
;; Update text editor blink (so cursor toggles) using the same timestamp
|
||||
(try
|
||||
(when wasm/context-initialized?
|
||||
(text-editor-update-blink timestamp)
|
||||
;; Render text editor overlay on top of main canvas
|
||||
(text-editor-render-overlay)
|
||||
;; Poll for editor events; if any event occurs, trigger a re-render
|
||||
(let [ev (text-editor-poll-event)]
|
||||
(when (and ev (not= ev 0))
|
||||
(request-render "text-editor-event"))))
|
||||
(catch :default e
|
||||
(js/console.error "text-editor overlay/update failed:" e)))
|
||||
|
||||
(set! wasm/internal-frame-id nil)
|
||||
(ug/dispatch! (ug/event "penpot:wasm:render"))))
|
||||
|
||||
@@ -187,6 +204,128 @@
|
||||
|
||||
(declare get-text-dimensions)
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Text editor WASM bindings
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defn text-editor-start
|
||||
"Activate the WASM text editor for a shape id (uuid). Returns boolean-like result."
|
||||
[id]
|
||||
(when wasm/context-initialized?
|
||||
(let [buffer (uuid/get-u32 id)]
|
||||
(h/call wasm/internal-module "_text_editor_start"
|
||||
(aget buffer 0)
|
||||
(aget buffer 1)
|
||||
(aget buffer 2)
|
||||
(aget buffer 3)))))
|
||||
|
||||
(defn text-editor-set-cursor-from-point
|
||||
"Tell WASM to set caret from screen point (client coords)."
|
||||
[x y]
|
||||
(when wasm/context-initialized?
|
||||
(h/call wasm/internal-module "_text_editor_set_cursor_from_point" x y)))
|
||||
|
||||
(defn text-editor-update-blink
|
||||
"Update editor blink state (timestamp_ms float)."
|
||||
[timestamp-ms]
|
||||
(when wasm/context-initialized?
|
||||
(h/call wasm/internal-module "_text_editor_update_blink" timestamp-ms)))
|
||||
|
||||
(defn text-editor-render-overlay
|
||||
"Render editor overlay (cursor & selection) using WASM internal render path."
|
||||
[]
|
||||
(when wasm/context-initialized?
|
||||
(h/call wasm/internal-module "_text_editor_render_overlay")))
|
||||
|
||||
(defn text-editor-poll-event
|
||||
"Poll for pending editor events. Returns integer code (0=none,1=content_changed,2=selection_changed,3=needs_layout)."
|
||||
[]
|
||||
(when wasm/context-initialized?
|
||||
(let [res (h/call wasm/internal-module "_text_editor_poll_event")]
|
||||
res)))
|
||||
|
||||
(defn text-editor-insert-text
|
||||
"Insert UTF-8 text bytes into editor (writes to wasm memory and calls wasm)."
|
||||
[text]
|
||||
(when wasm/context-initialized?
|
||||
(let [encoder (js/TextEncoder.)
|
||||
buf (.encode encoder text)
|
||||
heapu8 (mem/get-heap-u8)
|
||||
size (mem/size buf)
|
||||
offset (mem/alloc size)]
|
||||
(mem/write-buffer offset heapu8 buf)
|
||||
(h/call wasm/internal-module "_text_editor_insert_text")
|
||||
(mem/free))))
|
||||
|
||||
(defn text-editor-delete-backward []
|
||||
(when wasm/context-initialized?
|
||||
(h/call wasm/internal-module "_text_editor_delete_backward")))
|
||||
|
||||
(defn text-editor-delete-forward []
|
||||
(when wasm/context-initialized?
|
||||
(h/call wasm/internal-module "_text_editor_delete_forward")))
|
||||
|
||||
(defn text-editor-insert-paragraph []
|
||||
(when wasm/context-initialized?
|
||||
(h/call wasm/internal-module "_text_editor_insert_paragraph")))
|
||||
|
||||
(defn text-editor-move-cursor
|
||||
"Move cursor in direction (u8) and extend-selection bool.
|
||||
direction: 0=Left,1=Right,2=Up,3=Down,4=LineStart,5=LineEnd"
|
||||
[direction extend-selection]
|
||||
(when wasm/context-initialized?
|
||||
(h/call wasm/internal-module "_text_editor_move_cursor" direction (if extend-selection 1 0))))
|
||||
|
||||
(defn text-editor-select-all
|
||||
"Select all text in the current text shape."
|
||||
[]
|
||||
(when wasm/context-initialized?
|
||||
(h/call wasm/internal-module "_text_editor_select_all")))
|
||||
|
||||
(defn text-editor-stop
|
||||
"Stop the text editor and deactivate it."
|
||||
[]
|
||||
(when wasm/context-initialized?
|
||||
(h/call wasm/internal-module "_text_editor_stop")))
|
||||
|
||||
(defn text-editor-is-active?
|
||||
"Check if text editor is currently active."
|
||||
[]
|
||||
(when wasm/context-initialized?
|
||||
(not (zero? (h/call wasm/internal-module "_text_editor_is_active")))))
|
||||
|
||||
(defn text-editor-export-content
|
||||
"Export text content from WASM as JSON array of paragraphs with span texts.
|
||||
Returns a vector of vectors: [[\"span1\" \"span2\"] [\"para2span1\"]]"
|
||||
[]
|
||||
(when wasm/context-initialized?
|
||||
(let [len (h/call wasm/internal-module "_text_editor_export_content")]
|
||||
(when (pos? len)
|
||||
(let [json-str (mem/read-string len)]
|
||||
(mem/free-bytes)
|
||||
(js/JSON.parse json-str))))))
|
||||
|
||||
(defn text-editor-get-active-shape-id
|
||||
"Get the ID of the shape currently being edited."
|
||||
[]
|
||||
(when wasm/context-initialized?
|
||||
(let [buffer (js/Uint32Array. 4)]
|
||||
(h/call wasm/internal-module "_text_editor_get_active_shape_id"
|
||||
(.-byteOffset buffer))
|
||||
(uuid/from-u32 buffer))))
|
||||
|
||||
(defn text-editor-sync-content
|
||||
"Sync text content from WASM back to the frontend shape.
|
||||
Updates the shape's :content with the new text strings from WASM.
|
||||
Returns the shape-id and new-texts if sync was successful, nil otherwise."
|
||||
[]
|
||||
(when (and wasm/context-initialized? (text-editor-is-active?))
|
||||
(let [shape-id (text-editor-get-active-shape-id)
|
||||
new-texts (text-editor-export-content)]
|
||||
(when (and shape-id new-texts)
|
||||
{:shape-id shape-id
|
||||
:texts (js->clj new-texts)}))))
|
||||
|
||||
(defn update-text-rect!
|
||||
[id]
|
||||
(when wasm/context-initialized?
|
||||
|
||||
@@ -10,6 +10,7 @@ mod shadows;
|
||||
mod strokes;
|
||||
mod surfaces;
|
||||
pub mod text;
|
||||
pub mod text_editor;
|
||||
mod ui;
|
||||
|
||||
use skia_safe::{self as skia, Matrix, RRect, Rect};
|
||||
|
||||
243
render-wasm/src/render/text_editor.rs
Normal file
243
render-wasm/src/render/text_editor.rs
Normal file
@@ -0,0 +1,243 @@
|
||||
//! Rendering for the integrated text editor.
|
||||
//!
|
||||
//! This module handles rendering cursor (caret) and selection highlighting
|
||||
//! as overlays on top of text shapes being edited.
|
||||
|
||||
use skia_safe::{self as skia, Canvas, Color, Matrix, Paint, Rect};
|
||||
|
||||
use crate::shapes::{Shape, TextContent, Type};
|
||||
use crate::state::{TextCursor, TextEditorState, TextSelection};
|
||||
|
||||
/// Default cursor width in pixels
|
||||
const CURSOR_WIDTH: f32 = 1.5;
|
||||
|
||||
/// Selection highlight color (semi-transparent blue)
|
||||
const SELECTION_COLOR: Color = Color::from_argb(80, 66, 133, 244);
|
||||
|
||||
/// Cursor color (black)
|
||||
const CURSOR_COLOR: Color = Color::BLACK;
|
||||
|
||||
/// Render the text editor overlay (cursor and selection) for the active shape.
|
||||
pub fn render_overlay(
|
||||
canvas: &Canvas,
|
||||
editor_state: &TextEditorState,
|
||||
shape: &Shape,
|
||||
transform: &Matrix,
|
||||
) {
|
||||
if !editor_state.is_active {
|
||||
return;
|
||||
}
|
||||
|
||||
let Type::Text(text_content) = &shape.shape_type else {
|
||||
return;
|
||||
};
|
||||
|
||||
canvas.save();
|
||||
canvas.concat(transform);
|
||||
|
||||
// Render selection first (behind cursor)
|
||||
if editor_state.selection.is_selection() {
|
||||
render_selection(canvas, &editor_state.selection, text_content, shape);
|
||||
}
|
||||
|
||||
// Render cursor if visible (blinking)
|
||||
if editor_state.cursor_visible {
|
||||
render_cursor(canvas, &editor_state.selection.focus, text_content, shape);
|
||||
}
|
||||
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
/// Render the cursor (caret) at the focus position.
|
||||
fn render_cursor(canvas: &Canvas, cursor: &TextCursor, text_content: &TextContent, shape: &Shape) {
|
||||
let Some(rect) = calculate_cursor_rect(cursor, text_content, shape) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut paint = Paint::default();
|
||||
paint.set_color(CURSOR_COLOR);
|
||||
paint.set_anti_alias(true);
|
||||
|
||||
canvas.draw_rect(rect, &paint);
|
||||
}
|
||||
|
||||
/// Render selection highlighting for the selected text range.
|
||||
fn render_selection(
|
||||
canvas: &Canvas,
|
||||
selection: &TextSelection,
|
||||
text_content: &TextContent,
|
||||
shape: &Shape,
|
||||
) {
|
||||
let rects = calculate_selection_rects(selection, text_content, shape);
|
||||
|
||||
if rects.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut paint = Paint::default();
|
||||
paint.set_color(SELECTION_COLOR);
|
||||
paint.set_anti_alias(true);
|
||||
|
||||
for rect in rects {
|
||||
canvas.draw_rect(rect, &paint);
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate the cursor rectangle for a given cursor position.
|
||||
fn calculate_cursor_rect(
|
||||
cursor: &TextCursor,
|
||||
text_content: &TextContent,
|
||||
shape: &Shape,
|
||||
) -> Option<Rect> {
|
||||
let paragraphs = text_content.paragraphs();
|
||||
if cursor.paragraph >= paragraphs.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Get laid out paragraphs from layout cache
|
||||
let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect();
|
||||
|
||||
if cursor.paragraph >= layout_paragraphs.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let selrect = shape.selrect();
|
||||
|
||||
// Calculate y offset to the target paragraph
|
||||
let mut y_offset = 0.0;
|
||||
for (idx, laid_out_para) in layout_paragraphs.iter().enumerate() {
|
||||
if idx == cursor.paragraph {
|
||||
let char_pos = cursor.char_offset;
|
||||
|
||||
// Use Skia's get_rects_for_range to get cursor position
|
||||
use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
|
||||
|
||||
// For cursor, we get a zero-width range at the position
|
||||
// We need to handle edge cases:
|
||||
// - At start of paragraph: use position 0
|
||||
// - At end of paragraph: use last position
|
||||
let para = ¶graphs[cursor.paragraph];
|
||||
let para_char_count: usize = para.children().iter().map(|span| span.text.len()).sum();
|
||||
|
||||
let (cursor_x, cursor_height) = if para_char_count == 0 {
|
||||
// Empty paragraph - use default height
|
||||
(0.0, laid_out_para.height())
|
||||
} else if char_pos == 0 {
|
||||
// At start - get rect for first character
|
||||
let rects = laid_out_para.get_rects_for_range(
|
||||
0..1,
|
||||
RectHeightStyle::Tight,
|
||||
RectWidthStyle::Tight,
|
||||
);
|
||||
if !rects.is_empty() {
|
||||
(rects[0].rect.left(), rects[0].rect.height())
|
||||
} else {
|
||||
(0.0, laid_out_para.height())
|
||||
}
|
||||
} else if char_pos >= para_char_count {
|
||||
// At end - get rect for last character and use right edge
|
||||
let rects = laid_out_para.get_rects_for_range(
|
||||
para_char_count.saturating_sub(1)..para_char_count,
|
||||
RectHeightStyle::Tight,
|
||||
RectWidthStyle::Tight,
|
||||
);
|
||||
if !rects.is_empty() {
|
||||
(rects[0].rect.right(), rects[0].rect.height())
|
||||
} else {
|
||||
(laid_out_para.longest_line(), laid_out_para.height())
|
||||
}
|
||||
} else {
|
||||
// In middle - get rect at position
|
||||
let rects = laid_out_para.get_rects_for_range(
|
||||
char_pos..char_pos + 1,
|
||||
RectHeightStyle::Tight,
|
||||
RectWidthStyle::Tight,
|
||||
);
|
||||
if !rects.is_empty() {
|
||||
(rects[0].rect.left(), rects[0].rect.height())
|
||||
} else {
|
||||
// Fallback: use glyph position
|
||||
let pos = laid_out_para.get_glyph_position_at_coordinate((0.0, 0.0));
|
||||
(pos.position as f32, laid_out_para.height())
|
||||
}
|
||||
};
|
||||
|
||||
return Some(Rect::from_xywh(
|
||||
selrect.x() + cursor_x,
|
||||
selrect.y() + y_offset,
|
||||
CURSOR_WIDTH,
|
||||
cursor_height,
|
||||
));
|
||||
}
|
||||
y_offset += laid_out_para.height();
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Calculate selection rectangles for a given selection.
|
||||
fn calculate_selection_rects(
|
||||
selection: &TextSelection,
|
||||
text_content: &TextContent,
|
||||
shape: &Shape,
|
||||
) -> Vec<Rect> {
|
||||
let mut rects = Vec::new();
|
||||
|
||||
let start = selection.start();
|
||||
let end = selection.end();
|
||||
|
||||
let paragraphs = text_content.paragraphs();
|
||||
let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect();
|
||||
|
||||
let selrect = shape.selrect();
|
||||
let mut y_offset = 0.0;
|
||||
|
||||
for (para_idx, laid_out_para) in layout_paragraphs.iter().enumerate() {
|
||||
let para_height = laid_out_para.height();
|
||||
|
||||
// Check if this paragraph is in selection range
|
||||
if para_idx < start.paragraph || para_idx > end.paragraph {
|
||||
y_offset += para_height;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate character range for this paragraph
|
||||
let para = ¶graphs[para_idx];
|
||||
let para_char_count: usize = para.children().iter().map(|span| span.text.len()).sum();
|
||||
|
||||
let range_start = if para_idx == start.paragraph {
|
||||
start.char_offset
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let range_end = if para_idx == end.paragraph {
|
||||
end.char_offset
|
||||
} else {
|
||||
para_char_count
|
||||
};
|
||||
|
||||
if range_start < range_end {
|
||||
use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
|
||||
let text_boxes = laid_out_para.get_rects_for_range(
|
||||
range_start..range_end,
|
||||
RectHeightStyle::Tight,
|
||||
RectWidthStyle::Tight,
|
||||
);
|
||||
|
||||
for text_box in text_boxes {
|
||||
let r = text_box.rect;
|
||||
rects.push(Rect::from_xywh(
|
||||
selrect.x() + r.left(),
|
||||
selrect.y() + y_offset + r.top(),
|
||||
r.width(),
|
||||
r.height(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
y_offset += para_height;
|
||||
}
|
||||
|
||||
rects
|
||||
}
|
||||
@@ -316,6 +316,10 @@ impl TextContent {
|
||||
&self.paragraphs
|
||||
}
|
||||
|
||||
pub fn paragraphs_mut(&mut self) -> &mut Vec<Paragraph> {
|
||||
&mut self.paragraphs
|
||||
}
|
||||
|
||||
pub fn width(&self) -> f32 {
|
||||
self.size.width
|
||||
}
|
||||
@@ -838,6 +842,10 @@ impl Paragraph {
|
||||
&self.children
|
||||
}
|
||||
|
||||
pub fn children_mut(&mut self) -> &mut Vec<TextSpan> {
|
||||
&mut self.children
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn add_span(&mut self, span: TextSpan) {
|
||||
self.children.push(span);
|
||||
@@ -847,6 +855,26 @@ impl Paragraph {
|
||||
self.line_height
|
||||
}
|
||||
|
||||
pub fn letter_spacing(&self) -> f32 {
|
||||
self.letter_spacing
|
||||
}
|
||||
|
||||
pub fn text_align(&self) -> TextAlign {
|
||||
self.text_align
|
||||
}
|
||||
|
||||
pub fn text_direction(&self) -> TextDirection {
|
||||
self.text_direction
|
||||
}
|
||||
|
||||
pub fn text_decoration(&self) -> Option<TextDecoration> {
|
||||
self.text_decoration
|
||||
}
|
||||
|
||||
pub fn text_transform(&self) -> Option<TextTransform> {
|
||||
self.text_transform
|
||||
}
|
||||
|
||||
pub fn paragraph_to_style(&self) -> ParagraphStyle {
|
||||
let mut style = ParagraphStyle::default();
|
||||
|
||||
|
||||
@@ -1,9 +1,251 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use crate::shapes::TextPositionWithAffinity;
|
||||
use crate::uuid::Uuid;
|
||||
|
||||
/// TODO: Now this is just a tuple with 2 i32 working
|
||||
/// as indices (paragraph and span).
|
||||
/// Cursor position within text content.
|
||||
/// Uses character offsets for precise positioning.
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Copy, Default)]
|
||||
pub struct TextCursor {
|
||||
/// Index of the paragraph (0-based)
|
||||
pub paragraph: usize,
|
||||
/// Character offset within the paragraph (0-based, counting from paragraph start)
|
||||
pub char_offset: usize,
|
||||
}
|
||||
|
||||
impl TextCursor {
|
||||
pub fn new(paragraph: usize, char_offset: usize) -> Self {
|
||||
Self {
|
||||
paragraph,
|
||||
char_offset,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn zero() -> Self {
|
||||
Self {
|
||||
paragraph: 0,
|
||||
char_offset: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a text selection with anchor (start) and focus (end) cursors.
|
||||
/// When anchor == focus, it's a caret (collapsed selection).
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct TextSelection {
|
||||
/// The fixed end of the selection (where selection started)
|
||||
pub anchor: TextCursor,
|
||||
/// The moving end of the selection (where cursor displays)
|
||||
pub focus: TextCursor,
|
||||
}
|
||||
|
||||
impl TextSelection {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn from_cursor(cursor: TextCursor) -> Self {
|
||||
Self {
|
||||
anchor: cursor,
|
||||
focus: cursor,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if the selection is collapsed (just a caret)
|
||||
pub fn is_collapsed(&self) -> bool {
|
||||
self.anchor == self.focus
|
||||
}
|
||||
|
||||
/// Returns true if there's an actual selection (not collapsed)
|
||||
pub fn is_selection(&self) -> bool {
|
||||
!self.is_collapsed()
|
||||
}
|
||||
|
||||
/// Set both anchor and focus to the same position (caret)
|
||||
pub fn set_caret(&mut self, cursor: TextCursor) {
|
||||
self.anchor = cursor;
|
||||
self.focus = cursor;
|
||||
}
|
||||
|
||||
/// Move focus while keeping anchor fixed (extend selection)
|
||||
pub fn extend_to(&mut self, cursor: TextCursor) {
|
||||
self.focus = cursor;
|
||||
}
|
||||
|
||||
/// Collapse selection to the focus position
|
||||
pub fn collapse_to_focus(&mut self) {
|
||||
self.anchor = self.focus;
|
||||
}
|
||||
|
||||
/// Collapse selection to the anchor position
|
||||
pub fn collapse_to_anchor(&mut self) {
|
||||
self.focus = self.anchor;
|
||||
}
|
||||
|
||||
/// Get the start cursor (leftmost/topmost position)
|
||||
pub fn start(&self) -> TextCursor {
|
||||
if self.anchor.paragraph < self.focus.paragraph {
|
||||
self.anchor
|
||||
} else if self.anchor.paragraph > self.focus.paragraph {
|
||||
self.focus
|
||||
} else if self.anchor.char_offset <= self.focus.char_offset {
|
||||
self.anchor
|
||||
} else {
|
||||
self.focus
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the end cursor (rightmost/bottommost position)
|
||||
pub fn end(&self) -> TextCursor {
|
||||
if self.anchor.paragraph > self.focus.paragraph {
|
||||
self.anchor
|
||||
} else if self.anchor.paragraph < self.focus.paragraph {
|
||||
self.focus
|
||||
} else if self.anchor.char_offset >= self.focus.char_offset {
|
||||
self.anchor
|
||||
} else {
|
||||
self.focus
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Events that the text editor can emit for frontend synchronization
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum EditorEvent {
|
||||
None = 0,
|
||||
ContentChanged = 1,
|
||||
SelectionChanged = 2,
|
||||
NeedsLayout = 3,
|
||||
}
|
||||
|
||||
/// Main text editor state
|
||||
pub struct TextEditorState {
|
||||
/// Current text selection (includes cursor position)
|
||||
pub selection: TextSelection,
|
||||
/// Whether the editor is currently active/focused
|
||||
pub is_active: bool,
|
||||
/// The shape being edited (if any)
|
||||
pub active_shape_id: Option<Uuid>,
|
||||
/// Whether the cursor should be visible (for blinking)
|
||||
pub cursor_visible: bool,
|
||||
/// Timestamp of last blink toggle (milliseconds)
|
||||
pub last_blink_time: f64,
|
||||
/// Preferred x position for vertical cursor movement
|
||||
pub x_affinity: Option<f32>,
|
||||
/// Queue of pending events for frontend
|
||||
pending_events: Vec<EditorEvent>,
|
||||
}
|
||||
|
||||
// Blink interval in milliseconds
|
||||
const CURSOR_BLINK_INTERVAL_MS: f64 = 530.0;
|
||||
|
||||
impl TextEditorState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
selection: TextSelection::new(),
|
||||
is_active: false,
|
||||
active_shape_id: None,
|
||||
cursor_visible: true,
|
||||
last_blink_time: 0.0,
|
||||
x_affinity: None,
|
||||
pending_events: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Start editing a text shape
|
||||
pub fn start(&mut self, shape_id: Uuid) {
|
||||
self.is_active = true;
|
||||
self.active_shape_id = Some(shape_id);
|
||||
self.cursor_visible = true;
|
||||
self.last_blink_time = 0.0;
|
||||
self.selection = TextSelection::new();
|
||||
self.x_affinity = None;
|
||||
self.pending_events.clear();
|
||||
}
|
||||
|
||||
/// Stop editing
|
||||
pub fn stop(&mut self) {
|
||||
self.is_active = false;
|
||||
self.active_shape_id = None;
|
||||
self.cursor_visible = false;
|
||||
self.x_affinity = None;
|
||||
self.pending_events.clear();
|
||||
}
|
||||
|
||||
/// Set caret position from hit test result
|
||||
pub fn set_caret_from_position(&mut self, position: TextPositionWithAffinity) {
|
||||
let cursor = TextCursor::new(position.paragraph as usize, position.offset as usize);
|
||||
self.selection.set_caret(cursor);
|
||||
self.reset_blink();
|
||||
self.clear_x_affinity();
|
||||
self.push_event(EditorEvent::SelectionChanged);
|
||||
}
|
||||
|
||||
/// Extend selection from hit test result
|
||||
pub fn extend_selection_from_position(&mut self, position: TextPositionWithAffinity) {
|
||||
let cursor = TextCursor::new(position.paragraph as usize, position.offset as usize);
|
||||
self.selection.extend_to(cursor);
|
||||
self.reset_blink();
|
||||
self.push_event(EditorEvent::SelectionChanged);
|
||||
}
|
||||
|
||||
/// Update cursor blink state
|
||||
pub fn update_blink(&mut self, timestamp_ms: f64) {
|
||||
if !self.is_active {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.last_blink_time == 0.0 {
|
||||
self.last_blink_time = timestamp_ms;
|
||||
self.cursor_visible = true;
|
||||
return;
|
||||
}
|
||||
|
||||
let elapsed = timestamp_ms - self.last_blink_time;
|
||||
if elapsed >= CURSOR_BLINK_INTERVAL_MS {
|
||||
self.cursor_visible = !self.cursor_visible;
|
||||
self.last_blink_time = timestamp_ms;
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset blink to visible (called on user action)
|
||||
pub fn reset_blink(&mut self) {
|
||||
self.cursor_visible = true;
|
||||
self.last_blink_time = 0.0;
|
||||
}
|
||||
|
||||
/// Clear x affinity (called when horizontal movement occurs)
|
||||
pub fn clear_x_affinity(&mut self) {
|
||||
self.x_affinity = None;
|
||||
}
|
||||
|
||||
/// Push an event to the queue
|
||||
pub fn push_event(&mut self, event: EditorEvent) {
|
||||
// Avoid duplicate consecutive events
|
||||
if self.pending_events.last() != Some(&event) {
|
||||
self.pending_events.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll and remove the next pending event
|
||||
pub fn poll_event(&mut self) -> EditorEvent {
|
||||
self.pending_events.pop().unwrap_or(EditorEvent::None)
|
||||
}
|
||||
|
||||
/// Check if there are pending events
|
||||
pub fn has_pending_events(&self) -> bool {
|
||||
!self.pending_events.is_empty()
|
||||
}
|
||||
|
||||
// Legacy compatibility method
|
||||
pub fn set_caret_position_from(&mut self, text_position_with_affinity: TextPositionWithAffinity) {
|
||||
self.set_caret_from_position(text_position_with_affinity);
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the old types for backward compatibility during migration
|
||||
/// Legacy type - use TextCursor instead
|
||||
#[derive(Debug, PartialEq, Clone, Copy)]
|
||||
pub struct TextNodePosition {
|
||||
pub paragraph: i32,
|
||||
@@ -15,89 +257,7 @@ impl TextNodePosition {
|
||||
Self { paragraph, span }
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn is_invalid(&self) -> bool {
|
||||
self.paragraph < 0 || self.span < 0
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TextPosition {
|
||||
node: Option<TextNodePosition>,
|
||||
offset: i32,
|
||||
}
|
||||
|
||||
impl TextPosition {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
node: None,
|
||||
offset: -1,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set(&mut self, node: Option<TextNodePosition>, offset: i32) {
|
||||
self.node = node;
|
||||
self.offset = offset;
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TextSelection {
|
||||
focus: TextPosition,
|
||||
anchor: TextPosition,
|
||||
}
|
||||
|
||||
impl TextSelection {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
focus: TextPosition::new(),
|
||||
anchor: TextPosition::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn is_caret(&self) -> bool {
|
||||
self.focus.node == self.anchor.node && self.focus.offset == self.anchor.offset
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn is_selection(&self) -> bool {
|
||||
!self.is_caret()
|
||||
}
|
||||
|
||||
pub fn set_focus(&mut self, node: Option<TextNodePosition>, offset: i32) {
|
||||
self.focus.set(node, offset);
|
||||
}
|
||||
|
||||
pub fn set_anchor(&mut self, node: Option<TextNodePosition>, offset: i32) {
|
||||
self.anchor.set(node, offset);
|
||||
}
|
||||
|
||||
pub fn set(&mut self, node: Option<TextNodePosition>, offset: i32) {
|
||||
self.set_focus(node, offset);
|
||||
self.set_anchor(node, offset);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TextEditorState {
|
||||
selection: TextSelection,
|
||||
}
|
||||
|
||||
impl TextEditorState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
selection: TextSelection::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_caret_position_from(
|
||||
&mut self,
|
||||
text_position_with_affinity: TextPositionWithAffinity,
|
||||
) {
|
||||
self.selection.set(
|
||||
Some(TextNodePosition::new(
|
||||
text_position_with_affinity.paragraph,
|
||||
text_position_with_affinity.span,
|
||||
)),
|
||||
text_position_with_affinity.offset,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,11 @@ pub fn uuid_from_u32(id: [u32; 4]) -> Uuid {
|
||||
uuid_from_u32_quartet(id[0], id[1], id[2], id[3])
|
||||
}
|
||||
|
||||
// pub fn uuid_to_u32(id: &Uuid) -> [u32; 4] {
|
||||
// let (a, b, c, d) = uuid_to_u32_quartet(id);
|
||||
// [a, b, c, d]
|
||||
// }
|
||||
|
||||
pub fn get_image(image_id: &Uuid) -> Option<&Image> {
|
||||
with_state_mut!(state, { state.render_state_mut().images.get(image_id) })
|
||||
}
|
||||
|
||||
@@ -9,3 +9,4 @@ pub mod shapes;
|
||||
pub mod strokes;
|
||||
pub mod svg_attrs;
|
||||
pub mod text;
|
||||
pub mod text_editor;
|
||||
|
||||
1235
render-wasm/src/wasm/text_editor.rs
Normal file
1235
render-wasm/src/wasm/text_editor.rs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user