Arranging: boxes on everything, handwriting movable, the Eraser's preview, a Circle shape, pixel-exact cues
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
03907da8c9
commit
24a6a2595b
10 files changed
+690
-126
No files matched your search
+295
-20
@@ -44,10 +44,27 @@ HOVER_CUE = """
|
||||
const page = document.getElementById('page').getBoundingClientRect();
|
||||
const b = c.getBoundingClientRect();
|
||||
const k = s.page.width / page.width;
|
||||
const st = getComputedStyle(c);
|
||||
return {hidden: c.hidden, x: s.page.left + (b.left - page.left) * k, y: s.page.top + (b.top - page.top) * k,
|
||||
width: b.width * k, height: b.height * k};
|
||||
width: b.width * k, height: b.height * k, kind: c.className.split(' ')[1],
|
||||
box: [b.left - page.left, b.top - page.top, b.right - page.left, b.bottom - page.top],
|
||||
scale: r.cssPixelsPerUnit, origin: [s.page.left, s.page.top],
|
||||
edges: [st.borderTopWidth, st.borderRightWidth, st.borderBottomWidth, st.borderLeftWidth]};
|
||||
"""
|
||||
|
||||
# Where an overlay's box must lie around a rectangle's edges in pixels
|
||||
# (CUE_OFFSET_PX in renderer.js): the edges taken out to whole pixels and
|
||||
# the line this far beyond them.
|
||||
CUE_OFFSET_PX = 2
|
||||
|
||||
|
||||
def overlay_box(left, top, right, bottom, line):
|
||||
"""The pixel box an overlay with this line width takes around a
|
||||
rectangle whose edges are at these pixel positions."""
|
||||
beyond = CUE_OFFSET_PX - 1 + line
|
||||
return [math.floor(left) - beyond, math.floor(top) - beyond, math.ceil(right) + beyond, math.ceil(bottom) + beyond]
|
||||
|
||||
|
||||
# Whether anything at all is drawn on the writer's own layer canvas within
|
||||
# two device pixels of the page point (x, y).
|
||||
INK_AT = """
|
||||
@@ -538,6 +555,12 @@ class Firefox:
|
||||
"type": "pointer", "id": "mouse", "parameters": {"pointerType": "mouse"},
|
||||
"actions": moves}]})
|
||||
|
||||
def pointer_actions(self, actions):
|
||||
"""Perform mouse actions (WebDriver's pointer actions) in sequence."""
|
||||
self.in_session("POST", "/actions", {"actions": [{
|
||||
"type": "pointer", "id": "mouse", "parameters": {"pointerType": "mouse"},
|
||||
"actions": actions}]})
|
||||
|
||||
def type_into(self, selector, text):
|
||||
"""Type text into an element, as the keyboard would."""
|
||||
found = self.in_session("POST", "/element", {"using": "css selector", "value": selector})
|
||||
@@ -661,17 +684,30 @@ class EndToEndTest(unittest.TestCase):
|
||||
def state(self):
|
||||
return self.firefox.run(PAGE_STATE)
|
||||
|
||||
def assert_hover_cue(self, x, y, rect):
|
||||
"""A pointer hovering at the page point (x, y) shows the blue
|
||||
corners over the rectangle (page units), and none once it leaves."""
|
||||
def assert_hover_cue(self, x, y, rect, kind="hide"):
|
||||
"""A pointer hovering at the page point (x, y) shows the blue cue
|
||||
over the rectangle (page units), and none once it leaves: a text
|
||||
rectangle or a Hide outlined, free text's top and left edges
|
||||
(Andy, 2026-10-14)."""
|
||||
state = self.state()
|
||||
fx = (x - state["origin"][0]) / state["page"]["width"]
|
||||
fy = (y - state["origin"][1]) / state["page"]["height"]
|
||||
self.firefox.run(HOVER, fx, fy)
|
||||
cue = self.firefox.run(HOVER_CUE)
|
||||
self.assertFalse(cue["hidden"], f"no cue at {x}, {y}")
|
||||
for key, expected in zip(("x", "y", "width", "height"), rect):
|
||||
self.assertAlmostEqual(cue[key], expected, delta=3, msg=key)
|
||||
self.assertEqual(cue["kind"], kind)
|
||||
line = cue["edges"][0]
|
||||
self.assertNotEqual(line, "0px")
|
||||
# The cue's box lies CUE_OFFSET_PX outside the rectangle.
|
||||
px = lambda v: (v - cue["origin"][0]) * cue["scale"]
|
||||
py = lambda v: (v - cue["origin"][1]) * cue["scale"]
|
||||
expected = overlay_box(px(rect[0]), py(rect[1]), px(rect[0] + rect[2]), py(rect[1] + rect[3]), float(line[:-2]))
|
||||
for side, got, want in zip(("left", "top", "right", "bottom"), cue["box"], expected):
|
||||
self.assertAlmostEqual(got, want, delta=0.01, msg=side)
|
||||
if kind == "free":
|
||||
self.assertEqual(cue["edges"], [line, "0px", "0px", line], "top and left edges only")
|
||||
else:
|
||||
self.assertEqual(cue["edges"], [line] * 4, "outlined")
|
||||
self.firefox.run(HOVER, 0.01, 0.01)
|
||||
self.assertTrue(self.firefox.run(HOVER_CUE)["hidden"])
|
||||
|
||||
@@ -1517,20 +1553,39 @@ class EndToEndTest(unittest.TestCase):
|
||||
self.assertEqual(after["layers"]["Arranger"], [])
|
||||
self.assertEqual(rect(after, 0), rect(scaled, 0))
|
||||
|
||||
# A text rectangle of one's own and a Hide, marked by their own blue
|
||||
# corners, get no cue; a corner drag widens the text box, a drag
|
||||
# inside moves the Hide.
|
||||
# A text rectangle of one's own, free text and a Hide get cues too
|
||||
# (Andy, 2026-10-14), the free text's without corners; the hover
|
||||
# cue still shows on the box and the Hide; a corner drag widens
|
||||
# the text box, a drag inside moves the Hide.
|
||||
choose("text")
|
||||
firefox.run(TEXT_GESTURE, 0.1, 0.6, 0.3, 0.7)
|
||||
firefox.type_into(".text_field", "Box")
|
||||
firefox.run(TEXT_GESTURE, 0.4, 0.8, 0.4, 0.8) # Finishes the box.
|
||||
self.wait_until(lambda s: firefox.run("return document.querySelector('.text_field').hidden;"), "the box finished")
|
||||
firefox.run(TEXT_GESTURE, 0.4, 0.8, 0.4, 0.8) # Starts free text.
|
||||
firefox.type_into(".text_field", "Free")
|
||||
choose("hide")
|
||||
firefox.run(TEXT_GESTURE, 0.5, 0.6, 0.7, 0.7)
|
||||
with_all = self.wait_until(lambda s: len(s["hides"]) == 1 and s["texts"]["Arranger"] and s["texts"]["Arranger"][0]["text"] == "Box",
|
||||
"the box and the Hide")
|
||||
with_all = self.wait_until(lambda s: len(s["hides"]) == 1 and len(s["texts"]["Arranger"]) == 2
|
||||
and s["texts"]["Arranger"][0]["text"] == "Box" and s["texts"]["Arranger"][1]["text"] == "Free",
|
||||
"the box, the free text and the Hide")
|
||||
choose("arrange")
|
||||
self.assertEqual(cues(), ["image"])
|
||||
self.assertEqual(cues(), ["image", "hide", "text", "text"])
|
||||
self.assertEqual(firefox.run("return [...document.querySelectorAll('.arrange_cue.text')].map((c) => c.classList.contains('no_corners'));"),
|
||||
[False, True], "free text's cue has no corners")
|
||||
self.assertEqual(firefox.run("return [...document.querySelectorAll('.arrange_cue')].map((c) => getComputedStyle(c).borderTopWidth);"),
|
||||
["2px"] * 4, "every cue is a box, its line the grip's width")
|
||||
# The image's cue lies CUE_OFFSET_PX outside the image.
|
||||
im = scaled["images"][0]
|
||||
page_box = firefox.run("const b = document.getElementById('page').getBoundingClientRect(); return [b.left, b.top];")
|
||||
got = firefox.run("const b = document.querySelector('.arrange_cue.image').getBoundingClientRect(); return [b.left, b.top, b.right, b.bottom];")
|
||||
want = overlay_box(px(im["x"]), py(im["y"]), px(im["x"] + im["width"]), py(im["y"] + im["height"]), 2)
|
||||
for side, g, w in zip(("left", "top", "right", "bottom"), (got[0] - page_box[0], got[1] - page_box[1], got[2] - page_box[0], got[3] - page_box[1]), want):
|
||||
self.assertAlmostEqual(g, w, delta=0.01, msg=side)
|
||||
self.assertEqual(firefox.run("return getComputedStyle(document.documentElement).getPropertyValue('--arrange_blue');").strip(), "#1e88e5")
|
||||
self.assertEqual(firefox.run("return getComputedStyle(document.querySelector('.arrange_cue')).borderTopColor;"), "rgb(30, 136, 229)")
|
||||
box = with_all["texts"]["Arranger"][0]
|
||||
self.assert_hover_cue(box["x"] + 20, box["y"] + 20, [box["x"], box["y"], box["width"], box["height"]])
|
||||
self.assert_hover_cue(box["x"] + 20, box["y"] + 20, [box["x"], box["y"], box["width"], box["height"]], "rect")
|
||||
h = with_all["hides"][0]
|
||||
self.assert_hover_cue(h[0] + h[2] / 2, h[1] + h[3] / 2, h)
|
||||
firefox.drag("#page", px(box["x"] + box["width"]) - 3, py(box["y"] + box["height"]) - 3,
|
||||
@@ -1542,14 +1597,51 @@ class EndToEndTest(unittest.TestCase):
|
||||
px(hide[0] + hide[2] / 2) - 100, py(hide[1] + hide[3] / 2) + 50)
|
||||
hide_moved = self.wait_until(lambda s: s["hides"][0][0] != hide[0], "the Hide moved")
|
||||
self.assertEqual(hide_moved["hides"][0][2:], hide[2:])
|
||||
self.assertEqual(len(cues()), 1)
|
||||
self.assertEqual(len(cues()), 4)
|
||||
|
||||
|
||||
# Undo takes the Hide's move back, as it does a hold's.
|
||||
firefox.run("document.getElementById('undo').click();")
|
||||
self.wait_until(lambda s: s["hides"][0] == hide, "the Hide back by Undo")
|
||||
|
||||
# The watcher, without the right, is shown no cue; their own text
|
||||
# rectangle has its blue corners as everyone's has.
|
||||
# While something is held its blue cue gives way to the green grip:
|
||||
# a press on the Hide, held down, then released.
|
||||
page_box = firefox.run("const b = document.getElementById('page').getBoundingClientRect(); return [b.left, b.top];")
|
||||
firefox.pointer_actions([{"type": "pointerMove", "duration": 0, "origin": "viewport",
|
||||
"x": round(page_box[0] + px(hide[0] + hide[2] / 2)), "y": round(page_box[1] + py(hide[1] + hide[3] / 2))},
|
||||
{"type": "pointerDown", "button": 0}, {"type": "pause", "duration": 100}])
|
||||
self.assertEqual(cues(), ["image", "text", "text"], "the held Hide's cue is gone")
|
||||
self.assertFalse(firefox.run("return document.querySelector('.grip').hidden;"))
|
||||
self.assertEqual(firefox.run("return getComputedStyle(document.querySelector('.grip')).borderTopColor;"), "rgb(46, 125, 50)")
|
||||
# Held for moving, all four corners are the moving size (Andy, 2026-10-14).
|
||||
self.assertEqual(firefox.run("return [...document.querySelectorAll('.grip .handle')].map((h) => getComputedStyle(h).width);"), ["14px"] * 4)
|
||||
firefox.pointer_actions([{"type": "pointerUp", "button": 0}])
|
||||
self.assertEqual(cues(), ["image", "hide", "text", "text"], "the cue is back")
|
||||
self.assertTrue(firefox.run("return document.querySelector('.grip').hidden;"))
|
||||
# Held at a corner, only that corner is the moving size.
|
||||
firefox.pointer_actions([{"type": "pointerMove", "duration": 0, "origin": "viewport",
|
||||
"x": round(page_box[0] + px(hide[0] + hide[2])) - 3, "y": round(page_box[1] + py(hide[1] + hide[3])) - 3},
|
||||
{"type": "pointerDown", "button": 0}, {"type": "pause", "duration": 100}])
|
||||
self.assertEqual(firefox.run("return [...document.querySelectorAll('.grip .handle')].map((h) => getComputedStyle(h).width);"), ["10px"] * 4)
|
||||
# The square's position is a percentage of the grip's padding box.
|
||||
self.assertEqual(firefox.run("const g = document.querySelector('.grip'); const s = getComputedStyle(g, '::after');"
|
||||
" return [s.width, parseFloat(s.left) === g.clientWidth, parseFloat(s.top) === g.clientHeight];"),
|
||||
["14px", True, True], "the dragged corner, bottom right, larger")
|
||||
firefox.pointer_actions([{"type": "pointerUp", "button": 0}])
|
||||
self.wait_until(lambda s: s["hides"][0] == hide, "the Hide unchanged by a press")
|
||||
# Held free text shows the grip without corners, as its cue has none.
|
||||
free = self.state()["texts"]["Arranger"][1]
|
||||
firefox.pointer_actions([{"type": "pointerMove", "duration": 0, "origin": "viewport",
|
||||
"x": round(page_box[0] + px(free["x"] + 20)), "y": round(page_box[1] + py(free["y"] + 20))},
|
||||
{"type": "pointerDown", "button": 0}, {"type": "pause", "duration": 100}])
|
||||
self.assertEqual(cues(), ["image", "hide", "text"], "the held free text's cue is gone")
|
||||
self.assertEqual(firefox.run("return [...document.querySelectorAll('.grip .handle')].map((h) => getComputedStyle(h).display);"),
|
||||
["none"] * 4, "no corners on held free text")
|
||||
firefox.pointer_actions([{"type": "pointerUp", "button": 0}])
|
||||
self.assertEqual(cues(), ["image", "hide", "text", "text"])
|
||||
|
||||
# The watcher, without the right, is shown no cue on the image or
|
||||
# the Hide, only on their own text.
|
||||
firefox.switch_to(guest_tab)
|
||||
self.wait_until(lambda s: len(s["images"]) == 1 and len(s["hides"]) == 1, "the page at the watcher")
|
||||
choose("arrange")
|
||||
@@ -1558,10 +1650,10 @@ class EndToEndTest(unittest.TestCase):
|
||||
firefox.run(TEXT_GESTURE, 0.1, 0.8, 0.3, 0.9)
|
||||
firefox.type_into(".text_field", "Mine")
|
||||
choose("arrange")
|
||||
self.assertEqual(cues(), [])
|
||||
self.assertEqual(cues(), ["text"])
|
||||
mine = self.wait_until(lambda s: s["texts"].get("Watcher") and s["texts"]["Watcher"][0]["text"] == "Mine", "the watcher's box")
|
||||
mine = mine["texts"]["Watcher"][0]
|
||||
self.assert_hover_cue(mine["x"] + 20, mine["y"] + 20, [mine["x"], mine["y"], mine["width"], mine["height"]])
|
||||
self.assert_hover_cue(mine["x"] + 20, mine["y"] + 20, [mine["x"], mine["y"], mine["width"], mine["height"]], "rect")
|
||||
|
||||
def test_shapes(self):
|
||||
"""Andy, 2026-10-14: a Shape button right of Text draws the shape it
|
||||
@@ -1772,6 +1864,19 @@ class EndToEndTest(unittest.TestCase):
|
||||
scale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;")
|
||||
px = lambda x: (x - origin[0]) * scale
|
||||
py = lambda y: (y - origin[1]) * scale
|
||||
# Each cue's line lies just outside the shape's ink (the points plus
|
||||
# half the line's width, taken out to whole pixels), one pixel
|
||||
# beyond it on every side (Andy, 2026-10-14).
|
||||
page_box = firefox.run("const b = document.getElementById('page').getBoundingClientRect(); return [b.left, b.top];")
|
||||
boxes = firefox.run("return [...document.querySelectorAll('.arrange_cue')].map((c) => { const b = c.getBoundingClientRect();"
|
||||
" return [b.left, b.top, b.right, b.bottom, parseFloat(getComputedStyle(c).borderTopWidth)]; });")
|
||||
for stroke, (left, top, right, bottom, line) in zip(strokes(unfilled), boxes):
|
||||
xs, ys = stroke["points"][0::3], stroke["points"][1::3]
|
||||
half = stroke["width"] / 2
|
||||
self.assertEqual(line, 2)
|
||||
expected = overlay_box(px(min(xs) - half), py(min(ys) - half), px(max(xs) + half), py(max(ys) + half), line)
|
||||
for side, got, want in zip(("left", "top", "right", "bottom"), (left - page_box[0], top - page_box[1], right - page_box[0], bottom - page_box[1]), expected):
|
||||
self.assertAlmostEqual(got, want, delta=0.01, msg=side)
|
||||
before = strokes(filled)[4]["points"]
|
||||
firefox.drag("#page", px(at(0.2, 0.37)[0]), py(at(0.2, 0.37)[1]), px(at(0.2, 0.37)[0]) + 100, py(at(0.2, 0.37)[1]) + 50)
|
||||
moved = self.wait_until(lambda s: strokes(s)[4]["points"] != before, "the rectangle moved")
|
||||
@@ -1814,7 +1919,7 @@ class EndToEndTest(unittest.TestCase):
|
||||
box = self.wait_until(lambda s: s["texts"].get("Drawer") and s["texts"]["Drawer"][0]["text"] == "Box", "the box")["texts"]["Drawer"][0]
|
||||
self.assertFalse(firefox.run(INK_AT, box["x"] + box["width"] / 2, box["y"]), "no frame along the top edge")
|
||||
self.assertFalse(firefox.run(INK_AT, box["x"], box["y"]), "no corner mark on the canvas")
|
||||
self.assert_hover_cue(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2, [box["x"], box["y"], box["width"], box["height"]])
|
||||
self.assert_hover_cue(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2, [box["x"], box["y"], box["width"], box["height"]], "rect")
|
||||
saved = self.wait_until_true(lambda: (self.notebooks / "Drawer notebook" / "Current.svg").read_text(),
|
||||
lambda text: "<desc>Box</desc>" in text, "the box saved")
|
||||
self.assertNotIn("data-ground", saved)
|
||||
@@ -1827,7 +1932,177 @@ class EndToEndTest(unittest.TestCase):
|
||||
self.assertTrue(firefox.run(HOVER_CUE)["hidden"])
|
||||
self.assertFalse(firefox.run(INK_AT, free["x"], free["y"]), "no red L on free text")
|
||||
self.assert_hover_cue(free["x"] + free["boxWidth"] / 2, free["y"] + free["boxHeight"] / 2,
|
||||
[free["x"], free["y"], free["boxWidth"], free["boxHeight"]])
|
||||
[free["x"], free["y"], free["boxWidth"], free["boxHeight"]], "free")
|
||||
|
||||
# A circle (Andy, 2026-10-14): the centre first, then a drag out to
|
||||
# the edge in any direction; every point at that distance from the
|
||||
# centre. Resized by Arrange from a corner, it stays a circle.
|
||||
choose("shape")
|
||||
pick("circle")
|
||||
self.assertEqual(label(), "Circle")
|
||||
self.assertEqual(firefox.run("return [...document.querySelectorAll('#shape_menu [data-shape]')].filter((i) => i.getAttribute('aria-checked') === 'true').map((i) => i.dataset.shape);"), ["circle"])
|
||||
self.assertEqual(firefox.run("return getComputedStyle(document.querySelector('#shape_menu [data-shape=circle]'), '::before').content;"), '"✓"')
|
||||
self.assertEqual(firefox.run("return [...document.querySelectorAll('#shape_menu [data-shape]')].map((i) => i.dataset.shape);"),
|
||||
["rectangle", "circle", "ellipse", "line", "arrow"])
|
||||
now = self.state()
|
||||
count = len(strokes(now))
|
||||
now_at = lambda fx, fy: (now["origin"][0] + fx * now["page"]["width"], now["origin"][1] + fy * now["page"]["height"])
|
||||
firefox.run(TEXT_GESTURE, 0.3, 0.3, 0.33, 0.34)
|
||||
with_circle = self.wait_until(lambda s: len(strokes(s)) == count + 1 and strokes(s)[-1]["done"], "the circle")
|
||||
circle = strokes(with_circle)[-1]
|
||||
self.assertEqual(circle["shape"], "circle")
|
||||
cx, cy = now_at(0.3, 0.3)
|
||||
radius = math.hypot(*(a - b for a, b in zip(now_at(0.33, 0.34), (cx, cy))))
|
||||
xs, ys = circle["points"][0::3], circle["points"][1::3]
|
||||
for x, y in zip(xs, ys):
|
||||
self.assertAlmostEqual(math.hypot(x - cx, y - cy), radius, delta=1)
|
||||
self.assertEqual((xs[0], ys[0]), (xs[-1], ys[-1]), "closed")
|
||||
choose("arrange")
|
||||
right, bottom = max(xs), max(ys)
|
||||
firefox.drag("#page", px(right) - 3, py(bottom) - 3, px(right) + 90, py(bottom) + 30)
|
||||
resized = self.wait_until(lambda s: strokes(s)[-1]["points"] != circle["points"], "the circle resized")
|
||||
rx, ry = strokes(resized)[-1]["points"][0::3], strokes(resized)[-1]["points"][1::3]
|
||||
ncx, ncy = (min(rx) + max(rx)) / 2, (min(ry) + max(ry)) / 2
|
||||
nr = (max(rx) - min(rx)) / 2
|
||||
self.assertGreater(nr, radius + 40 / scale, "the radius grew by half the drag")
|
||||
self.assertAlmostEqual(max(ry) - min(ry), max(rx) - min(rx), delta=1, msg="still round")
|
||||
for x, y in zip(rx, ry):
|
||||
self.assertAlmostEqual(math.hypot(x - ncx, y - ncy), nr, delta=1)
|
||||
|
||||
def test_eraser_preview(self):
|
||||
"""Andy, 2026-10-14: with the Eraser chosen, a red box around each
|
||||
element a press under the hovering pointer would erase: a stroke by
|
||||
the ink's extent, a text block, an image a tap would remove; none
|
||||
on empty paper, none with another tool."""
|
||||
firefox = self.firefox
|
||||
firefox.go(self.address("Host"))
|
||||
self.enter_name("Eraser", "start_session")
|
||||
self.wait_until(lambda s: s["page"]["number"] == 1, "the page")
|
||||
choose = lambda tool: firefox.run(f"document.querySelector('[data-tool={tool}]').click();")
|
||||
cues = lambda: firefox.run("const p = document.getElementById('page').getBoundingClientRect(); return [...document.querySelectorAll('.erase_cue')]"
|
||||
".map((c) => { const b = c.getBoundingClientRect(); return [b.left - p.left, b.top - p.top, b.right - p.left, b.bottom - p.top, getComputedStyle(c).borderTopColor]; });")
|
||||
firefox.run(PEN_WAVE, 0.3, 0.5)
|
||||
choose("text")
|
||||
firefox.run(TEXT_GESTURE, 0.1, 0.7, 0.3, 0.8)
|
||||
firefox.type_into(".text_field", "Box")
|
||||
choose("pen")
|
||||
firefox.run_until_done(LOAD_IMAGE, 300, 200, "png", 0, False)
|
||||
state = self.wait_until(lambda s: len(s["layers"]["Eraser"]) == 1 and s["layers"]["Eraser"][0]["done"] and s["texts"]["Eraser"]
|
||||
and s["texts"]["Eraser"][0]["text"] == "Box" and len(s["images"]) == 1 and s["image"]["ready"], "the stroke, the box and the image")
|
||||
origin = state["origin"]
|
||||
scale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;")
|
||||
px = lambda x: (x - origin[0]) * scale
|
||||
py = lambda y: (y - origin[1]) * scale
|
||||
hover_at = lambda x, y: firefox.run(HOVER, (x - origin[0]) / state["page"]["width"], (y - origin[1]) / state["page"]["height"])
|
||||
stroke = state["layers"]["Eraser"][0]
|
||||
p = stroke["points"]
|
||||
halves = [stroke["width"] * (0.36 + 4 * q * q) / 2 for q in p[2::3]]
|
||||
left = min(x - h for x, h in zip(p[0::3], halves))
|
||||
right = max(x + h for x, h in zip(p[0::3], halves))
|
||||
top = min(y - h for y, h in zip(p[1::3], halves))
|
||||
bottom = max(y + h for y, h in zip(p[1::3], halves))
|
||||
# With the pen, nothing; with the eraser, the stroke's ink boxed in red.
|
||||
hover_at(p[0], p[1])
|
||||
self.assertEqual(cues(), [])
|
||||
choose("eraser")
|
||||
hover_at(p[0], p[1])
|
||||
boxes = cues()
|
||||
self.assertEqual(len(boxes), 1)
|
||||
self.assertEqual(boxes[0][4], "rgb(229, 57, 53)")
|
||||
for got, want in zip(boxes[0], overlay_box(px(left), py(top), px(right), py(bottom), 2)):
|
||||
self.assertAlmostEqual(got, want, delta=0.01)
|
||||
# The text block, by its box; the image, by a tap's rule; empty
|
||||
# paper, nothing; the cue goes when the pointer leaves the page.
|
||||
box = state["texts"]["Eraser"][0]
|
||||
hover_at(box["x"] + 20, box["y"] + 20)
|
||||
boxes = cues()
|
||||
self.assertEqual(len(boxes), 1)
|
||||
self.assertTrue(firefox.run(HOVER_CUE)["hidden"], "no blue cue beside the red one")
|
||||
for got, want in zip(boxes[0], overlay_box(px(box["x"]), py(box["y"]), px(box["x"] + box["width"]), py(box["y"] + box["height"]), 2)):
|
||||
self.assertAlmostEqual(got, want, delta=0.01)
|
||||
im = state["images"][0]
|
||||
hover_at(im["x"] + im["width"] / 2, im["y"] + im["height"] / 2)
|
||||
boxes = cues()
|
||||
self.assertEqual(len(boxes), 1)
|
||||
for got, want in zip(boxes[0], overlay_box(px(im["x"]), py(im["y"]), px(im["x"] + im["width"]), py(im["y"] + im["height"]), 2)):
|
||||
self.assertAlmostEqual(got, want, delta=0.01)
|
||||
hover_at(origin[0] + 0.9 * state["page"]["width"], origin[1] + 0.9 * state["page"]["height"])
|
||||
self.assertEqual(cues(), [])
|
||||
hover_at(p[0], p[1])
|
||||
self.assertEqual(len(cues()), 1)
|
||||
firefox.run("document.getElementById('page').dispatchEvent(new PointerEvent('pointerleave', {pointerId: 9, pointerType: 'mouse', bubbles: true}));")
|
||||
self.assertEqual(cues(), [])
|
||||
# Choosing another tool clears it.
|
||||
hover_at(p[0], p[1])
|
||||
self.assertEqual(len(cues()), 1)
|
||||
choose("pen")
|
||||
self.assertEqual(cues(), [])
|
||||
|
||||
def test_arrange_handwriting(self):
|
||||
"""Andy, 2026-10-14: under Arrange a handwritten stroke gets its
|
||||
box while the pointer hovers over its ink, kept while the pointer
|
||||
stays within the box; a press on the ink moves it, on the box's
|
||||
corner scales it; the hold with the pen leaves it alone."""
|
||||
firefox = self.firefox
|
||||
firefox.go(self.address("Host"))
|
||||
self.enter_name("Scribe", "start_session") # Not "Writer": another test counts the pages kept under that name.
|
||||
self.wait_until(lambda s: s["page"]["number"] == 1, "the page")
|
||||
choose = lambda tool: firefox.run(f"document.querySelector('[data-tool={tool}]').click();")
|
||||
cues = lambda: firefox.run("return [...document.querySelectorAll('.arrange_cue')].map((c) => c.className.split(' ')[1]);")
|
||||
firefox.run(PEN_WAVE, 0.3, 0.5)
|
||||
state = self.wait_until(lambda s: len(s["layers"]["Scribe"]) == 1 and s["layers"]["Scribe"][0]["done"], "the stroke")
|
||||
origin = state["origin"]
|
||||
scale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;")
|
||||
px = lambda x: (x - origin[0]) * scale
|
||||
py = lambda y: (y - origin[1]) * scale
|
||||
hover_at = lambda x, y: firefox.run(HOVER, (x - origin[0]) / state["page"]["width"], (y - origin[1]) / state["page"]["height"])
|
||||
stroke = state["layers"]["Scribe"][0]
|
||||
p = stroke["points"]
|
||||
halves = [stroke["width"] * (0.36 + 4 * q * q) / 2 for q in p[2::3]]
|
||||
left = min(x - h for x, h in zip(p[0::3], halves))
|
||||
right = max(x + h for x, h in zip(p[0::3], halves))
|
||||
top = min(y - h for y, h in zip(p[1::3], halves))
|
||||
bottom = max(y + h for y, h in zip(p[1::3], halves))
|
||||
choose("arrange")
|
||||
self.assertEqual(cues(), [], "no box until the pointer is over the ink")
|
||||
hover_at(p[0], p[1])
|
||||
self.assertEqual(cues(), ["stroke"])
|
||||
page_box = firefox.run("const b = document.getElementById('page').getBoundingClientRect(); return [b.left, b.top];")
|
||||
got = firefox.run("const b = document.querySelector('.arrange_cue.stroke').getBoundingClientRect(); return [b.left, b.top, b.right, b.bottom];")
|
||||
want = overlay_box(px(left), py(top), px(right), py(bottom), 2)
|
||||
for side, g, w in zip(("left", "top", "right", "bottom"), (got[0] - page_box[0], got[1] - page_box[1], got[2] - page_box[0], got[3] - page_box[1]), want):
|
||||
self.assertAlmostEqual(g, w, delta=0.01, msg=side)
|
||||
# Off the ink but within the box, the box stays; outside it, it goes.
|
||||
hover_at(left + 2, top + 2)
|
||||
self.assertEqual(cues(), ["stroke"], "kept within the box")
|
||||
hover_at(right + 200, bottom + 200)
|
||||
self.assertEqual(cues(), [], "gone outside the box")
|
||||
# A drag on the ink moves the whole stroke; Undo takes it back.
|
||||
firefox.drag("#page", px(p[0]), py(p[1]), px(p[0]) + 120, py(p[1]) + 60)
|
||||
moved = self.wait_until(lambda s: s["layers"]["Scribe"][0]["points"] != p, "the stroke moved")
|
||||
q = moved["layers"]["Scribe"][0]["points"]
|
||||
self.assertEqual(len(q), len(p))
|
||||
self.assertAlmostEqual(q[0] - p[0], 120 / scale, delta=2)
|
||||
self.assertAlmostEqual(q[1] - p[1], 60 / scale, delta=2)
|
||||
self.assertEqual(q[2::3], p[2::3], "the pressures stay")
|
||||
self.assertAlmostEqual(max(q[0::3]) - min(q[0::3]), max(p[0::3]) - min(p[0::3]), delta=0.3, msg="the same size")
|
||||
firefox.run("document.getElementById('undo').click();")
|
||||
self.wait_until(lambda s: s["layers"]["Scribe"][0]["points"] == p, "the move undone")
|
||||
# A drag on the shown box's bottom-right corner scales the stroke.
|
||||
hover_at(p[0], p[1])
|
||||
self.assertEqual(cues(), ["stroke"])
|
||||
firefox.drag("#page", px(right) + 3, py(bottom) + 3, px(right) + 150, py(bottom) + 40)
|
||||
scaled = self.wait_until(lambda s: s["layers"]["Scribe"][0]["points"] != p, "the stroke scaled")
|
||||
q = scaled["layers"]["Scribe"][0]["points"]
|
||||
self.assertAlmostEqual(min(q[0::3]), min(p[0::3]), delta=1, msg="the left edge stays")
|
||||
self.assertGreater(max(q[0::3]) - min(q[0::3]), max(p[0::3]) - min(p[0::3]) + 100 / scale)
|
||||
self.assertEqual(scaled["layers"]["Scribe"][0]["width"], stroke["width"], "the line width unchanged")
|
||||
# The hold with the pen draws a dot and leaves the stroke alone.
|
||||
choose("pen")
|
||||
self.assertEqual(cues(), [])
|
||||
firefox.run_until_done(PEN_HOLD, px(q[0]), py(q[1]), 40, 20, 700)
|
||||
held = self.wait_until(lambda s: len(s["layers"]["Scribe"]) == 2 and s["layers"]["Scribe"][1]["done"], "the pen's own stroke")
|
||||
self.assertEqual(held["layers"]["Scribe"][0]["points"], q, "the handwriting not moved by a hold")
|
||||
|
||||
def test_page_menu_and_help(self):
|
||||
"""Andy, 2026-10-14: the page and view commands are in a Page menu,
|
||||
|
||||
Reference in new issue
Block a user