Undo, and the toolbar in three groups
Undo takes back one's own latest mark, by the button, Ctrl+Z or two quick taps of the eraser; the toolbar's drawing tools, the page and view, and the session stand in three groups that do not move when the tool changes; the buttons are a little smaller.
This commit is contained in:
1 parent
a139361e8d
commit
4e3a281d65
11 files changed
+602
-35
No files matched your search
@@ -368,6 +368,37 @@ ERASE_THROUGH = """
|
||||
page.setPointerCapture = capture;
|
||||
"""
|
||||
|
||||
# Taps the eraser `count` times at (x, y) in page units, `gap` ms apart,
|
||||
# and calls the last argument when done.
|
||||
ERASER_TAPS = """
|
||||
const [x, y, count, gap, done] = arguments;
|
||||
const page = document.getElementById('page');
|
||||
const box = page.getBoundingClientRect();
|
||||
const extent = window.kokkoja.session.page;
|
||||
const cx = box.left + (x - extent.left) / extent.width * box.width;
|
||||
const cy = box.top + (y - extent.top) / extent.height * box.height;
|
||||
const capture = page.setPointerCapture;
|
||||
page.setPointerCapture = () => {};
|
||||
const fire = (type, buttons) => page.dispatchEvent(
|
||||
new PointerEvent(type, {pointerId: 7, pointerType: 'pen', isPrimary: true,
|
||||
clientX: cx, clientY: cy, pressure: 0.5,
|
||||
button: type === 'pointermove' ? -1 : 0, buttons, bubbles: true, cancelable: true}));
|
||||
document.querySelector('[data-tool=eraser]').click();
|
||||
let left = count;
|
||||
const tap = () => {
|
||||
fire('pointerdown', 1);
|
||||
fire('pointerup', 0);
|
||||
if (--left > 0) {
|
||||
setTimeout(tap, gap);
|
||||
} else {
|
||||
document.querySelector('[data-tool=pen]').click();
|
||||
page.setPointerCapture = capture;
|
||||
done();
|
||||
}
|
||||
};
|
||||
tap();
|
||||
"""
|
||||
|
||||
|
||||
def free_port():
|
||||
with socket.socket() as listener:
|
||||
@@ -467,6 +498,12 @@ class Firefox:
|
||||
"actions": [{"type": "pointerMove", "duration": 0, "origin": "viewport",
|
||||
"x": round(box[0]), "y": round(box[1])}]}]})
|
||||
|
||||
def chord(self, *keys):
|
||||
"""Press keys together and release them, as a keyboard shortcut."""
|
||||
actions = ([{"type": "keyDown", "value": key} for key in keys] +
|
||||
[{"type": "keyUp", "value": key} for key in reversed(keys)])
|
||||
self.in_session("POST", "/actions", {"actions": [{"type": "key", "id": "keyboard", "actions": actions}]})
|
||||
|
||||
def set_window(self, width, height):
|
||||
self.in_session("POST", "/window/rect", {"width": width, "height": height})
|
||||
|
||||
@@ -914,6 +951,136 @@ class EndToEndTest(unittest.TestCase):
|
||||
firefox.switch_to(host_tab)
|
||||
firefox.go("about:blank")
|
||||
|
||||
def test_undo(self):
|
||||
"""Andy, 2026-10-11: one's latest gesture is taken back, then the
|
||||
one before, by the Undo button, Ctrl+Z, or two quick taps of the
|
||||
eraser on empty paper; a stroke, an erasure, text, a Hide."""
|
||||
firefox = self.firefox
|
||||
host_tab = firefox.current_tab()
|
||||
firefox.go(self.address("Late"))
|
||||
self.enter_name("Writer", "start_session")
|
||||
invitation = firefox.run("return document.getElementById('invitation').value;")
|
||||
guest_tab = firefox.new_tab()
|
||||
firefox.go(self.address("Student", join=invitation))
|
||||
self.enter_name("Watcher", "join_session")
|
||||
self.wait_until(lambda s: s["page"]["number"] == 1 and len(s["participants"]) == 2, "the watcher")
|
||||
firefox.switch_to(host_tab)
|
||||
undo_disabled = lambda: firefox.run("return document.getElementById('undo').disabled;")
|
||||
undo = lambda: firefox.run("document.getElementById('undo').click();")
|
||||
self.assertTrue(undo_disabled(), "nothing to undo on a new page")
|
||||
|
||||
# Two strokes; Undo takes back the second, for everyone.
|
||||
firefox.drag("#page", 200, 300, 500, 320)
|
||||
firefox.drag("#page", 200, 400, 500, 420)
|
||||
two = self.wait_until(lambda s: len(s["layers"]["Writer"]) == 2 and s["layers"]["Writer"][-1]["done"],
|
||||
"two strokes")
|
||||
self.assertFalse(undo_disabled())
|
||||
undo()
|
||||
one = self.wait_until(lambda s: len(s["layers"]["Writer"]) == 1, "one stroke after the undo")
|
||||
self.assertEqual(one["layers"]["Writer"][0]["id"], two["layers"]["Writer"][0]["id"])
|
||||
firefox.switch_to(guest_tab)
|
||||
self.wait_until(lambda s: len(s["layers"].get("Writer", [])) == 1, "the undo to reach the watcher")
|
||||
firefox.switch_to(host_tab)
|
||||
|
||||
# An erasure is one gesture: undone, the stroke is back whole,
|
||||
# with its id, on every screen.
|
||||
stroke = one["layers"]["Writer"][0]
|
||||
page_box = firefox.run("const b = document.getElementById('page').getBoundingClientRect(); return [b.width, b.height];")
|
||||
firefox.run(ERASE_THROUGH, 350 / page_box[0], 310 / page_box[1])
|
||||
self.wait_until(lambda s: len(s["layers"]["Writer"]) == 0, "the stroke erased")
|
||||
self.assertFalse(undo_disabled())
|
||||
undo()
|
||||
back = self.wait_until(lambda s: len(s["layers"]["Writer"]) == 1, "the stroke back")
|
||||
self.assertEqual(back["layers"]["Writer"][0], stroke)
|
||||
firefox.switch_to(guest_tab)
|
||||
watcher = self.wait_until(lambda s: len(s["layers"].get("Writer", [])) == 1 and s["layers"]["Writer"][0]["done"],
|
||||
"the restored stroke to reach the watcher")
|
||||
self.assertEqual(watcher["layers"]["Writer"][0], stroke)
|
||||
firefox.switch_to(host_tab)
|
||||
|
||||
# Ctrl+Z undoes the stroke itself, the gesture before the erasure;
|
||||
# then there is nothing left to undo.
|
||||
firefox.chord("\ue009", "z")
|
||||
self.wait_until(lambda s: len(s["layers"]["Writer"]) == 0, "the stroke gone by Ctrl+Z")
|
||||
self.assertTrue(undo_disabled())
|
||||
|
||||
# Two quick taps of the eraser on empty paper undo; one tap, or two
|
||||
# far apart, do nothing; a tap that erases is not half of a double
|
||||
# tap, so the stroke it erased stays erased.
|
||||
origin = self.state()["origin"]
|
||||
vscale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;")
|
||||
units = lambda cx, cy: (origin[0] + cx / vscale, origin[1] + cy / vscale)
|
||||
empty = units(700, 150)
|
||||
firefox.drag("#page", 200, 400, 500, 420)
|
||||
self.wait_until(lambda s: len(s["layers"]["Writer"]) == 1 and s["layers"]["Writer"][0]["done"], "a stroke")
|
||||
firefox.run_until_done(ERASER_TAPS, *empty, 1, 0)
|
||||
time.sleep(0.6)
|
||||
firefox.run_until_done(ERASER_TAPS, *empty, 1, 0)
|
||||
time.sleep(0.2)
|
||||
self.assertEqual(len(self.state()["layers"]["Writer"]), 1, "taps far apart undo nothing")
|
||||
firefox.run_until_done(ERASER_TAPS, *empty, 2, 50)
|
||||
self.wait_until(lambda s: len(s["layers"]["Writer"]) == 0, "the stroke undone by a double tap")
|
||||
firefox.drag("#page", 200, 400, 500, 420)
|
||||
self.wait_until(lambda s: len(s["layers"]["Writer"]) == 1 and s["layers"]["Writer"][0]["done"], "a stroke again")
|
||||
firefox.run_until_done(ERASER_TAPS, *units(350, 410), 1, 0)
|
||||
self.wait_until(lambda s: len(s["layers"]["Writer"]) == 0, "the stroke erased by a tap")
|
||||
firefox.run_until_done(ERASER_TAPS, *empty, 1, 0)
|
||||
time.sleep(0.2)
|
||||
self.assertEqual(len(self.state()["layers"]["Writer"]), 0, "an erasing tap and an empty one are no double tap")
|
||||
firefox.run_until_done(ERASER_TAPS, *empty, 2, 50)
|
||||
self.wait_until(lambda s: len(s["layers"]["Writer"]) == 1, "the erasure undone by a double tap")
|
||||
|
||||
# Text: typing a block is one gesture, changing it another; undo
|
||||
# restores the text as it was before the change, then removes the
|
||||
# block.
|
||||
firefox.run("document.querySelector('[data-tool=text]').click();")
|
||||
firefox.run(TEXT_GESTURE, 0.6, 0.6, 0.6, 0.6)
|
||||
firefox.type_into(".text_field", "Hallo")
|
||||
firefox.run("document.querySelector('[data-tool=pen]').click();")
|
||||
typed = self.wait_until(lambda s: len(s["texts"]["Writer"]) == 1 and s["texts"]["Writer"][0]["text"] == "Hallo",
|
||||
"the text block")
|
||||
block = typed["texts"]["Writer"][0]
|
||||
state = self.state()
|
||||
fx = (block["x"] + 10 - state["origin"][0]) / state["page"]["width"]
|
||||
fy = (block["y"] + 10 - state["origin"][1]) / state["page"]["height"]
|
||||
firefox.run("document.querySelector('[data-tool=text]').click();")
|
||||
firefox.run(TEXT_GESTURE, fx, fy, fx, fy)
|
||||
firefox.type_into(".text_field", " Welt")
|
||||
firefox.run("document.querySelector('[data-tool=pen]').click();")
|
||||
self.wait_until(lambda s: s["texts"]["Writer"][0]["text"] == "Hallo Welt", "the changed text")
|
||||
undo()
|
||||
self.wait_until(lambda s: s["texts"]["Writer"][0]["text"] == "Hallo", "the text as it was")
|
||||
undo()
|
||||
self.wait_until(lambda s: len(s["texts"]["Writer"]) == 0, "the block gone")
|
||||
self.assertEqual(len(self.state()["layers"]["Writer"]), 1)
|
||||
|
||||
# A Hide rectangle made, and one removed by the eraser, are undone.
|
||||
firefox.run("document.querySelector('[data-tool=hide]').click();")
|
||||
firefox.run(TEXT_GESTURE, 0.05, 0.2, 0.35, 0.4)
|
||||
made = self.wait_until(lambda s: len(s["hides"]) == 1, "the Hide rectangle")
|
||||
undo()
|
||||
self.wait_until(lambda s: not s["hides"], "the Hide undone")
|
||||
firefox.run(TEXT_GESTURE, 0.05, 0.2, 0.35, 0.4)
|
||||
made = self.wait_until(lambda s: len(s["hides"]) == 1, "the Hide rectangle again")
|
||||
hide = made["hides"][0]
|
||||
firefox.run(ERASER_AT, hide[0] + 50, hide[1] + 50, 5)
|
||||
self.wait_until(lambda s: not s["hides"], "the Hide removed by the eraser")
|
||||
undo()
|
||||
self.wait_until(lambda s: s["hides"] == [hide], "the Hide back where it was")
|
||||
firefox.switch_to(guest_tab)
|
||||
self.wait_until(lambda s: s["hides"] == [hide], "the restored Hide to reach the watcher")
|
||||
firefox.switch_to(host_tab)
|
||||
|
||||
# A new page starts with nothing to undo.
|
||||
firefox.run("document.getElementById('new_page').click();")
|
||||
self.wait_until(lambda s: s["page"]["number"] == 2, "the new page")
|
||||
self.assertTrue(undo_disabled())
|
||||
|
||||
firefox.switch_to(guest_tab)
|
||||
firefox.in_session("DELETE", "/window")
|
||||
firefox.switch_to(host_tab)
|
||||
firefox.go("about:blank")
|
||||
|
||||
def test_images_are_moved_and_scaled_by_hand(self):
|
||||
"""The teacher's requests of 2026-10-08: composition mode, and
|
||||
images moved, scaled and brought to the front by a hold of the pen."""
|
||||
|
||||
Reference in new issue
Block a user