mirror of
https://github.com/penpot/penpot.git
synced 2026-10-02 16:56:16 +00:00
fixes selection
This commit is contained in:
parent
f62144ec67
commit
21d6050b7f
@ -12,6 +12,7 @@
|
||||
[app.common.types.text :as txt]
|
||||
[app.common.types.text.japanese-layout :as jl]
|
||||
[app.main.data.helpers :as dsh]
|
||||
[app.main.data.workspace.edition :as dwe]
|
||||
[app.main.data.workspace.texts :as dwt]
|
||||
[app.main.refs :as refs]
|
||||
[app.main.store :as st]
|
||||
@ -195,12 +196,14 @@
|
||||
ctrl? (or (.-ctrlKey event) (.-metaKey event))
|
||||
shift? (.-shiftKey event)]
|
||||
(cond
|
||||
;; Escape: finalize and stop
|
||||
;; Escape: end edition. Clearing the edition mode unmounts this
|
||||
;; component, whose unmount effect finalizes and disposes the
|
||||
;; WASM editor. We must not rely on blurring the capture node,
|
||||
;; because on-blur no longer finalizes or blurs the WASM editor.
|
||||
(= key "Escape")
|
||||
(do
|
||||
(dom/prevent-default event)
|
||||
(when-let [node (mf/ref-val contenteditable-ref)]
|
||||
(.blur node)))
|
||||
(st/emit! :interrupt (dwe/clear-edition-mode)))
|
||||
|
||||
;; Ctrl+A: select all (key is "a" or "A" depending on platform)
|
||||
(and ctrl? (= (str/lower key) "a"))
|
||||
@ -295,40 +298,49 @@
|
||||
(wasm.api/request-render "text-input"))
|
||||
(reset-input-node (mf/ref-val contenteditable-ref))))))
|
||||
|
||||
;; Pointer/click handlers must request a render so caret and selection
|
||||
;; updates are painted immediately. Without it the overlay only redraws
|
||||
;; on the caret-blink tick (every ~250ms), so dragging a selection or
|
||||
;; clicking to place the caret lags and appears to flicker.
|
||||
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)]
|
||||
(wasm.api/text-editor-pointer-down off-pt))))
|
||||
(wasm.api/text-editor-pointer-down off-pt)
|
||||
(wasm.api/request-render "text-pointer-down"))))
|
||||
|
||||
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)]
|
||||
(wasm.api/text-editor-pointer-move off-pt))))
|
||||
(wasm.api/text-editor-pointer-move off-pt)
|
||||
(wasm.api/request-render "text-pointer-move"))))
|
||||
|
||||
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)]
|
||||
(wasm.api/text-editor-pointer-up off-pt))))
|
||||
(wasm.api/text-editor-pointer-up off-pt)
|
||||
(wasm.api/request-render "text-pointer-up"))))
|
||||
|
||||
on-click
|
||||
(mf/use-fn
|
||||
(fn [^js event]
|
||||
(let [native-event (dom/event->native-event event)
|
||||
off-pt (dom/get-offset-position native-event)]
|
||||
(wasm.api/text-editor-set-cursor-from-offset off-pt))))
|
||||
(wasm.api/text-editor-set-cursor-from-offset off-pt)
|
||||
(wasm.api/request-render "text-set-cursor"))))
|
||||
|
||||
on-double-click
|
||||
(mf/use-fn
|
||||
(fn [^js event]
|
||||
(let [native-event (dom/event->native-event event)
|
||||
off-pt (dom/get-offset-position native-event)]
|
||||
(wasm.api/text-editor-select-word-boundary off-pt))))
|
||||
(wasm.api/text-editor-select-word-boundary off-pt)
|
||||
(wasm.api/request-render "text-select-word"))))
|
||||
|
||||
on-focus
|
||||
(mf/use-fn
|
||||
@ -338,8 +350,15 @@
|
||||
on-blur
|
||||
(mf/use-fn
|
||||
(fn [^js _event]
|
||||
(sync-wasm-text-editor-content! {:finalize? true})
|
||||
(wasm.api/text-editor-blur)))
|
||||
;; A blur is not an exit: focusing a sidebar control (e.g. the
|
||||
;; Japanese writing-mode buttons) blurs this capture surface while
|
||||
;; the editor stays open and is refocused shortly after. Only sync
|
||||
;; the current content here; do not finalize and do not blur the WASM
|
||||
;; editor. Finalizing on every blur commits a transient empty text and
|
||||
;; deletes the shape (closing the editor). The real finalize runs on
|
||||
;; unmount, when edition actually ends, and needs the WASM editor to
|
||||
;; still hold focus so it can read the content back.
|
||||
(sync-wasm-text-editor-content!)))
|
||||
|
||||
style #js {:pointerEvents "all"
|
||||
"--editor-container-width" (dm/str width "px")
|
||||
@ -355,11 +374,13 @@
|
||||
(.focus node)
|
||||
(text-editor/text-editor-select-all)
|
||||
(wasm.api/request-render "text-editor-select-all-on-mount"))
|
||||
;; On unmount, finalize the editor content and then dispose the WASM editor.
|
||||
;; We finalize on unmount instead of relying on the browser blur event, because
|
||||
;; it was not being reliable (timing issues, Firefox issues…)
|
||||
;; On unmount (edition actually ended), finalize the editor content and
|
||||
;; then blur and dispose the WASM editor. We finalize here instead of on
|
||||
;; the browser blur event, which is both unreliable (timing/Firefox
|
||||
;; issues) and fires for transient blurs like clicking a sidebar control.
|
||||
(fn []
|
||||
(on-blur)
|
||||
(sync-wasm-text-editor-content! {:finalize? true})
|
||||
(wasm.api/text-editor-blur)
|
||||
(text-editor/text-editor-dispose)
|
||||
(wasm.api/request-render "text-editor-dispose"))))
|
||||
|
||||
|
||||
@ -36,8 +36,18 @@
|
||||
[]
|
||||
(dom/query "[data-itype=\"editor\"]"))
|
||||
|
||||
(defn v3-get-text-editor-content
|
||||
[]
|
||||
(dom/get-element "text-editor-wasm-input"))
|
||||
|
||||
(defn get-text-editor-content
|
||||
[]
|
||||
(if (features/active-feature? @st/state "text-editor/v2")
|
||||
(cond
|
||||
(features/active-feature? @st/state "text-editor-wasm/v1")
|
||||
(v3-get-text-editor-content)
|
||||
|
||||
(features/active-feature? @st/state "text-editor/v2")
|
||||
(v2-get-text-editor-content)
|
||||
|
||||
:else
|
||||
(v1-get-text-editor-content)))
|
||||
|
||||
@ -2078,10 +2078,25 @@ pub fn layout_vertical(
|
||||
} else {
|
||||
span.font_size
|
||||
};
|
||||
// Flow down the column by the true vertical
|
||||
// advance when the font has `vmtx`, else the
|
||||
// horizontal advance (exact for full-width CJK).
|
||||
// The character in this cluster; used to size the
|
||||
// cell and, later, to classify it.
|
||||
let ch = segment.text[(cluster as usize)..].chars().next();
|
||||
// Flow down the column by the true vertical advance
|
||||
// when the font has `vmtx`. Without it, fall back to
|
||||
// the horizontal advance, which is exact for
|
||||
// full-width CJK. A character that is upright only
|
||||
// because `text-orientation: upright` forced it
|
||||
// (e.g. a Latin letter) is far narrower than an em
|
||||
// and would pack together and collide with the
|
||||
// following character, so floor it to a full em,
|
||||
// matching the browser's upright behavior.
|
||||
// Letter-spacing widens the gap after each cluster.
|
||||
let horizontal_fallback = if ch.is_some_and(|c| !is_upright_char(c))
|
||||
{
|
||||
h_advance.max(span.font_size)
|
||||
} else {
|
||||
h_advance
|
||||
};
|
||||
let extent = vmetrics
|
||||
.as_ref()
|
||||
.map(|vm| {
|
||||
@ -2091,7 +2106,7 @@ pub fn layout_vertical(
|
||||
.sum::<f32>()
|
||||
})
|
||||
.filter(|v| *v > 0.0)
|
||||
.unwrap_or(h_advance);
|
||||
.unwrap_or(horizontal_fallback);
|
||||
// vpal: the font's advance delta tightens the
|
||||
// cell and its placement delta lifts the drawn
|
||||
// ink to keep it inside. The tightened extent
|
||||
@ -2110,7 +2125,6 @@ pub fn layout_vertical(
|
||||
vpal_delta.map(|(_, shift)| shift).unwrap_or(0.0);
|
||||
let start = piece_base + utf8_to_utf16(cluster as usize);
|
||||
let end = piece_base + utf8_to_utf16(next_cluster_utf8);
|
||||
let ch = segment.text[(cluster as usize)..].chars().next();
|
||||
let minimum_oikomi_extent = minimum_oikomi_extent(
|
||||
ch,
|
||||
extent,
|
||||
@ -4383,6 +4397,37 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upright_narrow_latin_reserves_a_full_em_without_vmtx() {
|
||||
// Under text-orientation: upright, Latin letters are set upright. The
|
||||
// Latin test face carries no `vmtx`, so the flow advance must fall back
|
||||
// to a synthesized em instead of the (much narrower) horizontal advance;
|
||||
// otherwise the letters pack together and collide with the following
|
||||
// character.
|
||||
let em = 20.0;
|
||||
let mut content = make_content(&["ab"], 1000.0);
|
||||
content.paragraphs_mut()[0].children_mut()[0].text_orientation = TextOrientation::Upright;
|
||||
let layout = layout_with(&test_provider(), &content);
|
||||
assert_eq!(layout.cells.len(), 2, "one upright cell per Latin letter");
|
||||
for (index, cell) in layout.cells.iter().enumerate() {
|
||||
assert!(
|
||||
matches!(cell.kind, CellKind::Upright { .. }),
|
||||
"cell {index} should be an upright Latin letter"
|
||||
);
|
||||
assert!(
|
||||
(cell.extent - em).abs() < 0.5,
|
||||
"upright Latin cell {index} reserves a full em, got {}",
|
||||
cell.extent
|
||||
);
|
||||
}
|
||||
// The letters advance a full em down the column, so they never overlap.
|
||||
assert!(
|
||||
layout.cells[1].top - layout.cells[0].top >= em - 0.5,
|
||||
"successive upright letters are one em apart, got {}",
|
||||
layout.cells[1].top - layout.cells[0].top
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn segments_mixed_text() {
|
||||
let segments = segment_by_orientation("縦書きABC123です", TextOrientation::Mixed);
|
||||
|
||||
@ -438,9 +438,13 @@ impl TextEditorState {
|
||||
let Some(_last_text_span) = last_paragraph.children().last() else {
|
||||
return false;
|
||||
};
|
||||
// Selection offsets are counted in characters, not UTF-8 bytes;
|
||||
// using byte length overshoots for multi-byte (e.g. Japanese) text and
|
||||
// leaves the trailing characters outside the selection, so a
|
||||
// select-all followed by typing fails to replace them.
|
||||
let mut offset = 0;
|
||||
for span in last_paragraph.children() {
|
||||
offset += span.text.len();
|
||||
offset += span.text.chars().count();
|
||||
}
|
||||
self.extend_selection_from_position(&TextPositionWithAffinity::new(
|
||||
PositionWithAffinity {
|
||||
@ -535,11 +539,16 @@ impl TextEditorState {
|
||||
|
||||
pub fn set_caret_from_position(&mut self, position: &TextPositionWithAffinity) {
|
||||
self.selection.set_caret(*position);
|
||||
// Show the caret immediately at the new position instead of waiting for
|
||||
// the next blink phase (mirrors the keyboard navigation path).
|
||||
self.reset_blink();
|
||||
self.push_event(TextEditorEvent::SelectionChanged);
|
||||
}
|
||||
|
||||
pub fn extend_selection_from_position(&mut self, position: &TextPositionWithAffinity) {
|
||||
self.selection.extend_to(*position);
|
||||
// Keep the focus caret solid while the selection is being dragged.
|
||||
self.reset_blink();
|
||||
self.push_event(TextEditorEvent::SelectionChanged);
|
||||
}
|
||||
|
||||
|
||||
@ -368,11 +368,16 @@ pub extern "C" fn text_editor_composition_end() -> Result<()> {
|
||||
.get_selection(&get_text_editor_state().selection);
|
||||
text_helpers::delete_selection_range(text_content, &selection);
|
||||
|
||||
let cursor = get_text_editor_state().selection.focus;
|
||||
// Insert at the start of the replaced range (the pre-composition
|
||||
// selection or the previous preview), not at a possibly stale
|
||||
// selection focus, and advance the caret past the committed text.
|
||||
let cursor = selection.start();
|
||||
if let Some(new_cursor) =
|
||||
text_helpers::insert_text_with_newlines(text_content, &cursor, &text)
|
||||
{
|
||||
get_text_editor_state().selection.set_caret(new_cursor);
|
||||
} else {
|
||||
get_text_editor_state().selection.set_caret(cursor);
|
||||
}
|
||||
|
||||
text_content.layout.paragraphs.clear();
|
||||
@ -424,7 +429,14 @@ pub extern "C" fn text_editor_composition_update() -> Result<()> {
|
||||
.get_selection(&get_text_editor_state().selection);
|
||||
text_helpers::delete_selection_range(text_content, &selection);
|
||||
|
||||
let cursor = get_text_editor_state().selection.focus;
|
||||
// Collapse to the start of the replaced range before inserting the new
|
||||
// preview. The next update measures the previous preview as
|
||||
// [focus, focus + previous_len], so the caret must stay at the START of
|
||||
// the preview; leaving a stale pre-composition selection focus makes
|
||||
// each update append instead of replace, doubling the text and blocking
|
||||
// further edits.
|
||||
let cursor = selection.start();
|
||||
get_text_editor_state().selection.set_caret(cursor);
|
||||
text_helpers::insert_text_with_newlines(text_content, &cursor, &text);
|
||||
|
||||
text_content.layout.paragraphs.clear();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user