feat: F5.6d a drag is one change of the document; the others see it through the presence
Moving a device, moving or resizing a documentation item went into the shared document at every pointer move: 59 updates a second, each leaving about 18 bytes of history (M4 on test-filius). Now the editor keeps the drag while it lasts, shows it itself and tells the others through the awareness (moving, at most every 50 ms, the end at once), and writes it once when it is let go. A drag the browser cancels or a second finger turns into a pinch is written where it is; WebKit sends a second pointerdown for the first finger when another comes, which had lost the drag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,7 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import { JAVA_LABEL_FONT } from '../src/canvas/layout.js';
|
||||
import { drag, drawing, openEditor, serveEditor } from './editorPage.js';
|
||||
import { documentUpdates, drag, drawing, openEditor, serveEditor } from './editorPage.js';
|
||||
|
||||
let server: { readonly base: string; readonly close: () => Promise<void> };
|
||||
|
||||
@@ -69,9 +69,11 @@ test('makes, moves, resizes, colours and types documentation as Java does, and p
|
||||
// Java's paint in the documentation mode: grey at an opacity of 200.
|
||||
expect(await fill(page, rectangle.id)).toBe('rgba(204, 204, 204, 0.784)');
|
||||
|
||||
// Moved by its middle, resized by its corner to Java's smallest (20 × 20).
|
||||
// Moved by its middle, resized by its corner to Java's smallest (20 × 20); a drag is one change of the document, when it is let go.
|
||||
const changes = await documentUpdates(page);
|
||||
await drag(page, drop, { x: drop.x + 40, y: drop.y + 30 });
|
||||
await expect.poll(async () => (await docOf(page, rectangle.id))!.x - rectangle.x).toBeGreaterThan(20);
|
||||
expect(await documentUpdates(page)).toBe(changes + 1);
|
||||
const moved = (await docOf(page, rectangle.id))!;
|
||||
const corner = (await page.locator(`g[data-doc="${rectangle.id}"] .doc-focus`).boundingBox())!;
|
||||
await drag(page, { x: corner.x + corner.width - 2, y: corner.y + corner.height - 2 }, { x: corner.x - 50, y: corner.y - 50 });
|
||||
|
||||
@@ -35,6 +35,11 @@ export function drawing(page: Page): Promise<TopologyView> {
|
||||
return page.evaluate(() => (window as unknown as { filiusDev: { view: () => TopologyView } }).filiusDev.view());
|
||||
}
|
||||
|
||||
/** How many changes the document has had since the page opened it. */
|
||||
export function documentUpdates(page: Page): Promise<number> {
|
||||
return page.evaluate(() => (window as unknown as { filiusDev: { updates: () => number } }).filiusDev.updates());
|
||||
}
|
||||
|
||||
/** What the device sheet reads of a device. */
|
||||
export function sheetOf(page: Page, id: string): Promise<DeviceSheet | undefined> {
|
||||
return page.evaluate((each) => (window as unknown as { filiusDev: { sheet: (id: string) => DeviceSheet | undefined } }).filiusDev.sheet(each), id);
|
||||
|
||||
@@ -14,7 +14,7 @@ export interface Point {
|
||||
readonly y: number;
|
||||
}
|
||||
|
||||
type TouchType = 'touchStart' | 'touchMove' | 'touchEnd';
|
||||
type TouchType = 'touchStart' | 'touchMove' | 'touchEnd' | 'touchCancel';
|
||||
|
||||
export interface Fingers {
|
||||
tap(at: Point): Promise<void>;
|
||||
@@ -24,6 +24,10 @@ export interface Fingers {
|
||||
drag(from: Point, to: Point, steps?: number): Promise<void>;
|
||||
/** Two fingers either side of a middle, moved from one distance apart to another. */
|
||||
pinch(middle: Point, from: number, to: number, steps?: number): Promise<void>;
|
||||
/** One finger moved from one point to another, then a second one put down beside it and both moved apart. */
|
||||
dragThenPinch(from: Point, to: Point): Promise<void>;
|
||||
/** One finger moved from one point to another, then the touch cancelled (the browser took it). */
|
||||
dragThenCancel(from: Point, to: Point): Promise<void>;
|
||||
}
|
||||
|
||||
interface WebKitSession {
|
||||
@@ -47,8 +51,8 @@ export async function fingers(page: Page): Promise<Fingers> {
|
||||
// WebKit ends a touch with the fingers lifted, not with none.
|
||||
let last: readonly Point[] = [];
|
||||
send = (type, points) => {
|
||||
const touched = type === 'touchEnd' ? last : points;
|
||||
if (type !== 'touchEnd') last = points;
|
||||
const touched = type === 'touchEnd' || type === 'touchCancel' ? last : points;
|
||||
if (type !== 'touchEnd' && type !== 'touchCancel') last = points;
|
||||
return session.send('Input.dispatchTouchEvent', { type, touchPoints: touched.map(({ x, y }, id) => ({ x: Math.round(x), y: Math.round(y), id })) });
|
||||
};
|
||||
} else {
|
||||
@@ -81,5 +85,18 @@ export async function fingers(page: Page): Promise<Fingers> {
|
||||
await page.waitForTimeout(150);
|
||||
await send('touchEnd', []);
|
||||
},
|
||||
async dragThenPinch(from, to) {
|
||||
await send('touchStart', [from]);
|
||||
for (const share of steps(8)) await send('touchMove', [{ x: from.x + (to.x - from.x) * share, y: from.y + (to.y - from.y) * share }]);
|
||||
await send('touchStart', [to, { x: to.x + 60, y: to.y }]);
|
||||
for (const share of steps(4)) await send('touchMove', [{ x: to.x - 20 * share, y: to.y }, { x: to.x + 60 + 20 * share, y: to.y }]);
|
||||
await page.waitForTimeout(150);
|
||||
await send('touchEnd', []);
|
||||
},
|
||||
async dragThenCancel(from, to) {
|
||||
await send('touchStart', [from]);
|
||||
for (const share of steps(8)) await send('touchMove', [{ x: from.x + (to.x - from.x) * share, y: from.y + (to.y - from.y) * share }]);
|
||||
await send('touchCancel', []);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@ export interface ServerF {
|
||||
service(method: string, path: string, body?: unknown): Promise<Response>;
|
||||
/** The size of a blob in F's store, none without it. */
|
||||
blobSize(sha: string): Promise<number | undefined>;
|
||||
/** How many updates of a project F has stored since it last compacted it. */
|
||||
updateCount(project: string): Promise<number>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -97,6 +99,10 @@ export async function startServerF(editorBase: string, editorFolder?: string): P
|
||||
const result = await sql.query<{ size: number }>('SELECT size FROM filius_blob WHERE sha = $1', [sha]);
|
||||
return result.rows[0]?.size;
|
||||
},
|
||||
async updateCount(project) {
|
||||
const result = await sql.query<{ count: string }>('SELECT count(*) FROM filius_doc_update WHERE project_uuid = $1', [project]);
|
||||
return Number(result.rows[0]!.count);
|
||||
},
|
||||
service: (method, path, body) => fetch(`${base}${path}`, { method, headers: { 'x-filius-service-key': SERVICE_KEY }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }),
|
||||
async close() {
|
||||
const ended = new Promise((resolve) => child.once('exit', resolve));
|
||||
|
||||
@@ -163,6 +163,32 @@ test('three people in one project of F: moving, reading only, one simulation for
|
||||
await expect(canvas(mia)).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows a drag to the others while it lasts, and stores it in F once, when it is let go', async ({ browser }) => {
|
||||
const project = await f.project('java-examples/internetwork_komplett.fls');
|
||||
const mia = await person(browser, project.id, 'Mia');
|
||||
const ben = await person(browser, project.id, 'Ben');
|
||||
const id = project.node('Rechner 3.10');
|
||||
const before = await box(ben, id);
|
||||
await expect.poll(() => f.updateCount(project.id)).toBe(0);
|
||||
const from = await onDevice(mia, id);
|
||||
await mia.mouse.move(from.x, from.y);
|
||||
await mia.mouse.down();
|
||||
for (let step = 1; step <= 30; step++) {
|
||||
await mia.mouse.move(from.x - 3 * step, from.y + 2 * step);
|
||||
await mia.waitForTimeout(16);
|
||||
}
|
||||
// Still held: Mia and Ben see it on its way, F has stored nothing of it.
|
||||
expect(await box(mia, id)).not.toBe(before);
|
||||
await expect.poll(async () => (await box(ben, id)) !== before).toBe(true);
|
||||
expect(await f.updateCount(project.id)).toBe(0);
|
||||
await mia.mouse.up();
|
||||
await expect.poll(async () => box(ben, id)).toBe(await box(mia, id));
|
||||
await expect.poll(() => f.updateCount(project.id)).toBe(1);
|
||||
await mia.waitForTimeout(500);
|
||||
expect(await f.updateCount(project.id)).toBe(1);
|
||||
expect(await box(ben, id)).toBe(await box(mia, id));
|
||||
});
|
||||
|
||||
test('takes a large picture one person imports to F as a blob, and shows it to another', async ({ browser }) => {
|
||||
test.setTimeout(120_000);
|
||||
const project = await f.project('java-examples/internetwork_komplett.fls');
|
||||
|
||||
@@ -138,6 +138,37 @@ test('zooms the surface with two fingers and never the page (M7)', async ({ page
|
||||
expect(await page_()).toEqual({ scale: 1, x: 0, y: 0 });
|
||||
});
|
||||
|
||||
test('puts a device or an item down where it is when a second finger comes or the browser takes the touch', async ({ page }) => {
|
||||
for (const gesture of ['dragThenPinch', 'dragThenCancel'] as const) {
|
||||
// Each on a fresh page: the pinch zooms, and the next device would be elsewhere.
|
||||
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
|
||||
const finger = await fingers(page);
|
||||
const id = (await drawing(page)).nodes[0]!.id;
|
||||
const before = (await drawing(page)).nodes.find((node) => node.id === id)!;
|
||||
const from = await onDevice(page, id);
|
||||
await finger[gesture](from, { x: from.x + 40, y: from.y + 60 });
|
||||
// In the document, not only on the screen: the drag ended there and then.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const now = (await drawing(page)).nodes.find((node) => node.id === id)!;
|
||||
return now.x !== before.x || now.y !== before.y;
|
||||
}, { message: gesture })
|
||||
.toBe(true);
|
||||
const shown = await page.locator(`g.device[data-node="${id}"]`).getAttribute('data-box');
|
||||
await page.waitForTimeout(300);
|
||||
expect(await page.locator(`g.device[data-node="${id}"]`).getAttribute('data-box')).toBe(shown);
|
||||
}
|
||||
// A rectangle of the documentation, its drag taken by the browser: it too stays where it was dragged.
|
||||
await openEditor(page, server.base, 'nat_with_mapped_port.fls');
|
||||
const finger = await fingers(page);
|
||||
await page.locator('button[name="documentationMode"]').tap();
|
||||
const rectangle = (await drawing(page)).docs.find((item) => item.kind === 'rectangle')!;
|
||||
const area = (await page.locator(`g[data-doc="${rectangle.id}"]`).boundingBox())!;
|
||||
const middle = { x: area.x + area.width / 2, y: area.y + area.height / 2 };
|
||||
await finger.dragThenCancel(middle, { x: middle.x + 40, y: middle.y + 30 });
|
||||
await expect.poll(async () => (await drawing(page)).docs.find((item) => item.id === rectangle.id)!.x).not.toBe(rectangle.x);
|
||||
});
|
||||
|
||||
test('reaches every action without a right click: a long press, the three-dot button, the documentation', async ({ page }) => {
|
||||
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
|
||||
const finger = await fingers(page);
|
||||
|
||||
Reference in New Issue
Block a user