Arrange tool, and a hold that is easier to find
An Arrange tool marks the corners of every image, Hide and own text rectangle in blue and moves or resizes them by a plain drag, a press elsewhere doing nothing. The hold that does the same with any tool is shorter (450 ms), with more slack, and shows all four corners. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
94a3c1ba0e
commit
8ecf3e94a6
6 files changed
+260
-25
No files matched your search
+40
-5
@@ -460,7 +460,7 @@ async function enterSession(invitationText, name, notebook) {
|
||||
showInvitationCode(config.site ? input('invitation').value : '');
|
||||
|
||||
const isHost = identity.id === invitation.hostId;
|
||||
let tool = /** @type {'pen' | 'eraser' | 'text' | 'hide'} */ ('pen');
|
||||
let tool = /** @type {'pen' | 'eraser' | 'text' | 'hide' | 'arrange'} */ ('pen');
|
||||
let color = DEFAULT_COLOR;
|
||||
let width = WIDTHS[1];
|
||||
let textSize = TEXT_SIZE;
|
||||
@@ -510,6 +510,7 @@ async function enterSession(invitationText, name, notebook) {
|
||||
onTextChanged: (layerId) => {
|
||||
syncLayers();
|
||||
renderer.redrawLayer(layerId);
|
||||
if (layerId === session.identity.id) refreshArrangeCues();
|
||||
writer.changed();
|
||||
},
|
||||
onPageReplaced: (oldPage, newPage) => {
|
||||
@@ -529,11 +530,13 @@ async function enterSession(invitationText, name, notebook) {
|
||||
syncLayers();
|
||||
showParticipants(session, renderer);
|
||||
showPageNumber();
|
||||
refreshArrangeCues();
|
||||
},
|
||||
onImagesChanged: () => {
|
||||
log(`Page ${session.page.number}: ${session.page.images.length} images, ${session.page.hides.length} hides`);
|
||||
renderer.showImages();
|
||||
renderer.drawHides();
|
||||
refreshArrangeCues();
|
||||
writer.writeNow();
|
||||
},
|
||||
onPageResized: () => {
|
||||
@@ -552,6 +555,7 @@ async function enterSession(invitationText, name, notebook) {
|
||||
(session.isHost ? `; guests allowed: ${session.permitted.size}` : ''));
|
||||
showHostTools();
|
||||
showParticipants(session, renderer);
|
||||
refreshArrangeCues();
|
||||
},
|
||||
onParticipantsChanged: () => {
|
||||
syncLayers();
|
||||
@@ -781,6 +785,32 @@ async function enterSession(invitationText, name, notebook) {
|
||||
return block ? textBounds(block) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* With the Arrange tool chosen, mark what a press can move or resize:
|
||||
* the images and Hides (for the host and co-hosts) and one's own text
|
||||
* rectangles; free text can be moved but has no corners. Called
|
||||
* whenever any of them, or the right, may have changed.
|
||||
*/
|
||||
function refreshArrangeCues() {
|
||||
if (tool !== 'arrange') {
|
||||
renderer.setArrangeCues([]);
|
||||
return;
|
||||
}
|
||||
const page = session.page;
|
||||
/** @type {{rect: import('./page_model.js').Rectangle, kind: 'image' | 'text' | 'hide'}[]} */
|
||||
const cues = [];
|
||||
if (session.mayMakePages()) {
|
||||
for (const im of page.images) cues.push({ kind: 'image', rect: { left: im.x, top: im.y, width: im.width, height: im.height } });
|
||||
}
|
||||
for (const block of session.ownLayer.texts.values()) {
|
||||
if (block.height !== null) cues.push({ kind: 'text', rect: textBounds(block) });
|
||||
}
|
||||
if (session.mayMakePages()) {
|
||||
for (const h of page.hides) cues.push({ kind: 'hide', rect: { left: h.x, top: h.y, width: h.width, height: h.height } });
|
||||
}
|
||||
renderer.setArrangeCues(cues);
|
||||
}
|
||||
|
||||
/** @type {import('./pen_input.js').PenTarget} */
|
||||
const penTarget = {
|
||||
toPage: (clientX, clientY) => renderer.pageFromClient(clientX, clientY),
|
||||
@@ -871,6 +901,7 @@ async function enterSession(invitationText, name, notebook) {
|
||||
if (textEditor?.block?.id === block.id) textEditor.layout();
|
||||
}
|
||||
renderer.showGrip(currentHeldRect(held), held.corner);
|
||||
refreshArrangeCues();
|
||||
},
|
||||
holdEnd: () => {
|
||||
if (!held) return;
|
||||
@@ -881,6 +912,7 @@ async function enterSession(invitationText, name, notebook) {
|
||||
if (held.kind === 'hide') session.endHideEdit();
|
||||
held = null;
|
||||
renderer.showGrip(null);
|
||||
refreshArrangeCues();
|
||||
},
|
||||
eraseAlong: (x0, y0, x1, y1) => {
|
||||
textEditor?.stop();
|
||||
@@ -979,10 +1011,12 @@ async function enterSession(invitationText, name, notebook) {
|
||||
if (value !== tool) trace(`Tool: ${value}`);
|
||||
tool = value;
|
||||
// Only the chosen tool's settings show: the pen's colours and line
|
||||
// widths, the text's colours, size and font, nothing for the eraser.
|
||||
element('colors').hidden = value === 'eraser' || value === 'hide';
|
||||
// widths, the text's colours, size and font, nothing for the
|
||||
// eraser, Hide or Arrange.
|
||||
element('colors').hidden = value === 'eraser' || value === 'hide' || value === 'arrange';
|
||||
element('widths').hidden = value !== 'pen';
|
||||
element('text_style').hidden = value !== 'text';
|
||||
refreshArrangeCues();
|
||||
},
|
||||
setColor: (value) => {
|
||||
color = value;
|
||||
@@ -1249,7 +1283,7 @@ function press(group, chosen) {
|
||||
* @param {number} options.width Initial width.
|
||||
* @param {number} options.textSize
|
||||
* @param {import('./page_model.js').TextFont} options.textFont
|
||||
* @param {(tool: 'pen' | 'eraser' | 'text' | 'hide') => void} options.setTool
|
||||
* @param {(tool: 'pen' | 'eraser' | 'text' | 'hide' | 'arrange') => void} options.setTool
|
||||
* @param {(color: string) => void} options.setColor
|
||||
* @param {(width: number) => void} options.setWidth
|
||||
* @param {(size: number) => void} options.setTextSize
|
||||
@@ -1265,7 +1299,8 @@ function buildToolbar(options) {
|
||||
other.setAttribute('aria-pressed', String(other === button));
|
||||
}
|
||||
const chosen = button.getAttribute('data-tool');
|
||||
options.setTool(chosen === 'eraser' ? 'eraser' : chosen === 'text' ? 'text' : chosen === 'hide' ? 'hide' : 'pen');
|
||||
options.setTool(chosen === 'eraser' ? 'eraser' : chosen === 'text' ? 'text' : chosen === 'hide' ? 'hide'
|
||||
: chosen === 'arrange' ? 'arrange' : 'pen');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+23
-6
@@ -45,7 +45,7 @@ const focusSettleOn = !new URLSearchParams(location.search).has('no_focus_settle
|
||||
* @typedef {object} PenTarget
|
||||
* @property {(clientX: number, clientY: number) => [number, number]} toPage
|
||||
* Convert a window position to page units.
|
||||
* @property {() => 'pen' | 'eraser' | 'text' | 'hide'} tool The tool selected in the toolbar.
|
||||
* @property {() => 'pen' | 'eraser' | 'text' | 'hide' | 'arrange'} tool The tool selected in the toolbar.
|
||||
* @property {() => boolean} canWrite False while there is no page to write on.
|
||||
* @property {() => void} beginStroke
|
||||
* @property {(points: number[]) => void} addPoints
|
||||
@@ -85,12 +85,14 @@ const ZOOM_DEAD_ZONE = 4;
|
||||
|
||||
/**
|
||||
* A press that rests this long becomes a hold, which moves or scales the
|
||||
* image under it (Andy's teacher, 2026-10-08; 600 ms so that a careful
|
||||
* start of a stroke is not one). Moving farther than HOLD_SLACK_PX
|
||||
* before then makes it a stroke after all.
|
||||
* image, text block or Hide under it (Andy, 2026-10-08; 600 ms until
|
||||
* 2026-10-13, when the hold was made easier to find: shorter, and with
|
||||
* more slack for a pen held in the air). Longer than a tap, shorter than
|
||||
* a pause in writing (the start of a stroke is not one). Moving farther
|
||||
* than HOLD_SLACK_PX before that is a stroke, or erasing, as before.
|
||||
*/
|
||||
export const HOLD_MS = 600;
|
||||
export const HOLD_SLACK_PX = 6;
|
||||
export const HOLD_MS = 450;
|
||||
export const HOLD_SLACK_PX = 10;
|
||||
|
||||
/**
|
||||
* What the input device is delivering, for judging pen support.
|
||||
@@ -121,6 +123,8 @@ export function attachPenInput(surface, target) {
|
||||
/** @type {ReturnType<typeof setTimeout> | null} */
|
||||
let holdTimer = null;
|
||||
let holding = false;
|
||||
/** With the Arrange tool: the press took hold at once, or found nothing and does nothing. */
|
||||
let arranging = false;
|
||||
let pressX = 0;
|
||||
let pressY = 0;
|
||||
/** @type {PointerEvent | null} The eraser's press, not yet erased with. */
|
||||
@@ -246,6 +250,15 @@ export function attachPenInput(surface, target) {
|
||||
erasedTexts = 0;
|
||||
holding = false;
|
||||
eraseWaiting = null;
|
||||
// The Arrange tool holds at once: a press on an image, a text block
|
||||
// or a Hide moves it, on a corner resizes it, and a press elsewhere
|
||||
// does nothing (Andy, 2026-10-13).
|
||||
if (tool === 'arrange' && !erasing) {
|
||||
arranging = true;
|
||||
holding = target.beginHold?.(event.clientX, event.clientY) ?? false;
|
||||
trace(holding ? 'arrange: took hold' : 'arrange: nothing under the pointer');
|
||||
return;
|
||||
}
|
||||
// Resting the pointer, with any tool, may become a hold on an image.
|
||||
if (target.beginHold) {
|
||||
pressX = event.clientX;
|
||||
@@ -281,6 +294,7 @@ export function attachPenInput(surface, target) {
|
||||
target.holdMove?.(event.clientX, event.clientY);
|
||||
return;
|
||||
}
|
||||
if (arranging) return;
|
||||
if (holdTimer !== null && Math.hypot(event.clientX - pressX, event.clientY - pressY) > HOLD_SLACK_PX) {
|
||||
cancelHoldTimer();
|
||||
eraseFromPress();
|
||||
@@ -315,7 +329,10 @@ export function attachPenInput(surface, target) {
|
||||
cancelHoldTimer();
|
||||
if (holding) {
|
||||
holding = false;
|
||||
arranging = false;
|
||||
target.holdEnd?.();
|
||||
} else if (arranging) {
|
||||
arranging = false;
|
||||
} else if (zooming) {
|
||||
zooming = false;
|
||||
target.endZoom?.();
|
||||
|
||||
+73
-9
@@ -107,6 +107,19 @@ function strokeCrisp(context, polylines, color, width) {
|
||||
context.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Four handle marks, one for each corner of an overlay (the grip and the
|
||||
* Arrange cues), placed by their class.
|
||||
* @returns {HTMLElement[]}
|
||||
*/
|
||||
function cornerHandles() {
|
||||
return ['nw', 'ne', 'sw', 'se'].map((corner) => {
|
||||
const handle = document.createElement('span');
|
||||
handle.className = `handle ${corner}`;
|
||||
return handle;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the corners of a rectangle as short L-shaped marks.
|
||||
* @param {CanvasRenderingContext2D} context
|
||||
@@ -221,13 +234,30 @@ export class Renderer {
|
||||
* @type {{element: HTMLImageElement, data: Uint8Array | null, hash: string}[]}
|
||||
*/
|
||||
this.imageElements = [];
|
||||
/** The green cue around an image being handled by hand. */
|
||||
/**
|
||||
* The green cue around an image, text block or Hide being handled by
|
||||
* hand: its outline and its four corners, the corner being dragged
|
||||
* marked (all four since 2026-10-13, so that a hold shows where the
|
||||
* corners are).
|
||||
*/
|
||||
this.grip = document.createElement('div');
|
||||
this.grip.className = 'image_grip';
|
||||
this.grip.hidden = true;
|
||||
this.grip.append(...cornerHandles());
|
||||
this.pageElement.append(this.grip);
|
||||
/** @type {{rect: import('./page_model.js').Rectangle, corner: [number, number] | null} | null} */
|
||||
this.gripShown = null;
|
||||
/**
|
||||
* The blue cues of the Arrange tool: the corners of every image, text
|
||||
* rectangle and Hide that can be moved or resized, and an outline for
|
||||
* images, whose edges may be invisible on the white page (Andy,
|
||||
* 2026-10-13: figures on a white ground).
|
||||
* @type {{rect: import('./page_model.js').Rectangle, kind: 'image' | 'text' | 'hide'}[]}
|
||||
*/
|
||||
this.arrangeCues = [];
|
||||
this.cueElements = document.createElement('div');
|
||||
this.cueElements.className = 'arrange_cues';
|
||||
this.pageElement.append(this.cueElements);
|
||||
|
||||
/** Canvas pixels per page unit. */
|
||||
this.scale = 1;
|
||||
@@ -501,19 +531,52 @@ export class Renderer {
|
||||
const shown = this.gripShown;
|
||||
this.grip.hidden = !shown;
|
||||
if (!shown) return;
|
||||
const page = this.page;
|
||||
const style = this.grip.style;
|
||||
style.left = `${(shown.rect.left - page.left) / page.width * 100}%`;
|
||||
style.top = `${(shown.rect.top - page.top) / page.height * 100}%`;
|
||||
style.width = `${shown.rect.width / page.width * 100}%`;
|
||||
style.height = `${shown.rect.height / page.height * 100}%`;
|
||||
this.placeOverlay(this.grip, shown.rect);
|
||||
this.grip.classList.toggle('corner', shown.corner !== null);
|
||||
if (shown.corner) {
|
||||
style.setProperty('--corner-x', shown.corner[0] ? '100%' : '0%');
|
||||
style.setProperty('--corner-y', shown.corner[1] ? '100%' : '0%');
|
||||
this.grip.style.setProperty('--corner-x', shown.corner[0] ? '100%' : '0%');
|
||||
this.grip.style.setProperty('--corner-y', shown.corner[1] ? '100%' : '0%');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Place an overlay element over a rectangle of the page, in percentages
|
||||
* of the page, so that it keeps its place at any zoom.
|
||||
* @param {HTMLElement} el
|
||||
* @param {import('./page_model.js').Rectangle} rect Page units.
|
||||
*/
|
||||
placeOverlay(el, rect) {
|
||||
const page = this.page;
|
||||
el.style.left = `${(rect.left - page.left) / page.width * 100}%`;
|
||||
el.style.top = `${(rect.top - page.top) / page.height * 100}%`;
|
||||
el.style.width = `${rect.width / page.width * 100}%`;
|
||||
el.style.height = `${rect.height / page.height * 100}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the Arrange tool's cues on these rectangles, or none.
|
||||
* @param {{rect: import('./page_model.js').Rectangle, kind: 'image' | 'text' | 'hide'}[]} cues
|
||||
*/
|
||||
setArrangeCues(cues) {
|
||||
this.arrangeCues = cues;
|
||||
this.cueElements.replaceChildren();
|
||||
for (const cue of cues) {
|
||||
const el = document.createElement('div');
|
||||
el.className = `arrange_cue ${cue.kind}`;
|
||||
el.append(...cornerHandles());
|
||||
this.cueElements.append(el);
|
||||
}
|
||||
this.layoutCues();
|
||||
}
|
||||
|
||||
layoutCues() {
|
||||
const elements = this.cueElements.children;
|
||||
this.arrangeCues.forEach((cue, i) => {
|
||||
const el = /** @type {HTMLElement} */ (elements[i]);
|
||||
if (el) this.placeOverlay(el, cue.rect);
|
||||
});
|
||||
}
|
||||
|
||||
/** @returns {number} CORNER_REACH_PX in page units at the present zoom. */
|
||||
cornerReach() {
|
||||
return CORNER_REACH_PX / this.cssPixelsPerUnit;
|
||||
@@ -573,6 +636,7 @@ export class Renderer {
|
||||
this.pageElement.style.height = `${this.page.height / unitsPerCssPixel}px`;
|
||||
this.layoutImages();
|
||||
this.layoutGrip();
|
||||
this.layoutCues();
|
||||
// When the page grew to the left or upward, keep what is in view in view.
|
||||
if (this.shownLeft !== null && this.page.left < this.shownLeft) {
|
||||
desk.scrollLeft += (this.shownLeft - this.page.left) / unitsPerCssPixel;
|
||||
|
||||
Reference in new issue
Block a user