Skip to content

Commit e1fced9

Browse files
authored
fix: Various Delete Function Fixes (#801)
1 parent f4ce039 commit e1fced9

14 files changed

Lines changed: 298 additions & 67 deletions

File tree

crates/koharu-app/src/history.rs

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,68 @@ impl History {
155155
}
156156

157157
fn push_undo(&mut self, op: Op) {
158+
// Try to merge if the incoming op is a pipeline render operation
159+
if let Some(prev) = self.undo_stack.back_mut() {
160+
let should_merge = if let Op::Batch { label, .. } = &op
161+
&& (label.contains("renderer: page ") || label.contains("koharu-renderer: page "))
162+
{
163+
match prev {
164+
Op::Batch {
165+
label: prev_label, ..
166+
} => !prev_label.starts_with("undo:") && !prev_label.contains(": page "),
167+
_ => true,
168+
}
169+
} else {
170+
false
171+
};
172+
173+
if should_merge
174+
&& let Op::Batch {
175+
label: incoming_label,
176+
ops: incoming_ops,
177+
} = op
178+
{
179+
match prev {
180+
Op::Batch { ops: prev_ops, .. } => {
181+
prev_ops.extend(incoming_ops);
182+
}
183+
single_op => {
184+
let prev_op = std::mem::replace(
185+
single_op,
186+
Op::Batch {
187+
ops: vec![],
188+
label: String::new(),
189+
},
190+
);
191+
if let Op::Batch { ops, label } = single_op {
192+
*ops = vec![
193+
prev_op,
194+
Op::Batch {
195+
ops: incoming_ops,
196+
label: incoming_label,
197+
},
198+
];
199+
*label = match &ops[0] {
200+
Op::RemoveNode { .. } => "Delete block".to_string(),
201+
Op::UpdateNode { .. } => "Update block".to_string(),
202+
Op::AddNode { .. } => "Add block".to_string(),
203+
Op::RemovePage { .. } => "Delete page".to_string(),
204+
Op::AddPage { .. } => "Add page".to_string(),
205+
Op::UpdatePage { .. } => "Update page".to_string(),
206+
Op::ReorderPages { .. } => "Reorder pages".to_string(),
207+
Op::ReorderNodes { .. } => "Reorder blocks".to_string(),
208+
Op::UpdateProjectMeta { .. } => {
209+
"Update project metadata".to_string()
210+
}
211+
Op::Batch { label: l, .. } => l.clone(),
212+
};
213+
}
214+
}
215+
}
216+
return;
217+
}
218+
}
219+
158220
self.undo_stack.push_back(op);
159221
while self.undo_stack.len() > self.limit {
160222
self.undo_stack.pop_front();
@@ -219,3 +281,139 @@ pub fn replay(log_path: &Path, start_epoch: u64, scene: &mut Scene) -> Result<u6
219281
let _ = reader.seek(SeekFrom::End(0));
220282
Ok(epoch)
221283
}
284+
285+
#[cfg(test)]
286+
mod tests {
287+
use super::*;
288+
use std::time::{SystemTime, UNIX_EPOCH};
289+
290+
fn make_temp_history() -> (History, std::path::PathBuf) {
291+
let mut temp_path = std::env::temp_dir();
292+
let unique_id = SystemTime::now()
293+
.duration_since(UNIX_EPOCH)
294+
.unwrap()
295+
.as_nanos();
296+
temp_path.push(format!("test_history_{}.log", unique_id));
297+
let history = History::open(&temp_path, 0).unwrap();
298+
(history, temp_path)
299+
}
300+
301+
#[test]
302+
fn test_push_undo_merge_user_edit() {
303+
let (mut history, path) = make_temp_history();
304+
305+
// 1. User action: "Update block"
306+
let user_op = Op::Batch {
307+
ops: vec![],
308+
label: "Update block".to_string(),
309+
};
310+
history.push_undo(user_op);
311+
assert_eq!(history.undo_stack.len(), 1);
312+
313+
// 2. Renderer operation
314+
let render_op = Op::Batch {
315+
ops: vec![],
316+
label: "koharu-renderer: page 1".to_string(),
317+
};
318+
history.push_undo(render_op);
319+
320+
// Should merge: undo stack size remains 1
321+
assert_eq!(history.undo_stack.len(), 1);
322+
if let Some(Op::Batch { label, .. }) = history.undo_stack.back() {
323+
assert_eq!(label, "Update block");
324+
} else {
325+
panic!("Expected Batch");
326+
}
327+
328+
drop(history);
329+
let _ = std::fs::remove_file(path);
330+
}
331+
332+
#[test]
333+
fn test_push_undo_no_merge_pipeline_step() {
334+
let (mut history, path) = make_temp_history();
335+
336+
// 1. Pipeline step: "translation-gpt-4o: page 1"
337+
let pipeline_op = Op::Batch {
338+
ops: vec![],
339+
label: "translation-gpt-4o: page 1".to_string(),
340+
};
341+
history.push_undo(pipeline_op);
342+
assert_eq!(history.undo_stack.len(), 1);
343+
344+
// 2. Renderer operation
345+
let render_op = Op::Batch {
346+
ops: vec![],
347+
label: "koharu-renderer: page 1".to_string(),
348+
};
349+
history.push_undo(render_op);
350+
351+
// Should NOT merge: undo stack size becomes 2
352+
assert_eq!(history.undo_stack.len(), 2);
353+
354+
let first = &history.undo_stack[0];
355+
let second = &history.undo_stack[1];
356+
357+
if let Op::Batch { label, .. } = first {
358+
assert_eq!(label, "translation-gpt-4o: page 1");
359+
} else {
360+
panic!("Expected Batch");
361+
}
362+
363+
if let Op::Batch { label, .. } = second {
364+
assert_eq!(label, "koharu-renderer: page 1");
365+
} else {
366+
panic!("Expected Batch");
367+
}
368+
369+
drop(history);
370+
let _ = std::fs::remove_file(path);
371+
}
372+
373+
#[test]
374+
fn test_push_undo_merge_only_immediate_preceding_user_edit() {
375+
let (mut history, path) = make_temp_history();
376+
377+
// 1. Pipeline step (first in history)
378+
let pipeline_op = Op::Batch {
379+
ops: vec![],
380+
label: "translation-gpt-4o: page 1".to_string(),
381+
};
382+
history.push_undo(pipeline_op);
383+
384+
// 2. User edit (second in history, immediately preceding)
385+
let user_op = Op::Batch {
386+
ops: vec![],
387+
label: "Update block".to_string(),
388+
};
389+
history.push_undo(user_op);
390+
391+
// 3. Renderer operation
392+
let render_op = Op::Batch {
393+
ops: vec![],
394+
label: "koharu-renderer: page 1".to_string(),
395+
};
396+
history.push_undo(render_op);
397+
398+
// Undo stack should have 2 entries (pipeline step + merged user edit & render)
399+
assert_eq!(history.undo_stack.len(), 2);
400+
401+
let first = &history.undo_stack[0];
402+
let second = &history.undo_stack[1];
403+
404+
if let Op::Batch { label, .. } = first {
405+
assert_eq!(label, "translation-gpt-4o: page 1");
406+
} else {
407+
panic!("Expected Batch");
408+
}
409+
410+
if let Op::Batch { label, .. } = second {
411+
assert_eq!(label, "Update block");
412+
} else {
413+
panic!("Expected Batch");
414+
}
415+
416+
drop(history);
417+
let _ = std::fs::remove_file(path);
418+
}
419+
}

crates/koharu-ml/tests/README.md

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
# Koharu ML tests
2-
3-
To run test locally if you have CUDA enabled GPU, use the following command:
4-
5-
```bash
6-
# llm tests only
7-
bun run cargo test --package koharu-ml --test llm --features cuda
8-
# all
9-
bun run cargo test --package koharu-ml --features cuda
1+
# Koharu ML tests
2+
3+
To run test locally if you have CUDA enabled GPU, use the following command:
4+
5+
```bash
6+
# llm tests only
7+
bun run cargo test --package koharu-ml --test llm --features cuda
8+
# all
9+
bun run cargo test --package koharu-ml --features cuda
1010
```

crates/koharu/tauri.linux.conf.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
{
2-
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
1+
{
2+
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
33
"identifier": "Koharu",
44
"build": {
55
"features": [

ui/app/globals.css

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,4 +145,3 @@
145145
@apply select-text;
146146
}
147147
}
148-

ui/components/MenuBar.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,13 @@ import { useScene } from '@/hooks/useScene'
2424
import { getConfig, startPipeline } from '@/lib/api/default/default'
2525
import { isTauri, openExternalUrl } from '@/lib/backend'
2626
import { exportCurrentProjectAs, importPages } from '@/lib/io/pagesIo'
27-
import { clearSelectionOnCurrentPage, closeProject, redoOp, selectAllTextNodesOnCurrentPage, undoOp } from '@/lib/io/scene'
27+
import {
28+
clearSelectionOnCurrentPage,
29+
closeProject,
30+
redoOp,
31+
selectAllTextNodesOnCurrentPage,
32+
undoOp,
33+
} from '@/lib/io/scene'
2834
import { formatShortcutForDisplay, getPlatform } from '@/lib/shortcutUtils'
2935
import { useEditorUiStore } from '@/lib/stores/editorUiStore'
3036
import { usePreferencesStore } from '@/lib/stores/preferencesStore'

ui/components/Navigator.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useVirtualizer } from '@tanstack/react-virtual'
44
import { LayoutGridIcon, Trash2Icon } from 'lucide-react'
55
import { memo, useCallback, useMemo, useRef, useState } from 'react'
66
import type React from 'react'
7+
import { useHotkeys } from 'react-hotkeys-hook'
78
import { useTranslation } from 'react-i18next'
89

910
import { PageManagerDialog } from '@/components/PageManagerDialog'
@@ -14,7 +15,6 @@ import { getGetPageThumbnailUrl } from '@/lib/api/default/default'
1415
import { applyOp } from '@/lib/io/scene'
1516
import { ops } from '@/lib/ops'
1617
import { useSelectionStore } from '@/lib/stores/selectionStore'
17-
import { useHotkeys } from 'react-hotkeys-hook'
1818

1919
const THUMBNAIL_DPR =
2020
typeof window !== 'undefined' ? Math.min(Math.ceil(window.devicePixelRatio || 1), 3) : 2
@@ -126,7 +126,7 @@ export function Navigator() {
126126
(id: string) => {
127127
void handleDeletePages(new Set([id]))
128128
},
129-
[handleDeletePages,],
129+
[handleDeletePages],
130130
)
131131

132132
const handleBatchDelete = useCallback(() => {

ui/components/canvas/TextBlockLayer.tsx

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
'use client'
22

33
import { useDrag } from '@use-gesture/react'
4-
import { useMemo, useRef } from 'react'
5-
import { useHotkeys } from 'react-hotkeys-hook'
4+
import { useEffect, useRef } from 'react'
65

76
import { useBlobImage } from '@/hooks/useBlobData'
87
import { useCurrentPage, useTextNodes, type TextNodeEntry } from '@/hooks/useCurrentPage'
@@ -31,11 +30,6 @@ export function TextBlockLayer({ showSprites, scale, style }: TextBlockLayerProp
3130
const mode = useEditorUiStore((s) => s.mode)
3231
const interactive = mode === 'select' || mode === 'block'
3332

34-
const hasSelection = useMemo(() => {
35-
for (const id of selectedIds) if (id) return true
36-
return false
37-
}, [selectedIds])
38-
3933
const updateTransform = async (id: string, t: Transform) => {
4034
if (!page) return
4135
const data: NodeDataPatch = {
@@ -111,6 +105,12 @@ function TextBlockItem({
111105
const edgeRef = useRef<ResizeEdge | null>(null)
112106
const isResizeRef = useRef(false)
113107

108+
useEffect(() => {
109+
if (selected && boxRef.current) {
110+
boxRef.current.focus()
111+
}
112+
}, [selected])
113+
114114
const setBox = (x: number, y: number, w: number, h: number) => {
115115
const el = boxRef.current
116116
if (!el) return
@@ -128,6 +128,7 @@ function TextBlockItem({
128128
const additive = isAdditiveEvent(event)
129129
if (tap) {
130130
onSelect(node.id, additive)
131+
boxRef.current?.focus()
131132
return
132133
}
133134
if (first) {
@@ -140,6 +141,7 @@ function TextBlockItem({
140141
// Keep multi-selection intact when dragging a node that's already selected;
141142
// otherwise this click is a single-select (unless the modifier is held).
142143
if (additive || !selected) onSelect(node.id, additive)
144+
boxRef.current?.focus()
143145
}
144146
const { x: sx, y: sy, w: sw, h: sh } = dragStart.current
145147
const edge = edgeRef.current
@@ -208,6 +210,7 @@ function TextBlockItem({
208210
<div
209211
ref={boxRef}
210212
{...bind()}
213+
tabIndex={-1}
211214
style={{
212215
position: 'absolute',
213216
top: 0,
@@ -219,17 +222,20 @@ function TextBlockItem({
219222
zIndex: selected ? 20 : 10,
220223
touchAction: 'none',
221224
cursor: interactive ? 'move' : 'default',
225+
outline: 'none',
222226
}}
223227
>
224228
<div
225-
className={`absolute inset-0 rounded-md ${selected
226-
? 'border-[3px] border-primary bg-primary/15'
227-
: 'border-2 border-rose-400/60 bg-rose-400/5'
228-
}`}
229+
className={`absolute inset-0 rounded-md ${
230+
selected
231+
? 'border-[3px] border-primary bg-primary/15'
232+
: 'border-2 border-rose-400/60 bg-rose-400/5'
233+
}`}
229234
/>
230235
<div
231-
className={`pointer-events-none absolute -top-1.5 -left-1.5 flex h-4 w-4 items-center justify-center rounded-full text-[9px] font-semibold text-white shadow ${selected ? 'bg-primary' : 'bg-rose-400'
232-
}`}
236+
className={`pointer-events-none absolute -top-1.5 -left-1.5 flex h-4 w-4 items-center justify-center rounded-full text-[9px] font-semibold text-white shadow ${
237+
selected ? 'bg-primary' : 'bg-rose-400'
238+
}`}
233239
>
234240
{index + 1}
235241
</div>

0 commit comments

Comments
 (0)