fixes selection

This commit is contained in:
alonso.torres 2026-07-17 15:30:07 +02:00
parent f62144ec67
commit 21d6050b7f
5 changed files with 120 additions and 23 deletions

View File

@ -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"))))

View File

@ -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)))

View File

@ -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);

View File

@ -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);
}

View File

@ -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();