The Shape tool, implied rectangles, and the palette floating again
A Shapes button right of Text draws a rectangle, an ellipse, a line of taps or an arrow in the pen's colour and width, with a fill colour of its own; a hold, a secondary press or two quick clicks open its menu. Text and Hide rectangles draw nothing on the page and show blue corners while the pointer hovers over them; free text's red corner mark is gone. Arrange moves and resizes shapes. The colour palette floats below "+" again instead of reflowing the toolbar. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
46a82bf3b7
commit
03907da8c9
12 files changed
+1502
-297
No files matched your search
+385
-23
@@ -36,6 +36,59 @@ import zlib
|
||||
|
||||
PROJECT = pathlib.Path(__file__).resolve().parent.parent
|
||||
|
||||
# The hover cue (the blue corners over an implied rectangle under the
|
||||
# pointer): whether it shows, and where, in page units.
|
||||
HOVER_CUE = """
|
||||
const c = document.querySelector('.hover_cue');
|
||||
const r = window.kokkoja.renderer, s = window.kokkoja.session;
|
||||
const page = document.getElementById('page').getBoundingClientRect();
|
||||
const b = c.getBoundingClientRect();
|
||||
const k = s.page.width / page.width;
|
||||
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};
|
||||
"""
|
||||
|
||||
# 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 = """
|
||||
const [x, y] = arguments;
|
||||
const r = window.kokkoja.renderer, s = window.kokkoja.session;
|
||||
const canvas = r.canvases.get(s.identity.id);
|
||||
const px = Math.round((x - s.page.left) * r.scale), py = Math.round((y - s.page.top) * r.scale);
|
||||
const data = canvas.getContext('2d').getImageData(px - 2, py - 2, 5, 5).data;
|
||||
for (let i = 3; i < data.length; i += 4) if (data[i] > 0) return true;
|
||||
return false;
|
||||
"""
|
||||
|
||||
# A mouse moving over the page with no button down, to a fraction of the
|
||||
# page: a hover.
|
||||
HOVER = """
|
||||
const [fx, fy] = arguments;
|
||||
const page = document.getElementById('page');
|
||||
const box = page.getBoundingClientRect();
|
||||
page.dispatchEvent(new PointerEvent('pointermove', {pointerId: 9, pointerType: 'mouse', isPrimary: true,
|
||||
clientX: box.left + fx * box.width, clientY: box.top + fy * box.height, button: -1, buttons: 0,
|
||||
bubbles: true, cancelable: true}));
|
||||
"""
|
||||
|
||||
# A press on the Shape button held for `wait` ms, released, and the click
|
||||
# that a browser sends after a press; calls back with whether the menu
|
||||
# is open.
|
||||
HOLD_SHAPE_BUTTON = """
|
||||
const [wait, done] = arguments;
|
||||
const b = document.getElementById('shape_tool');
|
||||
const box = b.getBoundingClientRect();
|
||||
const fire = (type) => b.dispatchEvent(new PointerEvent(type, {pointerId: 5, pointerType: 'mouse', isPrimary: true,
|
||||
clientX: box.left + 5, clientY: box.top + 5, button: 0, buttons: type === 'pointerup' ? 0 : 1,
|
||||
bubbles: true, cancelable: true}));
|
||||
fire('pointerdown');
|
||||
setTimeout(() => {
|
||||
fire('pointerup');
|
||||
b.click();
|
||||
done(!document.getElementById('shape_menu').hidden);
|
||||
}, wait);
|
||||
"""
|
||||
|
||||
# Describes the page as one participant holds it: for each layer, by the
|
||||
# participant's name, the strokes with their points.
|
||||
PAGE_STATE = """
|
||||
@@ -45,7 +98,8 @@ PAGE_STATE = """
|
||||
const texts = {};
|
||||
for (const layer of session.page.layers.values()) {
|
||||
layers[layer.name] = [...layer.strokes.values()].map(
|
||||
(stroke) => ({id: stroke.id, done: stroke.done, points: stroke.points}));
|
||||
(stroke) => ({id: stroke.id, done: stroke.done, points: stroke.points, color: stroke.color,
|
||||
width: stroke.width, filled: stroke.filled, fill: stroke.fill, shape: stroke.shape}));
|
||||
texts[layer.name] = [...layer.texts.values()];
|
||||
}
|
||||
// The first image, and all of them in sequence.
|
||||
@@ -607,6 +661,20 @@ 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."""
|
||||
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.firefox.run(HOVER, 0.01, 0.01)
|
||||
self.assertTrue(self.firefox.run(HOVER_CUE)["hidden"])
|
||||
|
||||
def wait_until_true(self, read, condition, what, seconds=15):
|
||||
"""Wait until condition(read()) is true and return the value read."""
|
||||
deadline = time.monotonic() + seconds
|
||||
@@ -1394,9 +1462,11 @@ class EndToEndTest(unittest.TestCase):
|
||||
|
||||
def test_arrange_tool(self):
|
||||
"""Andy, 2026-10-13: with the Arrange tool, blue corners mark every
|
||||
image, Hide and own text rectangle, a drag inside one moves it and
|
||||
a drag on a corner resizes it, at once, no hold; a press elsewhere
|
||||
does nothing. A guest without the right sees only their own text."""
|
||||
image (and, until 2026-10-14, every Hide and own text rectangle,
|
||||
which carry blue corners at all times since), a drag inside one
|
||||
moves it and a drag on a corner resizes it, at once, no hold; a
|
||||
press elsewhere does nothing. A guest without the right sees no
|
||||
cues, their own text rectangle's corners being drawn anyway."""
|
||||
firefox = self.firefox
|
||||
host_tab = firefox.current_tab()
|
||||
firefox.go(self.address("Late"))
|
||||
@@ -1447,8 +1517,9 @@ 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 get cues too; a corner
|
||||
# drag widens the text box, a drag inside moves the Hide.
|
||||
# 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.
|
||||
choose("text")
|
||||
firefox.run(TEXT_GESTURE, 0.1, 0.6, 0.3, 0.7)
|
||||
firefox.type_into(".text_field", "Box")
|
||||
@@ -1457,8 +1528,11 @@ class EndToEndTest(unittest.TestCase):
|
||||
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")
|
||||
choose("arrange")
|
||||
self.assertEqual(sorted(cues()), ["hide", "image", "text"])
|
||||
self.assertEqual(cues(), ["image"])
|
||||
box = with_all["texts"]["Arranger"][0]
|
||||
self.assert_hover_cue(box["x"] + 20, box["y"] + 20, [box["x"], box["y"], box["width"], box["height"]])
|
||||
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,
|
||||
px(box["x"] + box["width"]) + 100, py(box["y"] + box["height"]) + 40)
|
||||
widened = self.wait_until(lambda s: s["texts"]["Arranger"][0]["width"] > box["width"] + 80 / scale, "the box widened")
|
||||
@@ -1468,13 +1542,14 @@ 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()), 3)
|
||||
self.assertEqual(len(cues()), 1)
|
||||
|
||||
# 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 only their own text.
|
||||
# The watcher, without the right, is shown no cue; their own text
|
||||
# rectangle has its blue corners as everyone's has.
|
||||
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")
|
||||
@@ -1483,7 +1558,276 @@ 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(), ["text"])
|
||||
self.assertEqual(cues(), [])
|
||||
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"]])
|
||||
|
||||
def test_shapes(self):
|
||||
"""Andy, 2026-10-14: a Shape button right of Text draws the shape it
|
||||
names in the pen's colour and width; a hold on the button, or a
|
||||
secondary press, opens a menu of shapes. A rectangle, an ellipse
|
||||
and an arrow are dragged open and become one stroke each, the
|
||||
arrow a filled one; a line is made of taps, one point each, grows
|
||||
for everyone as it is made, and is finished by a tap on its last
|
||||
point or closed by one on its first. Text rectangles have no
|
||||
frame or ground any more, only blue corners on screen; free text
|
||||
shows its corners while the pointer hovers over it."""
|
||||
firefox = self.firefox
|
||||
host_tab = firefox.current_tab()
|
||||
firefox.go(self.address("Late"))
|
||||
self.enter_name("Drawer", "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("Viewer", "join_session")
|
||||
self.wait_until(lambda s: s["page"]["number"] == 1 and len(s["participants"]) == 2, "the viewer")
|
||||
firefox.switch_to(host_tab)
|
||||
choose = lambda tool: firefox.run(f"document.querySelector('[data-tool={tool}]').click();")
|
||||
label = lambda: firefox.run("return document.getElementById('shape_tool').textContent;")
|
||||
pressed = lambda: firefox.run("return document.querySelector('[data-tool][aria-pressed=true]').dataset.tool;")
|
||||
menu_open = lambda: firefox.run("return !document.getElementById('shape_menu').hidden;")
|
||||
pick = lambda shape: firefox.run(f"document.querySelector('#shape_menu [data-shape={shape}]').click();")
|
||||
strokes = lambda s: s["layers"].get("Drawer", [])
|
||||
state = self.state()
|
||||
origin, page = state["origin"], state["page"]
|
||||
at = lambda fx, fy: (origin[0] + fx * page["width"], origin[1] + fy * page["height"])
|
||||
|
||||
# The button names the default shape and keeps one width whatever it names.
|
||||
self.assertEqual(label(), "Rectangle")
|
||||
button_width = firefox.run("return document.getElementById('shape_tool').getBoundingClientRect().width;")
|
||||
choose("shape")
|
||||
self.assertEqual(pressed(), "shape")
|
||||
self.assertEqual(firefox.run("return ['colors', 'widths', 'text_style'].map((id) => document.getElementById(id).hidden);"),
|
||||
[False, False, True], "colours and widths show, as for the pen")
|
||||
|
||||
# A rectangle: one stroke of four corners, each twice for straight
|
||||
# sides (the first and last once), at the pen's width and the
|
||||
# nominal pressure, no fill.
|
||||
firefox.run(TEXT_GESTURE, 0.1, 0.1, 0.3, 0.25)
|
||||
drawn = self.wait_until(lambda s: len(strokes(s)) == 1 and strokes(s)[0]["done"], "the rectangle")
|
||||
rect = strokes(drawn)[0]
|
||||
self.assertEqual((rect["filled"], rect["color"], rect["width"]), (False, "#000000", 7))
|
||||
self.assertEqual(len(rect["points"]), 8 * 3)
|
||||
self.assertEqual(set(rect["points"][2::3]), {0.4})
|
||||
self.assertAlmostEqual(rect["points"][0], at(0.1, 0.1)[0], delta=1)
|
||||
self.assertAlmostEqual(rect["points"][13], at(0.3, 0.25)[1], delta=1)
|
||||
self.assertEqual(rect["points"][0:2], rect["points"][21:23], "closed at the first corner")
|
||||
# A tap draws nothing.
|
||||
firefox.run(TEXT_GESTURE, 0.5, 0.5, 0.5, 0.5)
|
||||
time.sleep(0.3)
|
||||
self.assertEqual(len(strokes(self.state())), 1)
|
||||
|
||||
# The menu: a quick press on the button chooses the tool and opens
|
||||
# nothing; a press held for the hold time opens it, and the click
|
||||
# that follows chooses nothing.
|
||||
choose("pen")
|
||||
self.assertFalse(menu_open())
|
||||
self.assertTrue(firefox.run_until_done(HOLD_SHAPE_BUTTON, 600), "the menu opens on a hold")
|
||||
self.assertEqual(pressed(), "pen", "the click after the hold chooses nothing")
|
||||
pick("ellipse")
|
||||
self.assertFalse(menu_open())
|
||||
self.assertEqual((label(), pressed()), ("Ellipse", "shape"))
|
||||
self.assertEqual(firefox.run("return document.getElementById('shape_tool').getBoundingClientRect().width;"), button_width)
|
||||
# Two quick clicks open it too, and it marks the current shape.
|
||||
firefox.run("const b = document.getElementById('shape_tool'); b.click(); b.click();")
|
||||
self.assertTrue(menu_open())
|
||||
self.assertEqual(firefox.run("return [...document.querySelectorAll('#shape_menu [data-shape]')].filter((i) => i.getAttribute('aria-checked') === 'true').map((i) => i.dataset.shape);"), ["ellipse"])
|
||||
self.assertEqual(firefox.run("return getComputedStyle(document.querySelector('#shape_menu [data-shape=ellipse]'), '::before').content;"), '"✓"')
|
||||
self.assertEqual(firefox.run("return getComputedStyle(document.querySelector('#shape_menu [data-shape=line]'), '::before').content;"), '""')
|
||||
firefox.chord("\ue00c")
|
||||
self.assertFalse(menu_open())
|
||||
firefox.run(TEXT_GESTURE, 0.4, 0.1, 0.6, 0.3)
|
||||
drawn = self.wait_until(lambda s: len(strokes(s)) == 2 and strokes(s)[1]["done"], "the ellipse")
|
||||
ellipse = strokes(drawn)[1]["points"]
|
||||
self.assertGreaterEqual(len(ellipse) // 3, 24)
|
||||
self.assertEqual(ellipse[0:2], ellipse[-3:-1], "closed")
|
||||
self.assertAlmostEqual(ellipse[0], at(0.6, 0.1)[0], delta=1, msg="begins at the right end of the middle line")
|
||||
self.assertAlmostEqual(ellipse[1], at(0.5, 0.2)[1], delta=1)
|
||||
# A secondary press (the right button, a pen's barrel button) opens it too.
|
||||
firefox.run("document.getElementById('shape_tool').dispatchEvent(new MouseEvent('contextmenu', {bubbles: true, cancelable: true}));")
|
||||
self.assertTrue(menu_open())
|
||||
pick("arrow")
|
||||
self.assertEqual(label(), "Arrow")
|
||||
|
||||
# An arrow: a filled polygon of seven points, its tip at the end of
|
||||
# the drag; the file holds it as a filled path.
|
||||
firefox.run(TEXT_GESTURE, 0.1, 0.5, 0.3, 0.5)
|
||||
drawn = self.wait_until(lambda s: len(strokes(s)) == 3 and strokes(s)[2]["done"], "the arrow")
|
||||
arrow = strokes(drawn)[2]
|
||||
self.assertTrue(arrow["filled"])
|
||||
self.assertEqual(len(arrow["points"]), 7 * 3)
|
||||
self.assertAlmostEqual(arrow["points"][9], at(0.3, 0.5)[0], delta=1)
|
||||
self.assertAlmostEqual(arrow["points"][10], at(0.3, 0.5)[1], delta=1)
|
||||
self.assertEqual(arrow["points"][6], arrow["points"][12], "the barbs are abreast")
|
||||
self.assertAlmostEqual(abs(arrow["points"][13] - arrow["points"][7]), 2 * 0.6 * 25 / 7 * 7, delta=1, msg="the head of a 0.7 mm arrow is 2.5 mm long, 3 mm wide")
|
||||
self.assertAlmostEqual(arrow["points"][9] - arrow["points"][6], 25, delta=0.5, msg="the head's length")
|
||||
self.assertEqual(arrow["shape"], "arrow")
|
||||
saved = self.wait_until_true(lambda: (self.notebooks / "Drawer notebook" / "Current.svg").read_text(),
|
||||
lambda text: "data-filled" in text, "the arrow saved")
|
||||
self.assertRegex(saved, r'<g data-stroke="[^"]+" data-width="7" data-points="[^"]+" data-filled="" data-shape="arrow" stroke="#000000"[^>]*><path d="M[^"]+Z" fill="#000000" stroke="none"/></g>')
|
||||
self.assertEqual(saved.count("<g data-stroke="), 3)
|
||||
|
||||
# A line: a tap for each point, the stroke growing with each (its
|
||||
# inner points twice, for straight sides); a tap on the last point
|
||||
# finishes it.
|
||||
firefox.run("document.getElementById('shape_tool').dispatchEvent(new MouseEvent('contextmenu', {bubbles: true, cancelable: true}));")
|
||||
pick("line")
|
||||
firefox.run(TEXT_GESTURE, 0.1, 0.7, 0.1, 0.7)
|
||||
begun = self.wait_until(lambda s: len(strokes(s)) == 4, "the line's first point")
|
||||
self.assertEqual((strokes(begun)[3]["done"], len(strokes(begun)[3]["points"])), (False, 3))
|
||||
firefox.run(TEXT_GESTURE, 0.3, 0.7, 0.3, 0.7)
|
||||
firefox.run(TEXT_GESTURE, 0.3, 0.9, 0.3, 0.9)
|
||||
grown = self.wait_until(lambda s: len(strokes(s)[3]["points"]) == 5 * 3, "two more points")
|
||||
self.assertFalse(strokes(grown)[3]["done"])
|
||||
p = strokes(grown)[3]["points"]
|
||||
self.assertEqual(p[3:5], p[6:8])
|
||||
self.assertEqual(p[9:11], p[12:14])
|
||||
# The rubber band follows a hovering pointer.
|
||||
firefox.run(HOVER, 0.5, 0.9)
|
||||
self.assertFalse(firefox.run("return document.querySelector('.shape_preview').hasAttribute('hidden');"))
|
||||
firefox.run(TEXT_GESTURE, 0.3, 0.9, 0.3, 0.9)
|
||||
done = self.wait_until(lambda s: strokes(s)[3]["done"], "the line finished by a tap on its last point")
|
||||
self.assertEqual(len(strokes(done)[3]["points"]), 5 * 3)
|
||||
self.assertTrue(firefox.run("return document.querySelector('.shape_preview').hasAttribute('hidden');"))
|
||||
# A tap on the first point closes the shape: a triangle.
|
||||
for fx, fy in ((0.5, 0.7), (0.7, 0.7), (0.6, 0.9), (0.5, 0.7)):
|
||||
firefox.run(TEXT_GESTURE, fx, fy, fx, fy)
|
||||
closed = self.wait_until(lambda s: len(strokes(s)) == 5 and strokes(s)[4]["done"], "the triangle")
|
||||
p = strokes(closed)[4]["points"]
|
||||
self.assertEqual(len(p), 6 * 3)
|
||||
self.assertEqual(p[0:2], p[15:17], "back at the first point")
|
||||
# Backspace takes the last point back, Escape finishes.
|
||||
for fx, fy in ((0.8, 0.7), (0.9, 0.7), (0.9, 0.9)):
|
||||
firefox.run(TEXT_GESTURE, fx, fy, fx, fy)
|
||||
self.wait_until(lambda s: len(strokes(s)) == 6 and len(strokes(s)[5]["points"]) == 5 * 3, "three points")
|
||||
firefox.chord("\ue003")
|
||||
shortened = self.wait_until(lambda s: len(strokes(s)) == 6 and len(strokes(s)[5]["points"]) == 3 * 3, "two points left")
|
||||
self.assertFalse(strokes(shortened)[5]["done"])
|
||||
firefox.chord("\ue00c")
|
||||
self.wait_until(lambda s: strokes(s)[5]["done"], "the line finished by Escape")
|
||||
|
||||
# The viewer holds the same six strokes, the arrow filled.
|
||||
firefox.switch_to(guest_tab)
|
||||
theirs = self.wait_until(lambda s: len(strokes(s)) == 6 and all(one["done"] for one in strokes(s)), "the shapes at the viewer")
|
||||
self.assertEqual(strokes(theirs), strokes(self.state()))
|
||||
firefox.switch_to(host_tab)
|
||||
self.assertEqual(strokes(theirs), strokes(self.state()))
|
||||
|
||||
# Undo takes the last line back; the eraser removes the rectangle
|
||||
# like any stroke.
|
||||
firefox.run("document.getElementById('undo').click();")
|
||||
self.wait_until(lambda s: len(strokes(s)) == 5, "the line undone")
|
||||
choose("eraser")
|
||||
firefox.run(TEXT_GESTURE, 0.2, 0.1, 0.2, 0.1)
|
||||
self.wait_until(lambda s: len(strokes(s)) == 4 and strokes(s)[0]["id"] != rect["id"], "the rectangle erased")
|
||||
|
||||
# With Extend off a shape that would reach past the page is not drawn.
|
||||
firefox.run("const box = document.getElementById('extend'); box.checked = false; box.dispatchEvent(new Event('change'));")
|
||||
choose("shape")
|
||||
firefox.run("document.getElementById('shape_tool').dispatchEvent(new MouseEvent('contextmenu', {bubbles: true, cancelable: true}));")
|
||||
pick("rectangle")
|
||||
firefox.run(TEXT_GESTURE, 0.9, 0.5, 0.995, 0.6)
|
||||
time.sleep(0.4)
|
||||
self.assertEqual(len(strokes(self.state())), 4)
|
||||
firefox.run("const box = document.getElementById('extend'); box.checked = true; box.dispatchEvent(new Event('change'));")
|
||||
|
||||
# A fill colour from the menu's row, a state of its own: the
|
||||
# rectangle's polygon is filled beneath its line, in the file too;
|
||||
# a closed line is filled, an open one not; "No fill" ends it.
|
||||
firefox.run("document.getElementById('shape_tool').dispatchEvent(new MouseEvent('contextmenu', {bubbles: true, cancelable: true}));")
|
||||
firefox.run("document.querySelector('#shape_fill [aria-label=\"Fill #43a047\"]').click();")
|
||||
self.assertTrue(menu_open(), "a fill keeps the menu open")
|
||||
pick("rectangle")
|
||||
firefox.run(TEXT_GESTURE, 0.1, 0.3, 0.3, 0.45)
|
||||
filled = self.wait_until(lambda s: len(strokes(s)) == 5 and strokes(s)[4]["done"], "the filled rectangle")
|
||||
self.assertEqual((strokes(filled)[4]["fill"], strokes(filled)[4]["color"], strokes(filled)[4]["shape"]), ("#43a047", "#000000", "rectangle"))
|
||||
saved = self.wait_until_true(lambda: (self.notebooks / "Drawer notebook" / "Current.svg").read_text(),
|
||||
lambda text: 'data-fill="' in text, "the fill saved")
|
||||
self.assertRegex(saved, r'data-fill="#43a047" data-shape="rectangle"[^>]*><path d="M[^"]+Z" fill="#43a047" stroke="none"/><path d="M')
|
||||
firefox.run("document.getElementById('shape_tool').dispatchEvent(new MouseEvent('contextmenu', {bubbles: true, cancelable: true}));")
|
||||
pick("line")
|
||||
for fx, fy in ((0.5, 0.3), (0.7, 0.3), (0.6, 0.45), (0.5, 0.3)):
|
||||
firefox.run(TEXT_GESTURE, fx, fy, fx, fy)
|
||||
triangle = self.wait_until(lambda s: len(strokes(s)) == 6 and strokes(s)[5]["done"], "a filled triangle")
|
||||
self.assertEqual(strokes(triangle)[5]["fill"], "#43a047")
|
||||
saved = self.wait_until_true(lambda: (self.notebooks / "Drawer notebook" / "Current.svg").read_text(),
|
||||
lambda text: text.count('data-fill="') == 2, "the triangle saved")
|
||||
self.assertEqual(saved.count('fill="#43a047" stroke="none"'), 2)
|
||||
firefox.run("document.getElementById('shape_tool').dispatchEvent(new MouseEvent('contextmenu', {bubbles: true, cancelable: true}));")
|
||||
firefox.run("document.querySelector('#shape_fill [aria-label=\"No fill\"]').click();")
|
||||
pick("line")
|
||||
for fx, fy in ((0.8, 0.3), (0.9, 0.45), (0.9, 0.45)):
|
||||
firefox.run(TEXT_GESTURE, fx, fy, fx, fy)
|
||||
unfilled = self.wait_until(lambda s: len(strokes(s)) == 7 and strokes(s)[6]["done"], "an unfilled line")
|
||||
self.assertIsNone(strokes(unfilled)[6]["fill"])
|
||||
|
||||
# Arrange marks one's own shapes and moves or resizes them: a drag
|
||||
# inside the filled rectangle moves it whole, a corner drag of the
|
||||
# triangle resizes it, the arrow keeps its head when moved; the
|
||||
# viewer follows; Undo takes a move back.
|
||||
cues = lambda: firefox.run("return [...document.querySelectorAll('.arrange_cue')].map((c) => c.className.split(' ')[1]);")
|
||||
choose("arrange")
|
||||
self.assertEqual(cues(), ["shape"] * 7)
|
||||
scale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;")
|
||||
px = lambda x: (x - origin[0]) * scale
|
||||
py = lambda y: (y - origin[1]) * scale
|
||||
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")
|
||||
after = strokes(moved)[4]["points"]
|
||||
self.assertEqual(len(after), len(before))
|
||||
for i in range(0, len(before), 3):
|
||||
self.assertAlmostEqual(after[i] - before[i], 100 / scale, delta=2)
|
||||
self.assertAlmostEqual(after[i + 1] - before[i + 1], 50 / scale, delta=2)
|
||||
self.assertEqual((strokes(moved)[4]["id"], strokes(moved)[4]["fill"]), (strokes(filled)[4]["id"], "#43a047"))
|
||||
firefox.switch_to(guest_tab)
|
||||
self.wait_until(lambda s: strokes(s)[4]["points"] == after, "the moved rectangle at the viewer")
|
||||
firefox.switch_to(host_tab)
|
||||
firefox.run("document.getElementById('undo').click();")
|
||||
self.wait_until(lambda s: strokes(s)[4]["points"] == before, "the move undone")
|
||||
tri = strokes(triangle)[5]["points"]
|
||||
right, bottom = max(tri[0::3]), max(tri[1::3])
|
||||
firefox.drag("#page", px(right) - 3, py(bottom) - 3, px(right) + 80, py(bottom) + 60)
|
||||
resized = self.wait_until(lambda s: strokes(s)[5]["points"] != tri, "the triangle resized")
|
||||
p = strokes(resized)[5]["points"]
|
||||
self.assertAlmostEqual(min(p[0::3]), min(tri[0::3]), delta=1, msg="the left edge stays")
|
||||
self.assertAlmostEqual(max(p[0::3]), right + 80 / scale, delta=2)
|
||||
self.assertAlmostEqual(max(p[1::3]), bottom + 60 / scale, delta=2)
|
||||
self.assertEqual(p[0:2], p[-3:-1], "still closed")
|
||||
head = arrow["points"][13] - arrow["points"][7]
|
||||
firefox.drag("#page", px(at(0.2, 0.5)[0]), py(at(0.2, 0.5)[1]), px(at(0.2, 0.5)[0]), py(at(0.2, 0.5)[1]) + 40)
|
||||
arrow_moved = self.wait_until(lambda s: strokes(s)[1]["points"] != arrow["points"], "the arrow moved") # Index 1 since the rectangle's erasure.
|
||||
a = strokes(arrow_moved)[1]["points"]
|
||||
self.assertAlmostEqual(a[13] - a[7], head, delta=0.5, msg="the head keeps its size")
|
||||
self.assertAlmostEqual(a[10] - arrow["points"][10], 40 / scale, delta=2)
|
||||
choose("pen")
|
||||
self.assertEqual(cues(), [])
|
||||
|
||||
# A text rectangle: nothing along its edges, nothing of it in the
|
||||
# file; it and free text show their corners while the pointer
|
||||
# hovers over them, and not otherwise.
|
||||
choose("text")
|
||||
firefox.run(TEXT_GESTURE, 0.36, 0.58, 0.48, 0.66) # Clear of every shape (the moved arrow's tip is near 0.3, 0.53).
|
||||
firefox.type_into(".text_field", "Box")
|
||||
choose("pen")
|
||||
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"]])
|
||||
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)
|
||||
self.assertNotIn('stroke="#e53935"', saved.split("<desc>Box</desc>")[1].split("</g>")[0])
|
||||
choose("text")
|
||||
firefox.run(TEXT_GESTURE, 0.75, 0.6, 0.75, 0.6)
|
||||
firefox.type_into(".text_field", "Frei")
|
||||
choose("pen")
|
||||
free = self.wait_until(lambda s: len(s["texts"]["Drawer"]) == 2 and s["texts"]["Drawer"][1]["text"] == "Frei", "the free text")["texts"]["Drawer"][1]
|
||||
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"]])
|
||||
|
||||
def test_page_menu_and_help(self):
|
||||
"""Andy, 2026-10-14: the page and view commands are in a Page menu,
|
||||
@@ -2165,10 +2509,11 @@ class EndToEndTest(unittest.TestCase):
|
||||
block = self.wait_until(lambda s: s["texts"]["Teacher"][0]["size"] == 49.4,
|
||||
"the text back at 14 points")["texts"]["Teacher"][0]
|
||||
|
||||
# A drag opens a text rectangle, shown dotted while dragging: a red
|
||||
# box, white inside, in which the text wraps and which grows
|
||||
# downward by lines as the text needs. A hold at its corner
|
||||
# resizes it freely; a hold inside moves it.
|
||||
# A drag opens a text rectangle, shown dotted while dragging: a
|
||||
# region in which the text wraps and which grows downward by lines
|
||||
# as the text needs, marked by blue corners (until 2026-10-14 a red
|
||||
# box, white inside, which the field drew as an outline). A hold
|
||||
# at its corner resizes it freely; a hold inside moves it.
|
||||
firefox.run("""
|
||||
const page = document.getElementById('page');
|
||||
const box = page.getBoundingClientRect();
|
||||
@@ -2190,7 +2535,7 @@ class EndToEndTest(unittest.TestCase):
|
||||
self.assertGreater(preview["height"], 10)
|
||||
firefox.run(TEXT_GESTURE, 0.1, 0.75, 0.6, 0.78)
|
||||
self.assertTrue(firefox.run("return document.querySelector('.rect_preview').hidden;"))
|
||||
self.assertEqual(firefox.run("return getComputedStyle(document.querySelector('.text_field')).outlineStyle;"), "solid")
|
||||
self.assertEqual(firefox.run("return getComputedStyle(document.querySelector('.text_field')).outlineStyle;"), "none")
|
||||
firefox.type_into(".text_field", "Ein langer Satz, der an der rechten Kante umbrochen wird.")
|
||||
wrapped = self.wait_until(lambda s: len(s["texts"]["Teacher"]) == 2 and
|
||||
len(s["texts"]["Teacher"][1]["lines"]) > 1, "the wrapped text")
|
||||
@@ -2253,15 +2598,16 @@ class EndToEndTest(unittest.TestCase):
|
||||
# The page file holds the text, and reads it back.
|
||||
saved_text = self.wait_until_true(
|
||||
lambda: (self.notebooks / "Teacher notebook" / "Current.svg").read_text(),
|
||||
lambda saved: "<text " in saved and "zwei Zeilen" in saved and 'data-ground=' in saved,
|
||||
lambda saved: "<text " in saved and "zwei Zeilen" in saved and "Weiter." in saved,
|
||||
"the text to be saved")
|
||||
# The rectangle's white ground lies beneath every layer, its red
|
||||
# frame within its own.
|
||||
self.assertLess(saved_text.index('<g data-grounds='), saved_text.index('<g data-layer='))
|
||||
self.assertIn('fill="none" stroke="#e53935"', saved_text)
|
||||
# Free text carries its corner mark, a text rectangle its frame.
|
||||
self.assertEqual(saved_text.count('<path d="M'), 1)
|
||||
self.assertEqual(saved_text.count('<rect x='), 2) # The sheet, and the frame.
|
||||
# A text block draws nothing of its own on the page (Andy,
|
||||
# 2026-10-14 and 2026-10-14: an implied rectangle, blue corners on
|
||||
# screen while the pointer hovers; a red frame on a white ground
|
||||
# and a red L on free text until then).
|
||||
self.assertNotIn('#e53935', saved_text)
|
||||
self.assertEqual(saved_text.count('<path d="M'), 0)
|
||||
self.assertNotIn('data-ground', saved_text)
|
||||
self.assertEqual(saved_text.count('<rect x='), 1) # The sheet alone.
|
||||
round_trip = firefox.run("""
|
||||
return (async () => {
|
||||
const {pageToSvg, pageFromSvg} = await import('./js/page_file.js');
|
||||
@@ -2742,8 +3088,21 @@ class EndToEndTest(unittest.TestCase):
|
||||
return [...document.querySelectorAll('#color_palette .swatch')]
|
||||
.map((s) => s.classList.contains('missing') ? '' : s.getAttribute('aria-label').replace('Color ', ''));
|
||||
""")
|
||||
row_height = firefox.run("return document.querySelector('.toolbar').getBoundingClientRect().height;")
|
||||
firefox.run("document.getElementById('more_colors').click();")
|
||||
self.assertFalse(firefox.run("return document.getElementById('color_palette').hidden;"))
|
||||
# The palette floats below "+", above the page; it does not reflow
|
||||
# the toolbar (it did from the Page menu's commit of 2026-10-14
|
||||
# until the same day, when its positioning had moved to the shared
|
||||
# popup rule and the palette had not been given that class).
|
||||
self.assertEqual(firefox.run("return document.querySelector('.toolbar').getBoundingClientRect().height;"), row_height)
|
||||
placed = firefox.run("""
|
||||
const p = document.getElementById('color_palette').getBoundingClientRect();
|
||||
const b = document.getElementById('more_colors').getBoundingClientRect();
|
||||
return {position: getComputedStyle(document.getElementById('color_palette')).position,
|
||||
below: p.top >= b.bottom, left: Math.abs(p.left - b.left) < 1};
|
||||
""")
|
||||
self.assertEqual(placed, {"position": "absolute", "below": True, "left": True})
|
||||
shown = palette()
|
||||
self.assertEqual(len(shown), 32)
|
||||
self.assertEqual([i for i, c in enumerate(shown) if c == ""], [0, 3, 5, 24, 31])
|
||||
@@ -2753,6 +3112,9 @@ class EndToEndTest(unittest.TestCase):
|
||||
""")
|
||||
self.assertTrue(firefox.run("return document.getElementById('color_palette').hidden;"))
|
||||
self.assertEqual(swatches()[-1], "#8e24aa")
|
||||
# The added colour is among the shapes' fill colours as well.
|
||||
self.assertEqual(firefox.run("return [...document.querySelectorAll('#shape_fill .swatch')].map((s) => s.getAttribute('aria-label'));"),
|
||||
["No fill", "Fill #000000", "Fill #e53935", "Fill #43a047", "Fill #1e88e5", "Fill #ffffff", "Fill #8e24aa"])
|
||||
self.assertEqual(firefox.run("""
|
||||
const pressed = [...document.querySelectorAll('#colors .swatch[aria-pressed=true]')];
|
||||
return pressed.map((s) => s.getAttribute('aria-label'));
|
||||
|
||||
Reference in new issue
Block a user