2026-10-06 15:58:02 +02:00
// @ts-check
/**
* The Kokkoja client: the start screen, and the session screen that
* connects the pen, the page and the other participants.
*/
import {
deriveSession , formatInvitation , formatInvitationLink , loadIdentity , newIdentity , newSecret ,
parseInvitation , saveIdentity ,
} from './crypto.js' ;
import {
2026-10-11 16:59:03 +02:00
NotebookWriter , discardNotebook , discardPage , keepCurrentPage , keepIdentity , listKeptPages , listNotebooks , notebooksInBrowser ,
2026-10-06 15:58:02 +02:00
readCurrentPage , readIdentity , readPage , rememberToken , useBrowserStorage ,
} from './notebook.js' ;
import { download , pickImageFile } from './browser_files.js' ;
2026-10-10 19:01:20 +02:00
import { DEFAULT_NOTEBOOK , NotebookPicker } from './notebook_picker.js' ;
2026-10-06 15:58:02 +02:00
import { pageFromSvg , pageToSvg } from './page_file.js' ;
import { hashOf } from './crypto.js' ;
import { imageResolution } from './image_info.js' ;
import {
2026-10-14 14:00:47 +02:00
LEAST_TEXT_POINTS , MAX_IMAGE_BYTES , MOST_TEXT_POINTS , POINT_SIZE , TEXT_FONTS , TEXT_SIZE ,
UNITS_PER_POINT , rounded , strokeBox , strokesTouched , textAt , textsTouched ,
2026-10-06 15:58:02 +02:00
} from './page_model.js' ;
2026-10-14 14:00:47 +02:00
import { HOLD_MS , NOMINAL_PRESSURE , attachPenInput } from './pen_input.js' ;
2026-10-10 19:01:20 +02:00
import { Renderer } from './renderer.js' ;
import { LEAST_IMAGE_SIDE , textBounds , rectAt } from './page_model.js' ;
2026-10-14 20:38:29 +02:00
import { inkExtent } from './stroke_geometry.js' ;
2026-10-14 14:00:47 +02:00
import {
2026-10-14 20:38:29 +02:00
SHAPE_KEY , SHAPE_NAMES , draggedPoints , isFilled , isTapped , lineInProgressPoints , movedPoints ,
shapeNamed , straightPoints , vertexPoints ,
2026-10-14 14:00:47 +02:00
} from './shapes.js' ;
2026-10-10 19:01:20 +02:00
import { LINE_HEIGHT , TOP_GAP , layoutText } from './text_layout.js' ;
import { log , logText , trace , watchLog } from './log.js' ;
2026-10-06 15:58:02 +02:00
import { Session } from './session.js' ;
import { NotebookChooser } from './chooser.js' ;
import { FileChooser } from './file_chooser.js' ;
import { writePicture } from './files.js' ;
import { pageToPng } from './page_picture.js' ;
import { TextEditor } from './text_editor.js' ;
import { enableTooltips } from './tooltip.js' ;
2026-10-11 13:17:22 +02:00
import { guardSelection , inField } from './selection_guard.js' ;
2026-10-10 19:01:20 +02:00
import { QUIET_ZONE , qrModules , qrPath } from './qr_code.js' ;
2026-10-15 17:17:45 +02:00
import { manualList , manualText } from './manual.js' ;
2026-10-06 15:58:02 +02:00
2026-10-10 19:01:20 +02:00
/**
* The colours in the toolbar: black, red, green, blue, white (Andy's
* teacher, 2026-10-08), with the palette's own values so that the palette
* can leave them out.
*/
const COLORS = [ '#000000' , '#e53935' , '#43a047' , '#1e88e5' , '#ffffff' ];
2026-10-06 15:58:02 +02:00
/** Everyone begins in black (Andy, 2026-10-03; the host began in red before). */
const DEFAULT_COLOR = COLORS [ 0 ];
2026-10-10 19:01:20 +02:00
/** Nominal line widths in page units (0.1 mm); the last is twice the one before (2026-10-08). */
const WIDTHS = [ 4 , 7 , 12 , 24 ];
/** Whether the page grows when one's writing reaches an edge: 'no' if not. */
const EXTEND_KEY = 'kokkoja_extend' ;
2026-10-06 15:58:02 +02:00
/** Radius of the eraser on the screen, in CSS pixels. */
const ERASER_RADIUS = 10 ;
/** With the text tool or the eraser, a press that moves less than this (3 mm) is a tap. */
const TAP_DISTANCE = 30 ;
2026-10-11 13:17:22 +02:00
/**
* Two taps of the eraser within this, both finding nothing to erase, undo
* the latest gesture (Andy, 2026-10-11). A tap that erases something is
* never half of a double tap, so the eraser's own taps are unaffected.
*/
const DOUBLE_TAP_MS = 400 ;
2026-10-06 15:58:02 +02:00
/**
* An image file is shared as it is if it is no larger than this, in bytes
* and in pixels along its longer side. Otherwise it is made smaller and
* stored as JPEG, so that it travels quickly and the page file stays small.
*/
const IMAGE_AS_IS_BYTES = 1500000 ;
const IMAGE_LONGEST_SIDE = 2400 ;
const NAME_KEY = 'kokkoja_name' ;
/** Colours the user added from the palette, remembered in this browser. */
const COLORS_KEY = 'kokkoja_colors' ;
/**
* The palette behind "+": eight hues in three shades, and greys. Our own,
* not the browser's colour dialog, which crashes Firefox on Linux.
*/
const PALETTE = [
'#e53935' , '#fb8c00' , '#fdd835' , '#43a047' , '#00acc1' , '#1e88e5' , '#8e24aa' , '#d81b60' ,
'#b71c1c' , '#e65100' , '#f9a825' , '#1b5e20' , '#006064' , '#0d47a1' , '#4a148c' , '#880e4f' ,
'#ffcdd2' , '#ffe0b2' , '#fff9c4' , '#c8e6c9' , '#b2ebf2' , '#bbdefb' , '#e1bee7' , '#f8bbd0' ,
'#ffffff' , '#bdbdbd' , '#757575' , '#424242' , '#8d6e63' , '#5d4037' , '#3e2723' , '#000000' ,
];
const NOTEBOOK_KEY = 'kokkoja_notebook' ;
2026-10-14 14:00:47 +02:00
/** The Shape tool's fill colour, remembered in this browser; 'none' for none. */
const FILL_KEY = 'kokkoja_fill' ;
/** @typedef {import('./pen_input.js').Tool} Tool */
/** @typedef {import('./shapes.js').Shape} Shape */
2026-10-06 15:58:02 +02:00
/**
* Where the installed Kokkoja program serves the client, for a guest who
* opened an invitation link on a web site but has the program too.
*/
const INSTALLED_ADDRESS = 'http://localhost:8760/' ;
/**
* How this client was served (`config.json`): by the local program, which
* keeps the notebooks as files ("files"), or by a web site, where the
* notebooks are kept in the browser ("browser"). The relay is the one for
* sessions started here; the site, if any, is a Kokkoja web site that an
* invitation can be a link to.
* @typedef {object} Config
* @property {string} relay
* @property {'files' | 'browser'} storage
* @property {string} site
*/
/** @type {Config} */
let config = { relay : '' , storage : 'files' , site : '' };
/**
* @param {string} id
* @returns {HTMLElement}
*/
function element ( id ) {
return /** @type {HTMLElement} */ ( document . getElementById ( id ));
}
/**
* @param {string} id
* @returns {HTMLInputElement}
*/
function input ( id ) {
return /** @type {HTMLInputElement} */ ( document . getElementById ( id ));
}
2026-10-10 19:01:20 +02:00
/**
* @param {string} id
* @returns {HTMLSelectElement}
*/
function select ( id ) {
return /** @type {HTMLSelectElement} */ ( document . getElementById ( id ));
}
/** The start screen's notebook picker. */
const startPicker = new NotebookPicker (
select ( 'notebook' ), input ( 'notebook_name' ),
( message ) => { element ( 'start_message' ). textContent = message ; },
() => {});
2026-10-06 15:58:02 +02:00
/**
* Our identity: the signing keys that make us the same participant in
* every session. The local program keeps them, so that they survive the
* browser and are the same from any browser on this computer; the first
* time, they are made here and given to it.
* @returns {Promise<import('./crypto.js').Identity>}
*/
async function ownIdentity () {
const kept = await readIdentity ();
if ( kept !== null ) return loadIdentity ( kept );
const identity = await newIdentity ();
if ( await keepIdentity ( await saveIdentity ( identity ))) return identity ;
// Another tab got there first: use what it kept.
const other = await readIdentity ();
return other !== null ? loadIdentity ( other ) : identity ;
}
// Start screen.
/** @param {string} [message] */
function showStart ( message = '' ) {
element ( 'session' ). hidden = true ;
element ( 'start' ). hidden = false ;
element ( 'start_message' ). textContent = message ;
input ( 'name' ). value = localStorage . getItem ( NAME_KEY ) ?? '' ;
2026-10-10 19:01:20 +02:00
const remembered = localStorage . getItem ( NOTEBOOK_KEY ) ?? DEFAULT_NOTEBOOK ;
startPicker . fill ([], remembered );
startPicker . current = remembered ;
2026-10-06 15:58:02 +02:00
const pending = invitationInAddress ();
if ( pending ) input ( 'invitation_entry' ). value = pending ;
2026-10-10 19:01:20 +02:00
// An invitation is a request to join one session: arriving with one,
// the screen offers joining alone (Andy, 2026-10-06, after starting a
// session of his own by mistake on a guest's machine).
element ( 'start_choice' ). hidden = Boolean ( pending );
2026-10-06 15:58:02 +02:00
// From a web site, a guest who has the Kokkoja program installed may
// prefer it, for a notebook of real files.
const installed = /** @type {HTMLAnchorElement} */ ( element ( 'open_installed' ));
installed . hidden = ! ( pending && notebooksInBrowser ());
installed . href = ` ${ INSTALLED_ADDRESS } #join= ${ encodeURIComponent ( pending ) } ` ;
2026-10-10 19:01:20 +02:00
showNotebooks ( startPicker ). catch (( error ) => {
2026-10-06 15:58:02 +02:00
element ( 'notebook_note' ). textContent = String ( error . message ?? error );
element ( 'notebook_note' ). classList . add ( 'problem' );
});
}
2026-10-10 19:01:20 +02:00
/**
* Offer the existing notebooks in a picker and say where notebooks are kept.
* The picker's present choice stays chosen: the listing arrives later
* than the screen, and a notebook made meanwhile must not be lost.
* @param {NotebookPicker} picker
*/
async function showNotebooks ( picker ) {
2026-10-06 15:58:02 +02:00
const { directory , notebooks } = await listNotebooks ();
2026-10-10 19:01:20 +02:00
const current = picker . value ;
picker . fill ( notebooks , current );
picker . current = current ;
2026-10-06 15:58:02 +02:00
for ( const note of document . querySelectorAll ( '.notebook_directory' )) {
note . textContent = directory ? `Notebooks are kept in ${ directory } `
: 'Notebooks are kept in this browser, on this computer. “Keep page” also downloads the page.' ;
note . classList . remove ( 'problem' );
}
}
/**
* The part of the page address after '#', as named values. The local
* program puts the token there; a join link puts the invitation there.
* Browsers do not send this part of an address to any server.
*/
function addressValues () {
return new URLSearchParams ( location . hash . slice ( 1 ));
}
2026-10-10 19:01:20 +02:00
/** How long a confirmation stays on the notice line, and a passing problem. */
const NOTICE_MS = 6000 ;
const PROBLEM_NOTICE_MS = 12000 ;
/** A reconnection shorter than this is not announced. */
const CONNECTION_GRACE_MS = 1500 ;
/**
* The notice line between the toolbar and the page. It says nothing
* while all is well. A STANDING notice, kept by its source until that
* source clears it, is a state: not connected, the page not being
* saved, no page from the host yet. A PASSING notice is a report of
* one event, kept for a few seconds: a page kept or exported, a file
* that was not an image. Standing notices come before a passing one.
*/
/** @type {Map<string, {text: string, problem: boolean}>} */
const standing = new Map ();
let passing = { text : '' , problem : false };
/** @type {ReturnType<typeof setTimeout> | undefined} */
let passingTimer ;
function showNotice () {
const line = element ( 'notice' );
const states = [... standing . values ()];
const shown = states . length ? states : [ passing ];
line . textContent = shown . map (( notice ) => notice . text ). join ( ' ' );
line . hidden = line . textContent === '' ;
line . classList . toggle ( 'problem' , shown . some (( notice ) => notice . problem ));
}
/**
* @param {string} source
* @param {string} text '' clears the source's notice.
* @param {boolean} [problem]
*/
function standingNotice ( source , text , problem = true ) {
if ( text ) standing . set ( source , { text , problem });
else standing . delete ( source );
showNotice ();
}
/**
* @param {string} text
* @param {boolean} [problem]
*/
function passingNotice ( text , problem = false ) {
passing = { text , problem };
clearTimeout ( passingTimer );
passingTimer = setTimeout (() => {
passing = { text : '' , problem : false };
showNotice ();
}, problem ? PROBLEM_NOTICE_MS : NOTICE_MS );
showNotice ();
}
/**
* The last word on saving, in the Session window.
* @param {string} text
* @param {boolean} problem
*/
function showSaveState ( text , problem ) {
element ( 'save_status' ). textContent = text ;
element ( 'save_status' ). classList . toggle ( 'problem' , problem );
}
/**
* The invitation link as a QR code in the Session window, for the camera
* of a phone or a tablet (Andy, 2026-10-10: there is no good way to send
* a link from a Linux computer to an iPad). None for a bare invitation,
* which a camera could only show as text.
* @param {string} link
*/
function showInvitationCode ( link ) {
const holder = element ( 'invitation_code' );
holder . replaceChildren ();
holder . hidden = link === '' ;
if ( link === '' ) return ;
const modules = qrModules ( link );
const span = modules . length + 2 * QUIET_ZONE ;
const svg = document . createElementNS ( 'http://www.w3.org/2000/svg' , 'svg' );
svg . setAttribute ( 'viewBox' , ` ${ - QUIET_ZONE } ${ - QUIET_ZONE } ${ span } ${ span } ` );
svg . setAttribute ( 'shape-rendering' , 'crispEdges' );
svg . setAttribute ( 'role' , 'img' );
svg . setAttribute ( 'aria-label' , 'The invitation as a QR code' );
const path = document . createElementNS ( 'http://www.w3.org/2000/svg' , 'path' );
path . setAttribute ( 'd' , qrPath ( modules ));
path . setAttribute ( 'fill' , '#000' );
svg . append ( path );
holder . append ( svg );
}
2026-10-06 15:58:02 +02:00
/** @returns {string} The invitation in the page address, or ''. */
function invitationInAddress () {
return addressValues (). get ( 'join' ) ?? '' ;
}
/**
* Set the page address for a session, or for none.
* @param {string} [invitation]
*/
function setAddress ( invitation ) {
history . replaceState ( null , '' , invitation
? `#join= ${ encodeURIComponent ( invitation ) } ` : location . pathname );
}
/** @returns {string | null} The name entered, or null after complaining. */
function enteredName () {
const name = input ( 'name' ). value . trim ();
if ( ! name ) {
element ( 'start_message' ). textContent = 'Please enter your name.' ;
input ( 'name' ). focus ();
return null ;
}
localStorage . setItem ( NAME_KEY , name );
return name ;
}
2026-10-10 19:01:20 +02:00
/** @returns {string} The notebook chosen. */
2026-10-06 15:58:02 +02:00
function enteredNotebook () {
2026-10-10 19:01:20 +02:00
const notebook = startPicker . value || DEFAULT_NOTEBOOK ;
2026-10-06 15:58:02 +02:00
localStorage . setItem ( NOTEBOOK_KEY , notebook );
return notebook ;
}
async function startSession () {
const name = enteredName ();
if ( ! name ) return ;
const identity = await ownIdentity ();
const secret = newSecret ();
const invitation = formatInvitation ({ relay : config . relay , secret , hostId : identity . id });
await enterSession ( invitation , name , enteredNotebook ());
}
async function joinSession () {
const name = enteredName ();
if ( ! name ) return ;
const invitation = input ( 'invitation_entry' ). value . trim ();
if ( ! parseInvitation ( invitation )) {
element ( 'start_message' ). textContent = 'That is not a Kokkoja invitation.' ;
return ;
}
await enterSession ( invitation , name , enteredNotebook ());
}
// Session screen.
/**
* Prepare an image file to be put on the page. The image's natural size
* comes from the resolution the file records, if any, and otherwise from
* taking a pixel as 0.1 mm (Andy's decision, 2026-10-03); where it goes on
* the page is decided when it is added. An image of more pixels or bytes
* than is useful is made smaller here, which does not change its natural
* size.
* @param {Blob} file
* @returns {Promise<Parameters<Session['addImage']>[0]>}
*/
async function prepareImage ( file ) {
const original = new Uint8Array ( await file . arrayBuffer ());
const bitmap = await createImageBitmap ( file );
const dpi = imageResolution ( original );
const unitsPerPixel = dpi === null ? 1 : 254 / dpi ;
let blob = file ;
const longest = Math . max ( bitmap . width , bitmap . height );
const asIs = ( file . type === 'image/png' || file . type === 'image/jpeg' ) &&
file . size <= IMAGE_AS_IS_BYTES && longest <= IMAGE_LONGEST_SIDE ;
if ( ! asIs ) {
const scale = Math . min ( 1 , IMAGE_LONGEST_SIDE / longest );
const canvas = document . createElement ( 'canvas' );
canvas . width = Math . round ( bitmap . width * scale );
canvas . height = Math . round ( bitmap . height * scale );
const context = /** @type {CanvasRenderingContext2D} */ ( canvas . getContext ( '2d' ));
context . fillStyle = '#ffffff' ;
context . fillRect ( 0 , 0 , canvas . width , canvas . height );
context . drawImage ( bitmap , 0 , 0 , canvas . width , canvas . height );
for ( const quality of [ 0.9 , 0.75 , 0.5 ]) {
blob = await new Promise (( resolve , reject ) => canvas . toBlob (
( result ) => ( result ? resolve ( result ) : reject ( new Error ( 'The image could not be prepared.' ))),
'image/jpeg' , quality ));
if ( blob . size <= MAX_IMAGE_BYTES ) break ;
}
}
if ( blob . size > MAX_IMAGE_BYTES ) throw new Error ( 'The image is too large.' );
const data = blob === file ? original : new Uint8Array ( await blob . arrayBuffer ());
const type = blob . type === 'image/png' ? 'image/png' : 'image/jpeg' ;
return {
type , data , hash : await hashOf ( data ),
naturalWidth : bitmap . width * unitsPerPixel , naturalHeight : bitmap . height * unitsPerPixel ,
};
}
/**
* The page to begin a session with, from the notebook's current page file.
* If that file holds a page of this very session (the browser page was
* reloaded, or we left and came back), the session continues from it. If
* it holds a page of another session, that page is kept first, because the
* file is about to be given over to this session.
* @param {string} notebook
* @param {string} roomId
* @returns {Promise<import('./page_model.js').Page | undefined>}
*/
async function pageToContinue ( notebook , roomId ) {
const text = await readCurrentPage ( notebook );
if ( text === null ) return undefined ;
const saved = pageFromSvg ( text );
if ( saved && saved . session === roomId ) return saved . page ;
await keepCurrentPage ( notebook );
return undefined ;
}
/** True once this browser page has entered a session. */
let entered = false ;
/**
* Where a browser tab remembers the notebook it chose for a session.
* @param {string} roomId
*/
function notebookKeyFor ( roomId ) {
return `kokkoja_notebook_ ${ roomId } ` ;
}
/**
* @param {string} invitationText
* @param {string} name
* @param {string} notebook
*/
async function enterSession ( invitationText , name , notebook ) {
const invitation = parseInvitation ( invitationText );
if ( ! invitation ) {
showStart ( 'That is not a Kokkoja invitation.' );
return ;
}
if ( entered ) return ;
entered = true ;
const { roomId , key } = await deriveSession ( invitation . secret );
const identity = await ownIdentity ();
// A browser tab stays with the notebook it chose for a session.
const notebookKey = notebookKeyFor ( roomId );
notebook = sessionStorage . getItem ( notebookKey ) ?? notebook ;
sessionStorage . setItem ( notebookKey , notebook );
const savedPage = await pageToContinue ( notebook , roomId );
setAddress ( invitationText );
element ( 'start' ). hidden = true ;
element ( 'session' ). hidden = false ;
// The invitation is shown as a link to the web site when there is one,
// so that a guest can open it and be in the session with nothing
// installed. The link carries the invitation itself.
const plainInvitation = formatInvitation ( invitation );
input ( 'invitation' ). value = config . site ? formatInvitationLink ( config . site , plainInvitation ) : plainInvitation ;
2026-10-10 19:01:20 +02:00
showInvitationCode ( config . site ? input ( 'invitation' ). value : '' );
2026-10-06 15:58:02 +02:00
const isHost = identity . id === invitation . hostId ;
2026-10-14 14:00:47 +02:00
let tool = /** @type {Tool} */ ( 'pen' );
/** The Shape tool's current shape, remembered in this browser (Andy, 2026-10-14). */
let shape = shapeNamed ( localStorage . getItem ( SHAPE_KEY ));
/** The colour a shape is filled with, or null (Andy, 2026-10-14); separate from the line's colour. */
let fill = /^#[0-9a-f]{6}$/ . exec ( localStorage . getItem ( FILL_KEY ) ?? '' ) ? .[ 0 ] ?? null ;
2026-10-06 15:58:02 +02:00
let color = DEFAULT_COLOR ;
let width = WIDTHS [ 1 ];
let textSize = TEXT_SIZE ;
let textFont = /** @type {import('./page_model.js').TextFont} */ ( 'sans-serif' );
/** @type {Renderer} */
let renderer ;
let layerOrder = '' ;
function syncLayers () {
const order = session . layerOrder ();
if ( order . join ( ' ' ) === layerOrder ) return ;
layerOrder = order . join ( ' ' );
renderer . setLayerOrder ( order );
}
/**
* @param {import('./page_model.js').Page} page
* @returns {string} The page file.
*/
const fileOf = ( page ) => pageToSvg ( page , { session : roomId , hostId : invitation . hostId });
function showPageNumber () {
2026-10-10 19:01:20 +02:00
const waiting = session . page . number === 0 ;
element ( 'page_number' ). textContent = waiting ? 'No page yet' : `Page ${ session . page . number } ` ;
standingNotice ( 'page' , waiting ? 'Waiting for the host’ s page' : '' , false );
2026-10-06 15:58:02 +02:00
}
2026-10-10 19:01:20 +02:00
/** @type {ReturnType<typeof setTimeout> | undefined} */
let connectionNoticeTimer ;
2026-10-06 15:58:02 +02:00
const session = new Session (
{
relay : invitation . relay , roomId , key , identity , hostId : invitation . hostId , name ,
page : savedPage ,
},
{
onLayerReplaced : ( layerId ) => {
syncLayers ();
renderer . redrawLayer ( layerId );
2026-10-14 14:00:47 +02:00
if ( layerId === session . identity . id ) refreshArrangeCues ();
2026-10-06 15:58:02 +02:00
writer . changed ();
},
onStrokeProgress : ( layerId , stroke ) => {
syncLayers ();
renderer . strokeProgress ( layerId , stroke );
2026-10-14 14:00:47 +02:00
if ( stroke . done ) {
if ( stroke . shape && layerId === session . identity . id ) refreshArrangeCues ();
writer . changed ();
}
2026-10-06 15:58:02 +02:00
},
onTextChanged : ( layerId ) => {
syncLayers ();
renderer . redrawLayer ( layerId );
2026-10-14 00:01:45 +02:00
if ( layerId === session . identity . id ) refreshArrangeCues ();
2026-10-06 15:58:02 +02:00
writer . changed ();
},
onPageReplaced : ( oldPage , newPage ) => {
2026-10-10 19:01:20 +02:00
log ( `Page ${ newPage . number } ( ${ newPage . images . length } images) replaces page ${ oldPage . number } ` );
// A block being typed stays on the page being left, where every
// keystroke already put it; finishing the edit now would move it
// to the new page (seen by Andy, 2026-10-06). An empty one is
// dropped from the old page before that page is kept.
const abandoned = textEditor ? . discard () ?? null ;
if ( abandoned && abandoned . text . trim () === '' ) {
oldPage . layers . get ( session . identity . id ) ? . texts . delete ( abandoned . id );
}
2026-10-14 14:00:47 +02:00
// A line being made likewise stays on the page being left (the
// session has ended its stroke).
lineInProgress = null ;
renderer . hideShapePreview ();
2026-10-06 15:58:02 +02:00
// Nothing is lost: the page being left is kept in the notebook.
writer . pageReplaced ( oldPage . number > 0 ? fileOf ( oldPage ) : null );
renderer . setPage ( newPage );
layerOrder = '' ;
syncLayers ();
showParticipants ( session , renderer );
showPageNumber ();
2026-10-14 00:01:45 +02:00
refreshArrangeCues ();
2026-10-06 15:58:02 +02:00
},
onImagesChanged : () => {
2026-10-10 19:01:20 +02:00
log ( `Page ${ session . page . number } : ${ session . page . images . length } images, ${ session . page . hides . length } hides` );
2026-10-06 15:58:02 +02:00
renderer . showImages ();
2026-10-10 19:01:20 +02:00
renderer . drawHides ();
2026-10-14 00:01:45 +02:00
refreshArrangeCues ();
2026-10-06 15:58:02 +02:00
writer . writeNow ();
},
2026-10-10 19:01:20 +02:00
onPageResized : () => {
// The view keeps its scale; the page extends beyond the window.
2026-10-06 15:58:02 +02:00
renderer . resize ();
textEditor ? . layout ();
writer . changed ();
},
2026-10-10 19:01:20 +02:00
onViewCommand : ( command ) => {
2026-10-14 01:54:26 +02:00
log ( `The host's view: ${ command === 'fit' ? 'Fit' : 'Normal scale' } ` );
2026-10-10 19:01:20 +02:00
if ( command === 'fit' ) renderer . fit ();
else renderer . sheetScale ();
},
2026-10-06 15:58:02 +02:00
onPermissionsChanged : () => {
2026-10-10 19:01:20 +02:00
log ( `May start pages: ${ session . mayMakePages () ? 'yes' : 'no' } ` +
( session . isHost ? `; guests allowed: ${ session . permitted . size } ` : '' ));
2026-10-06 15:58:02 +02:00
showHostTools ();
showParticipants ( session , renderer );
2026-10-14 00:01:45 +02:00
refreshArrangeCues ();
2026-10-06 15:58:02 +02:00
},
onParticipantsChanged : () => {
syncLayers ();
showParticipants ( session , renderer );
},
2026-10-11 13:17:22 +02:00
onUndoChanged : () => {
/** @type {HTMLButtonElement} */ ( element ( 'undo' )). disabled = ! session . canUndo ();
},
2026-10-06 15:58:02 +02:00
onStatus : ( status , detail ) => {
const text = { connecting : 'Connecting…' , connected : 'Connected' , failed : detail ?? 'Disconnected' };
2026-10-10 19:01:20 +02:00
log ( `Relay: ${ text [ status ] } ` );
2026-10-06 15:58:02 +02:00
element ( 'status' ). textContent = text [ status ];
element ( 'status' ). classList . toggle ( 'problem' , status !== 'connected' );
2026-10-10 19:01:20 +02:00
// The notice line says nothing while connected, nothing during a
// short reconnection either, and stays up while a longer one lasts.
// Every attempt reports 'connecting' again, so the grace runs from
// the first of them.
if ( status === 'connecting' ) {
if ( connectionNoticeTimer === undefined && ! standing . has ( 'connection' )) {
connectionNoticeTimer = setTimeout (() => {
connectionNoticeTimer = undefined ;
standingNotice ( 'connection' , 'Not connected. Reconnecting…' );
}, CONNECTION_GRACE_MS );
}
return ;
}
clearTimeout ( connectionNoticeTimer );
connectionNoticeTimer = undefined ;
standingNotice ( 'connection' , status === 'connected' ? '' : text . failed );
2026-10-06 15:58:02 +02:00
},
});
const writer = new NotebookWriter (
notebook ,
() => ( session . page . number > 0 ? fileOf ( session . page ) : null ),
2026-10-10 19:01:20 +02:00
{
onSaved : ( time ) => {
showSaveState ( `Saved at ${ time } ` , false );
standingNotice ( 'saving' , '' );
},
onProblem : ( message ) => {
showSaveState ( message , true );
standingNotice ( 'saving' , message );
},
onKept : ( message ) => passingNotice ( message ),
2026-10-06 15:58:02 +02:00
});
2026-10-10 19:01:20 +02:00
showSaveState ( savedPage ? 'Continued from the notebook' : '' , false );
2026-10-06 15:58:02 +02:00
const pageElement = element ( 'page' );
renderer = new Renderer ( pageElement , session . page );
/** @type {TextEditor | null} */
let textEditor = null ;
syncLayers ();
for ( const layerId of session . page . layers . keys ()) renderer . redrawLayer ( layerId );
showParticipants ( session , renderer );
showPageNumber ();
new ResizeObserver (() => {
renderer . resize ();
textEditor ? . layout ();
}). observe ( /** @type {Element} */ ( pageElement . parentElement ));
2026-10-10 19:01:20 +02:00
// The toolbar, when the window is too narrow for one row, centres its
// rows rather than holding the two ends (Andy, 2026-10-09). Wrapping
// is told from the toolbar's height against one row's.
const toolbar = /** @type {HTMLElement} */ ( document . querySelector ( '.toolbar' ));
const watchToolbar = () => {
// One row is a control's height plus the toolbar's padding; two rows
// are nearly twice that.
const row = /** @type {HTMLElement} */ ( toolbar . firstElementChild ). offsetHeight ;
toolbar . classList . toggle ( 'wrapped' , toolbar . clientHeight > row * 1.9 );
};
new ResizeObserver ( watchToolbar ). observe ( toolbar );
watchToolbar ();
2026-10-14 01:07:53 +02:00
// The drawing group and the settings (colours, widths, font and size)
// keep the width of their widest state, so that Eraser, Hide and Undo,
// which stand after the settings since 2026-10-11, do not jump when a
// tool shows fewer settings (the drawing group's floor also kept the
// middle group still while there was one, until 2026-10-14). The
// floor rises if a group ever grows, as when a colour is added.
2026-10-11 13:17:22 +02:00
for ( const part of [ element ( 'drawing' ), element ( 'settings' )]) {
let widest = 0 ;
new ResizeObserver (() => {
const width = part . getBoundingClientRect (). width ;
if ( width > widest ) {
widest = width ;
part . style . minWidth = ` ${ width } px` ;
}
}). observe ( part );
}
2026-10-10 19:01:20 +02:00
2026-10-06 15:58:02 +02:00
textEditor = new TextEditor ( pageElement , renderer , {
setText : ( block ) => session . setText ( block ),
removeText : ( id ) => session . removeTexts ([ id ]),
2026-10-11 13:17:22 +02:00
beginEdit : ( id ) => session . beginTextEdit ( id , 'editor' ),
endEdit : ( id ) => session . endTextEdit ( id , 'editor' ),
2026-10-10 19:01:20 +02:00
// A hold on the field being typed in moves its block, as on the page.
beginHold : ( clientX , clientY ) => penTarget . beginHold ? .( clientX , clientY ) ?? false ,
holdMove : ( clientX , clientY ) => penTarget . holdMove ? .( clientX , clientY ),
holdEnd : () => penTarget . holdEnd ? .(),
});
2026-10-11 13:17:22 +02:00
// Undo: one's own latest gesture, then the one before (Andy,
// 2026-10-11): the button, Ctrl+Z (Command+Z on a Mac) outside a field,
// and two quick taps of the eraser on empty paper (eraseEnded below).
2026-10-14 14:00:47 +02:00
// A block being typed, or a line being made, is finished first, so that
// the undo is of it.
2026-10-11 13:17:22 +02:00
const undo = () => {
textEditor ? . stop ();
2026-10-14 14:00:47 +02:00
finishLine ();
2026-10-11 13:17:22 +02:00
const what = session . undo ();
if ( what ) log ( `Undid ${ what } ` );
};
element ( 'undo' ). addEventListener ( 'click' , undo );
document . addEventListener ( 'keydown' , ( event ) => {
if (( event . ctrlKey || event . metaKey ) && ! event . shiftKey && ! event . altKey &&
event . key . toLowerCase () === 'z' && ! inField ()) {
event . preventDefault ();
undo ();
}
2026-10-14 14:00:47 +02:00
// A line being made: Escape finishes it, Backspace takes back its
// last point.
if ( lineInProgress && ! inField () && ( event . key === 'Escape' || event . key === 'Backspace' )) {
event . preventDefault ();
if ( event . key === 'Escape' ) finishLine ();
else removeLastVertex ();
}
2026-10-11 13:17:22 +02:00
});
2026-10-10 19:01:20 +02:00
// Whether one's own writing may grow the page (the "Extend" box).
let extend = localStorage . getItem ( EXTEND_KEY ) !== 'no' ;
const extendBox = input ( 'extend' );
extendBox . checked = extend ;
extendBox . addEventListener ( 'change' , () => {
extend = extendBox . checked ;
localStorage . setItem ( EXTEND_KEY , extend ? 'yes' : 'no' );
});
/**
* With "Extend" off, points that would make the page grow are dropped:
* the stroke stops short of the edge and resumes when the pen is back
* inside. Nothing is stored beyond the page.
* @param {number[]} points
* @returns {number[]}
*/
const withinPage = ( points ) => {
if ( extend ) return points ;
const page = session . page ;
const kept = [];
for ( let i = 0 ; i < points . length ; i += 3 ) {
if ( page . holdsWriting ( points [ i ], points [ i + 1 ], width )) kept . push ( points [ i ], points [ i + 1 ], points [ i + 2 ]);
}
return kept ;
};
let reportedDevice = '' ;
/**
2026-10-14 14:00:47 +02:00
* What a hold of the pen is handling: a Hide rectangle, a text block or
* a shape of ours, or an image; which; whether a corner is being
* dragged (and then which, with the opposite one fixed); and how the
* pen's position maps to the rectangle. Images keep their shape when
* scaled; the others resize freely (Andy, 2026-10-08).
2026-10-10 19:01:20 +02:00
* @typedef {object} Held
2026-10-14 14:00:47 +02:00
* @property {'hide' | 'text' | 'shape' | 'image'} kind
2026-10-10 19:01:20 +02:00
* @property {number} index Of the Hide rectangle or image.
2026-10-14 14:00:47 +02:00
* @property {string} id Of the text block or the shape's stroke.
2026-10-10 19:01:20 +02:00
* @property {[number, number] | null} corner
* @property {[number, number]} fixed
* @property {number} aspect
* @property {number} offsetX
* @property {number} offsetY
*/
/** @type {Held | null} */
let held = null ;
2026-10-11 13:17:22 +02:00
/** When the eraser last tapped empty paper, by performance.now(); 0 if not lately. */
let lastEmptyTap = 0 ;
2026-10-10 19:01:20 +02:00
/** @type {import('./page_model.js').Stroke | null} The stroke a hold began as. */
let strokeInProgress = null ;
/**
* The rectangle a hold has moved or resized to, from the pen's position.
* @param {Held} h
* @param {import('./page_model.js').Rectangle} now The rectangle as it is.
* @param {number} x
* @param {number} y
* @param {boolean} keepShape
* @returns {import('./page_model.js').Rectangle}
*/
function heldRect ( h , now , x , y , keepShape ) {
if ( ! h . corner ) return { left : x - h . offsetX , top : y - h . offsetY , width : now . width , height : now . height };
const [ fx , fy ] = h . fixed ;
const dirX = h . corner [ 0 ] ? 1 : - 1 ;
const dirY = h . corner [ 1 ] ? 1 : - 1 ;
let w = Math . max (( x - fx ) * dirX , LEAST_IMAGE_SIDE );
let hgt = Math . max (( y - fy ) * dirY , LEAST_IMAGE_SIDE );
if ( keepShape ) {
// Along the diagonal from the fixed corner, so the aspect ratio holds.
w = Math . max (( x - fx ) * dirX , ( y - fy ) * dirY * h . aspect , LEAST_IMAGE_SIDE , LEAST_IMAGE_SIDE * h . aspect );
hgt = w / h . aspect ;
}
return { left : dirX > 0 ? fx : fx - w , top : dirY > 0 ? fy : fy - hgt , width : w , height : hgt };
}
/**
* @param {Held['kind']} kind
* @param {number} index
* @param {string} id
* @param {import('./page_model.js').Rectangle} r
* @param {[number, number] | null} corner
* @param {number} x
* @param {number} y
* @returns {Held}
*/
const hold = ( kind , index , id , r , corner , x , y ) => ({
kind , index , id , corner ,
fixed : corner ? [ r . left + ( 1 - corner [ 0 ]) * r . width , r . top + ( 1 - corner [ 1 ]) * r . height ] : [ 0 , 0 ],
aspect : r . width / r . height , offsetX : x - r . left , offsetY : y - r . top ,
2026-10-06 15:58:02 +02:00
});
2026-10-10 19:01:20 +02:00
/**
* @param {Held} h
* @returns {import('./page_model.js').Rectangle | null}
*/
function currentHeldRect ( h ) {
const page = session . page ;
if ( h . kind === 'hide' ) {
const hide = page . hides [ h . index ];
return hide ? { left : hide . x , top : hide . y , width : hide . width , height : hide . height } : null ;
}
if ( h . kind === 'image' ) {
const image = page . images [ h . index ];
return image ? { left : image . x , top : image . y , width : image . width , height : image . height } : null ;
}
2026-10-14 14:00:47 +02:00
if ( h . kind === 'shape' ) {
const stroke = session . ownLayer . strokes . get ( h . id );
return stroke ? strokeBox ( stroke ) : null ;
}
2026-10-10 19:01:20 +02:00
const block = session . ownLayer . texts . get ( h . id );
return block ? textBounds ( block ) : null ;
}
2026-10-14 20:38:29 +02:00
/**
* The grip around the held thing: for a stroke, around its ink rather
* than its points (Andy, 2026-10-14).
* @param {Held} h
*/
function showHeldGrip ( h ) {
if ( h . kind === 'shape' ) {
const stroke = session . ownLayer . strokes . get ( h . id );
renderer . showGrip ( stroke ? inkExtent ( stroke ) : null , h . corner , true );
} else {
renderer . showGrip ( currentHeldRect ( h ), h . corner , heldHasCorners ( h ));
}
}
/**
* @param {Held} h
* @returns {boolean} Whether the held thing has corners to resize by:
* everything but free text.
*/
const heldHasCorners = ( h ) => h . kind !== 'text' || session . ownLayer . texts . get ( h . id ) ? . height !== null ;
2026-10-14 14:00:47 +02:00
/** @returns {import('./page_model.js').Stroke[]} Our shapes, in drawing order. */
const ownShapes = () => [... session . ownLayer . strokes . values ()]. filter (( stroke ) => stroke . shape !== null && stroke . done );
2026-10-14 00:01:45 +02:00
/**
2026-10-14 20:38:29 +02:00
* With the Arrange tool chosen, mark everything a press can move or
* resize with a blue box and corners (Andy, 2026-10-14): the images
* and Hides for the host and co-hosts, one's own text blocks and
* shapes; free text, which has no width to resize, gets the box without
* the corners. The one being held is left out, the green grip standing
* in its place until the pointer lifts. Called whenever the images,
* the Hides, one's own text or shapes, the right, or the hold may have
2026-10-14 14:00:47 +02:00
* changed.
2026-10-14 00:01:45 +02:00
*/
function refreshArrangeCues () {
if ( tool !== 'arrange' ) {
renderer . setArrangeCues ([]);
return ;
}
2026-10-14 20:38:29 +02:00
/** @type {{rect: import('./page_model.js').Rectangle, kind: 'image' | 'text' | 'hide' | 'shape' | 'stroke', corners: boolean}[]} */
2026-10-14 00:01:45 +02:00
const cues = [];
2026-10-14 20:38:29 +02:00
const page = session . page ;
2026-10-14 00:01:45 +02:00
if ( session . mayMakePages ()) {
2026-10-14 20:38:29 +02:00
page . images . forEach (( im , index ) => {
if ( held ? . kind === 'image' && held . index === index ) return ;
cues . push ({ kind : 'image' , rect : { left : im . x , top : im . y , width : im . width , height : im . height }, corners : true });
});
page . hides . forEach (( h , index ) => {
if ( held ? . kind === 'hide' && held . index === index ) return ;
cues . push ({ kind : 'hide' , rect : { left : h . x , top : h . y , width : h . width , height : h . height }, corners : true });
});
}
for ( const block of session . ownLayer . texts . values ()) {
if ( held ? . kind === 'text' && held . id === block . id ) continue ;
cues . push ({ kind : 'text' , rect : textBounds ( block ), corners : block . height !== null });
2026-10-14 00:01:45 +02:00
}
2026-10-14 14:00:47 +02:00
for ( const stroke of ownShapes ()) {
2026-10-14 20:38:29 +02:00
if ( held ? . kind === 'shape' && held . id === stroke . id ) continue ;
const rect = inkExtent ( stroke );
if ( rect ) cues . push ({ kind : 'shape' , rect , corners : true });
}
// The handwritten stroke under the hovering pointer, if any.
const hovered = hoveredStroke && session . ownLayer . strokes . get ( hoveredStroke );
if ( hovered && ! ( held ? . kind === 'shape' && held . id === hovered . id )) {
const rect = inkExtent ( hovered );
if ( rect ) cues . push ({ kind : 'stroke' , rect , corners : true });
2026-10-14 00:01:45 +02:00
}
renderer . setArrangeCues ( cues );
}
2026-10-14 20:38:29 +02:00
/**
* Handwriting under Arrange (Andy, 2026-10-14): a page may hold hundreds
* of strokes, so they are not all boxed at once; the one whose ink the
* pointer hovers over gets the box, kept while the pointer stays within
* it (so that its corners can be reached), and a press takes hold of a
* stroke by its ink, or by the shown box's corner, under Arrange only:
* the hold with another tool leaves handwriting alone, since resting
* the pen on one's own writing is part of writing.
* @type {string | null} The id of the stroke whose box is shown.
*/
let hoveredStroke = null ;
/**
* The frontmost handwritten stroke of ours (not a shape, which has its
* cue always) within the eraser's reach of the point, by its ink.
* @param {number} x
* @param {number} y
* @returns {import('./page_model.js').Stroke | null}
*/
function handwritingAt ( x , y ) {
const layer = session . ownLayer ;
const ids = strokesTouched ( layer , x , y , x , y , renderer . pageLength ( ERASER_RADIUS ));
for ( let i = ids . length - 1 ; i >= 0 ; i -- ) {
const stroke = layer . strokes . get ( ids [ i ]);
if ( stroke && stroke . done && stroke . shape === null ) return stroke ;
}
return null ;
}
/**
* Under Arrange, the pointer hovering at this point: the stroke under
* it gets the box, or the shown box stays while the pointer is within
* it or near its corners.
* @param {number} x
* @param {number} y
*/
function hoverHandwriting ( x , y ) {
const shown = hoveredStroke && session . ownLayer . strokes . get ( hoveredStroke );
const box = shown ? inkExtent ( shown ) : null ;
const reach = renderer . cornerReach ();
const within = box && x >= box . left - reach && x <= box . left + box . width + reach && y >= box . top - reach && y <= box . top + box . height + reach ;
const under = handwritingAt ( x , y );
const next = under ? under . id : within ? hoveredStroke : null ;
if ( next === hoveredStroke ) return ;
hoveredStroke = next ;
refreshArrangeCues ();
}
2026-10-14 14:00:47 +02:00
// The Shape tool (Andy, 2026-10-14; shapes.js): a rectangle, an ellipse
// or an arrow is dragged open and becomes one stroke when the pointer
// lifts, a preview showing meanwhile; a line is made of taps, one
// vertex each, its stroke growing for everyone with each tap, a rubber
// band from the last vertex following a hovering pointer, until a tap
// on the last vertex (finished open) or on the first (closed), Escape,
// or a change of tool or shape.
/** @type {{stroke: import('./page_model.js').Stroke, vertices: [number, number][]} | null} */
let lineInProgress = null ;
/** The line being made is complete. */
function finishLine () {
const line = lineInProgress ;
if ( ! line ) return ;
lineInProgress = null ;
renderer . hideShapePreview ();
if ( session . currentStroke === line . stroke ) session . endStroke ();
log ( `Line of ${ line . vertices . length } points` );
}
/**
* Backspace while a line is made: its last vertex goes. A stroke in
* progress cannot lose points by any message, so the stroke is erased
* and begun again with the vertices left.
*/
function removeLastVertex () {
const line = lineInProgress ;
if ( ! line ) return ;
line . vertices . pop ();
if ( session . currentStroke === line . stroke ) session . endStroke ( false );
session . erase ([ line . stroke . id ]);
if ( line . vertices . length === 0 ) {
lineInProgress = null ;
renderer . hideShapePreview ();
return ;
}
line . stroke = session . beginStroke ( color , width , { fill , kind : 'line' });
session . addPoints ( lineInProgressPoints ( line . vertices ));
}
/**
* Whether a shape's points all lie where the page need not grow, or
* Extend is on. With Extend off a shape is drawn whole or not at all,
* since dropping its points beyond the edge, as a stroke's are, would
* deform it.
* @param {number[]} points
*/
function pageHolds ( points ) {
if ( extend ) return true ;
for ( let i = 0 ; i < points . length ; i += POINT_SIZE ) {
if ( ! session . page . holdsWriting ( points [ i ], points [ i + 1 ], width )) return false ;
}
return true ;
}
/**
* The pointer went down at (x0, y0) and up at (x1, y1) with the Shape
* tool.
* @param {number} x0
* @param {number} y0
* @param {number} x1
* @param {number} y1
*/
function placeShape ( x0 , y0 , x1 , y1 ) {
renderer . hideShapePreview ();
const dragged = Math . hypot ( x1 - x0 , y1 - y0 ) >= TAP_DISTANCE ;
/** @param {number} v */
const r = ( v ) => rounded ( v , 10 );
if ( ! isTapped ( shape )) {
if ( ! dragged ) return ;
const points = draggedPoints ( shape , r ( x0 ), r ( y0 ), r ( x1 ), r ( y1 ), width );
if ( ! pageHolds ( points )) {
trace ( 'shape refused: it would reach past the page with Extend off' );
return ;
}
textEditor ? . stop ();
session . beginStroke ( color , width , { filled : isFilled ( shape ), fill : isFilled ( shape ) ? null : fill , kind : shape });
session . addPoints ( points );
session . endStroke ();
log ( ` ${ SHAPE_NAMES [ shape ] } drawn ${ fill && ! isFilled ( shape ) ? `, filled ${ fill } ` : '' } ` );
return ;
}
// A line: each press and release gives a vertex, the release point.
const vx = r ( x1 );
const vy = r ( y1 );
if ( lineInProgress && session . currentStroke !== lineInProgress . stroke ) lineInProgress = null ;
const line = lineInProgress ;
if ( line ) {
const reach = renderer . cornerReach ();
const [ fx , fy ] = line . vertices [ 0 ];
const [ lx , ly ] = line . vertices [ line . vertices . length - 1 ];
if ( line . vertices . length >= 2 && Math . hypot ( vx - fx , vy - fy ) <= reach ) {
session . addPoints ([ fx , fy , NOMINAL_PRESSURE ]); // Closed: back to the first point.
finishLine ();
} else if ( Math . hypot ( vx - lx , vy - ly ) <= reach ) {
finishLine ();
} else if ( pageHolds ([ vx , vy , NOMINAL_PRESSURE ])) {
line . vertices . push ([ vx , vy ]);
session . addPoints ( vertexPoints ( vx , vy ));
} else {
trace ( 'line point refused: beyond the page with Extend off' );
}
return ;
}
// The first vertex; a drag gives the first two.
/** @type {[number, number][]} */
const vertices = dragged ? [[ r ( x0 ), r ( y0 )], [ vx , vy ]] : [[ vx , vy ]];
const points = lineInProgressPoints ( vertices );
if ( ! pageHolds ( points )) {
trace ( 'line refused: beyond the page with Extend off' );
return ;
}
textEditor ? . stop ();
const stroke = session . beginStroke ( color , width , { fill , kind : 'line' });
lineInProgress = { stroke , vertices };
session . addPoints ( points );
}
/**
* The implied rectangle under a point, for the hover cue (Andy,
* 2026-10-14: text rectangles and Hides show their corners only then,
* like free text): the frontmost Hide, else the frontmost text block
2026-10-14 20:38:29 +02:00
* among the layers shown, with its kind, which decides the cue's form.
2026-10-14 14:00:47 +02:00
* @param {number} x
* @param {number} y
2026-10-14 20:38:29 +02:00
* @returns {{rect: import('./page_model.js').Rectangle, kind: 'hide' | 'rect' | 'free'} | null}
2026-10-14 14:00:47 +02:00
*/
function impliedRectAt ( x , y ) {
/** @param {import('./page_model.js').Rectangle} b */
const holds = ( b ) => x >= b . left && x <= b . left + b . width && y >= b . top && y <= b . top + b . height ;
for ( const h of [... session . page . hides ]. reverse ()) {
const b = { left : h . x , top : h . y , width : h . width , height : h . height };
2026-10-14 20:38:29 +02:00
if ( holds ( b )) return { rect : b , kind : 'hide' };
2026-10-14 14:00:47 +02:00
}
for ( const id of session . layerOrder (). reverse ()) {
const layer = session . page . layers . get ( id );
if ( ! layer || renderer . hidden . has ( id )) continue ;
for ( const block of [... layer . texts . values ()]. reverse ()) {
const b = textBounds ( block );
2026-10-14 20:38:29 +02:00
if ( holds ( b )) return { rect : b , kind : block . height === null ? 'free' : 'rect' };
2026-10-14 14:00:47 +02:00
}
}
return null ;
}
2026-10-14 20:38:29 +02:00
/**
* What a press of the eraser at this point would remove, for the cue
* shown while the pointer hovers (Andy, 2026-10-14): the rectangles
* around the strokes and text blocks of ours within the eraser's reach,
* whole, as the eraser takes them; or, where there is none, the Hide or
* image a tap would remove, by the same rules as eraseEnded.
* @param {number} x
* @param {number} y
* @returns {import('./page_model.js').Rectangle[]}
*/
function eraseTargetsAt ( x , y ) {
const layer = session . ownLayer ;
const radius = renderer . pageLength ( ERASER_RADIUS );
/** @type {import('./page_model.js').Rectangle[]} */
const rects = [];
for ( const id of strokesTouched ( layer , x , y , x , y , radius )) {
const stroke = layer . strokes . get ( id );
const rect = stroke && inkExtent ( stroke );
if ( rect ) rects . push ( rect );
}
for ( const id of textsTouched ( layer , x , y , x , y , radius )) {
const block = layer . texts . get ( id );
if ( block ) rects . push ( textBounds ( block ));
}
if ( rects . length ) return rects ;
const page = session . page ;
const hide = page . hideAt ( x , y , 0 );
if ( hide && session . mayMakePages ()) {
const h = page . hides [ hide . index ];
return [{ left : h . x , top : h . y , width : h . width , height : h . height }];
}
const found = page . imageAt ( x , y , 0 );
const image = found && page . images [ found . index ];
if ( image && ( session . mayMakePages () || image . by === session . identity . id )) {
return [{ left : image . x , top : image . y , width : image . width , height : image . height }];
}
return [];
}
2026-10-10 19:01:20 +02:00
/** @type {import('./pen_input.js').PenTarget} */
const penTarget = {
2026-10-06 15:58:02 +02:00
toPage : ( clientX , clientY ) => renderer . pageFromClient ( clientX , clientY ),
tool : () => tool ,
canWrite : () => session . page . number > 0 ,
beginStroke : () => {
textEditor ? . stop ();
2026-10-10 19:01:20 +02:00
strokeInProgress = session . beginStroke ( color , width );
},
addPoints : ( points ) => {
const inside = withinPage ( points );
if ( inside . length ) session . addPoints ( inside );
2026-10-06 15:58:02 +02:00
},
endStroke : () => {
2026-10-10 19:01:20 +02:00
strokeInProgress = null ;
2026-10-06 15:58:02 +02:00
session . endStroke ();
2026-10-10 19:01:20 +02:00
},
beginZoom : ( clientX , clientY ) => renderer . beginZoomDrag ( clientX , clientY ),
zoomTo : ( pixelsUp ) => renderer . zoomDragTo ( pixelsUp ),
endZoom : () => renderer . endZoomDrag (),
// A hold moves what is under the pen, or resizes it from a corner: a
// Hide rectangle or an image (for the host and co-hosts), or a
// text block of our own. The dot drawn meanwhile goes.
beginHold : ( clientX , clientY ) => {
const [ x , y ] = renderer . pageFromClient ( clientX , clientY );
const reach = renderer . cornerReach ();
const page = session . page ;
/** @type {Held | null} */
let found = null ;
const hide = session . mayMakePages () ? page . hideAt ( x , y , reach ) : null ;
if ( hide ) {
const h = page . hides [ hide . index ];
found = hold ( 'hide' , hide . index , h . id , { left : h . x , top : h . y , width : h . width , height : h . height }, hide . corner , x , y );
2026-10-11 13:17:22 +02:00
session . beginHideEdit ( h . id );
2026-10-10 19:01:20 +02:00
} else {
const blocks = [... session . ownLayer . texts . values ()];
const at = rectAt ( blocks . map ( textBounds ), x , y , reach );
2026-10-14 20:38:29 +02:00
// Handwriting, under Arrange only: the shown box's corner, or a
// stroke by its ink.
/** @type {{stroke: import('./page_model.js').Stroke, corner: [number, number] | null} | null} */
let writing = null ;
if ( ! at && tool === 'arrange' ) {
const shown = hoveredStroke && session . ownLayer . strokes . get ( hoveredStroke );
const shownBox = shown ? inkExtent ( shown ) : null ;
const cornerAt = shownBox ? rectAt ([ shownBox ], x , y , reach ) : null ;
if ( shown && cornerAt ? . corner ) writing = { stroke : shown , corner : cornerAt . corner };
else {
const under = handwritingAt ( x , y );
if ( under ) writing = { stroke : under , corner : null };
}
}
const shapes = at || writing ? [] : ownShapes ();
const shapeAt = at || writing ? null : rectAt ( shapes . map (( s ) => strokeBox ( s ) ?? { left : 0 , top : 0 , width : 0 , height : 0 }), x , y , reach );
2026-10-10 19:01:20 +02:00
if ( at ) {
const block = blocks [ at . index ];
// Free text has no corners to resize by.
const corner = block . height === null ? null : at . corner ;
found = hold ( 'text' , at . index , block . id , textBounds ( block ), corner , x , y );
2026-10-11 13:17:22 +02:00
session . beginTextEdit ( block . id , 'hold' );
2026-10-14 20:38:29 +02:00
} else if ( writing ) {
const box = /** @type {import('./page_model.js').Rectangle} */ ( strokeBox ( writing . stroke ));
if ( session . beginShapeEdit ( writing . stroke . id )) found = hold ( 'shape' , 0 , writing . stroke . id , box , writing . corner , x , y );
2026-10-14 14:00:47 +02:00
} else if ( shapeAt ) {
// A shape of ours (Andy, 2026-10-14): moved, or resized from a
// corner, by the rectangle around its points.
const stroke = shapes [ shapeAt . index ];
const box = /** @type {import('./page_model.js').Rectangle} */ ( strokeBox ( stroke ));
if ( session . beginShapeEdit ( stroke . id )) found = hold ( 'shape' , shapeAt . index , stroke . id , box , shapeAt . corner , x , y );
2026-10-10 19:01:20 +02:00
} else if ( session . mayMakePages ()) {
const image = page . imageAt ( x , y , reach );
if ( image ) {
const index = session . raiseImage ( image . index );
const im = page . images [ index ];
found = hold ( 'image' , index , '' , { left : im . x , top : im . y , width : im . width , height : im . height }, image . corner , x , y );
}
}
}
if ( ! found ) return false ;
if ( strokeInProgress ) {
const begun = strokeInProgress ;
strokeInProgress = null ;
2026-10-11 13:17:22 +02:00
session . endStroke ( false );
2026-10-10 19:01:20 +02:00
session . erase ([ begun . id ]);
}
textEditor ? . hidePreview ();
held = found ;
2026-10-14 20:38:29 +02:00
showHeldGrip ( found );
refreshArrangeCues (); // The held thing's blue cue gives way to the grip.
2026-10-10 19:01:20 +02:00
log ( ` ${ found . kind } held ${ found . corner ? ' at a corner' : '' } ` );
return true ;
},
holdMove : ( clientX , clientY ) => {
if ( ! held ) return ;
const [ x , y ] = renderer . pageFromClient ( clientX , clientY );
const now = currentHeldRect ( held );
if ( ! now ) return ;
2026-10-14 20:38:29 +02:00
// An image keeps its shape when resized, and so does a circle.
const keepShape = held . kind === 'image' || ( held . kind === 'shape' && session . ownLayer . strokes . get ( held . id ) ? . shape === 'circle' );
const rect = heldRect ( held , now , x , y , keepShape );
2026-10-10 19:01:20 +02:00
if ( held . kind === 'image' ) {
held . index = session . placeImageByHand ( held . index , rect );
} else if ( held . kind === 'hide' ) {
session . placeHideByHand ( held . index , rect );
2026-10-14 14:00:47 +02:00
} else if ( held . kind === 'shape' ) {
const stroke = session . ownLayer . strokes . get ( held . id );
if ( ! stroke ) return ;
session . setShapePoints ( held . id , movedPoints ( stroke , now , {
left : Math . round ( rect . left ), top : Math . round ( rect . top ), width : Math . round ( rect . width ), height : Math . round ( rect . height ),
}));
2026-10-10 19:01:20 +02:00
} else {
const block = session . ownLayer . texts . get ( held . id );
if ( ! block ) return ;
block . x = Math . round ( rect . left );
block . y = Math . round ( rect . top );
if ( block . height !== null ) {
block . width = Math . round ( rect . width );
Object . assign ( block , layoutText ( block . text , block . width , block . size , block . font ));
block . height = Math . max ( Math . round ( rect . height ), Math . ceil ( block . boxHeight ));
}
session . setText ( block );
// The field being typed in follows its block.
if ( textEditor ? . block ? . id === block . id ) textEditor . layout ();
}
2026-10-14 20:38:29 +02:00
showHeldGrip ( held );
2026-10-14 00:01:45 +02:00
refreshArrangeCues ();
2026-10-10 19:01:20 +02:00
},
holdEnd : () => {
if ( ! held ) return ;
const r = currentHeldRect ( held );
if ( r ) log ( ` ${ held . kind } placed at ${ r . left / 100 } , ${ r . top / 100 } cm, ${ r . width / 100 } by ${ r . height / 100 } cm` );
2026-10-14 14:00:47 +02:00
if ( held . kind === 'image' || held . kind === 'hide' ) session . sendPageNow ();
2026-10-11 13:17:22 +02:00
if ( held . kind === 'text' ) session . endTextEdit ( held . id , 'hold' );
2026-10-14 14:00:47 +02:00
if ( held . kind === 'shape' ) session . endShapeEdit ();
2026-10-11 13:17:22 +02:00
if ( held . kind === 'hide' ) session . endHideEdit ();
2026-10-10 19:01:20 +02:00
held = null ;
renderer . showGrip ( null );
2026-10-14 00:01:45 +02:00
refreshArrangeCues ();
2026-10-06 15:58:02 +02:00
},
eraseAlong : ( x0 , y0 , x1 , y1 ) => {
textEditor ? . stop ();
2026-10-11 13:17:22 +02:00
session . beginErasing ();
2026-10-06 15:58:02 +02:00
const radius = renderer . pageLength ( ERASER_RADIUS );
2026-10-10 19:01:20 +02:00
const strokes = strokesTouched ( session . ownLayer , x0 , y0 , x1 , y1 , radius );
const texts = textsTouched ( session . ownLayer , x0 , y0 , x1 , y1 , radius );
session . erase ( strokes );
session . removeTexts ( texts );
return { strokes : strokes . length , texts : texts . length };
2026-10-06 15:58:02 +02:00
},
eraseEnded : ( x , y , reach ) => {
2026-10-11 13:17:22 +02:00
// What the movement erased is one gesture to undo.
const erased = session . endErasing ();
if ( reach >= TAP_DISTANCE ) {
lastEmptyTap = 0 ;
return ;
}
2026-10-10 19:01:20 +02:00
// A tap of the eraser on a Hide rectangle or an image removes it
// (the right to make pages, or having added the image).
2026-10-11 13:17:22 +02:00
let took = erased . strokes + erased . texts > 0 ;
if ( ! took ) {
const hide = session . page . hideAt ( x , y , 0 );
if ( hide && session . removeHide ( hide . index )) {
log ( `Hide ${ hide . index + 1 } removed` );
took = true ;
} else {
const found = session . page . imageAt ( x , y , 0 );
if ( found && session . removeImage ( found . index )) {
log ( `Image ${ found . index + 1 } removed` );
took = true ;
}
}
}
if ( took ) {
lastEmptyTap = 0 ;
2026-10-10 19:01:20 +02:00
return ;
2026-10-06 15:58:02 +02:00
}
2026-10-11 13:17:22 +02:00
// A tap on empty paper: the second within DOUBLE_TAP_MS undoes.
const now = performance . now ();
if ( now - lastEmptyTap <= DOUBLE_TAP_MS ) {
lastEmptyTap = 0 ;
undo ();
} else {
lastEmptyTap = now ;
}
2026-10-06 15:58:02 +02:00
},
2026-10-10 19:01:20 +02:00
previewRect : ( x0 , y0 , x1 , y1 ) => {
2026-10-14 14:00:47 +02:00
const dragged = Math . hypot ( x1 - x0 , y1 - y0 ) >= TAP_DISTANCE ;
if ( tool === 'shape' ) {
// The shape as it will be, in the pen's colour and width; for a
// line, the side the press is adding.
if ( isTapped ( shape )) {
/** @type {[number, number]} */
const from = lineInProgress ? lineInProgress . vertices [ lineInProgress . vertices . length - 1 ] : [ x0 , y0 ];
if ( lineInProgress || dragged ) renderer . showShapePreview ( straightPoints ([ from , [ x1 , y1 ]]), color , width , false );
} else if ( dragged ) {
renderer . showShapePreview ( draggedPoints ( shape , x0 , y0 , x1 , y1 , width ), color , width , isFilled ( shape ));
} else {
renderer . hideShapePreview ();
}
return ;
}
if ( dragged ) {
2026-10-10 19:01:20 +02:00
textEditor ? . showPreview ( x0 , y0 , x1 , y1 );
} else {
textEditor ? . hidePreview ();
}
2026-10-06 15:58:02 +02:00
},
2026-10-14 14:00:47 +02:00
// A hovering pointer: the rubber band of a line being made, and the
// corners of free text under it (Andy, 2026-10-14: free text is used
// constantly, so its extent shows only then).
hover : ( clientX , clientY ) => {
const [ x , y ] = renderer . pageFromClient ( clientX , clientY );
if ( lineInProgress && tool === 'shape' ) {
const last = lineInProgress . vertices [ lineInProgress . vertices . length - 1 ];
renderer . showShapePreview ( straightPoints ([ last , [ x , y ]]), color , width , false );
}
2026-10-14 20:38:29 +02:00
// With the Eraser, the red box says what matters, so the blue cue
// of an implied rectangle stays away (the two had shown together).
const implied = tool === 'eraser' ? null : impliedRectAt ( x , y );
renderer . showHoverCue ( implied ? . rect ?? null , implied ? . kind );
renderer . setEraseCues ( tool === 'eraser' ? eraseTargetsAt ( x , y ) : []);
if ( tool === 'arrange' ) hoverHandwriting ( x , y );
2026-10-14 14:00:47 +02:00
},
2026-10-14 20:38:29 +02:00
hoverEnded : ( left = false ) => {
2026-10-14 14:00:47 +02:00
renderer . showHoverCue ( null );
2026-10-14 20:38:29 +02:00
renderer . setEraseCues ([]);
2026-10-14 14:00:47 +02:00
if ( lineInProgress ) renderer . hideShapePreview ();
2026-10-14 20:38:29 +02:00
// The handwriting's box stays through a press (the pointer is still
// there) and goes when the pointer leaves the page.
if ( left && hoveredStroke ) {
hoveredStroke = null ;
refreshArrangeCues ();
}
2026-10-14 14:00:47 +02:00
},
2026-10-10 19:01:20 +02:00
placeRect : ( x0 , y0 , x1 , y1 ) => {
2026-10-14 14:00:47 +02:00
if ( tool === 'shape' ) {
placeShape ( x0 , y0 , x1 , y1 );
return ;
}
2026-10-06 15:58:02 +02:00
if ( ! textEditor ) return ;
textEditor . hidePreview ();
2026-10-10 19:01:20 +02:00
const dragged = Math . hypot ( x1 - x0 , y1 - y0 ) >= TAP_DISTANCE ;
const rect = {
left : Math . min ( x0 , x1 ), top : Math . min ( y0 , y1 ),
width : Math . max ( 100 , Math . round ( Math . abs ( x1 - x0 ))), height : Math . max ( 100 , Math . round ( Math . abs ( y1 - y0 ))),
};
if ( tool === 'hide' ) {
if ( dragged && session . addHide ( rect )) log ( 'Hide made' );
return ;
}
2026-10-06 15:58:02 +02:00
const editing = textEditor . block ;
textEditor . stop ();
const own = textAt ( session . ownLayer , x0 , y0 );
2026-10-10 19:01:20 +02:00
if ( ! dragged ) {
// A tap: into our own text to change it, or free text here. A
// tap elsewhere while typing only finishes the block (2026-10-09).
2026-10-06 15:58:02 +02:00
if ( own && own !== editing ) textEditor . edit ( own );
2026-10-10 19:01:20 +02:00
else if ( ! own && ! editing ) textEditor . begin ( x0 , y0 , null , null , { color , size : textSize , font : textFont });
2026-10-06 15:58:02 +02:00
} else {
2026-10-10 19:01:20 +02:00
// A rectangle dragged open: at least one line tall.
const line = Math . ceil ( textSize * ( TOP_GAP + LINE_HEIGHT ));
textEditor . begin ( rect . left , rect . top , rect . width , Math . max ( rect . height , line ),
2026-10-06 15:58:02 +02:00
{ color , size : textSize , font : textFont });
}
},
onReport : ( report ) => {
2026-10-10 19:01:20 +02:00
// To the console once, and again when the kind of device changes.
if ( report . pointerType === reportedDevice || report . pointsPerSecond === 0 ) return ;
reportedDevice = report . pointerType ;
log ( `Input: ${ report . pointerType } , pressure ${ report . pressure . toFixed ( 2 ) } , ` +
` ${ report . pointsPerSecond } points per second` );
2026-10-06 15:58:02 +02:00
},
2026-10-10 19:01:20 +02:00
};
attachPenInput ( pageElement , penTarget );
2026-10-06 15:58:02 +02:00
buildToolbar ({
2026-10-14 14:00:47 +02:00
color , width , textSize , textFont , shape , fill ,
2026-10-06 15:58:02 +02:00
setTool : ( value ) => {
if ( value !== 'text' ) textEditor ? . stop ();
2026-10-14 14:00:47 +02:00
if ( value !== 'shape' ) finishLine ();
2026-10-10 19:01:20 +02:00
if ( value !== tool ) trace ( `Tool: ${ value } ` );
2026-10-06 15:58:02 +02:00
tool = value ;
2026-10-14 14:00:47 +02:00
// Only the chosen tool's settings show: the pen's and the shapes'
// colours and line widths, the text's colours, size and font,
// nothing for the eraser, Hide or Arrange.
2026-10-14 00:01:45 +02:00
element ( 'colors' ). hidden = value === 'eraser' || value === 'hide' || value === 'arrange' ;
2026-10-14 14:00:47 +02:00
element ( 'widths' ). hidden = value !== 'pen' && value !== 'shape' ;
2026-10-06 15:58:02 +02:00
element ( 'text_style' ). hidden = value !== 'text' ;
2026-10-14 20:38:29 +02:00
if ( value !== 'arrange' ) hoveredStroke = null ;
2026-10-14 00:01:45 +02:00
refreshArrangeCues ();
2026-10-14 20:38:29 +02:00
if ( value !== 'eraser' ) renderer . setEraseCues ([]);
2026-10-06 15:58:02 +02:00
},
2026-10-14 14:00:47 +02:00
setShape : ( value ) => {
finishLine ();
if ( value !== shape ) trace ( `Shape: ${ value } ` );
shape = value ;
localStorage . setItem ( SHAPE_KEY , value );
},
setFill : ( value ) => {
finishLine ();
fill = value ;
localStorage . setItem ( FILL_KEY , value ?? 'none' );
trace ( `Fill: ${ value ?? 'none' } ` );
},
2026-10-06 15:58:02 +02:00
setColor : ( value ) => {
color = value ;
textEditor ? . restyle ({ color });
},
setWidth : ( value ) => { width = value ; },
setTextSize : ( value ) => {
textSize = value ;
textEditor ? . restyle ({ size : value });
},
setTextFont : ( value ) => {
textFont = value ;
textEditor ? . restyle ({ font : value });
},
});
// Zoom is each person's own; it changes nothing on the page.
// A reloaded tab shows the page as it was last seen in it (Andy,
// 2026-10-05: the least surprise): the zoom and the place scrolled to
// are remembered in the tab, for the page they belong to.
const viewKey = `kokkoja_view_ ${ roomId } ` ;
const saveView = () => {
if ( session . page . number === 0 ) return ;
sessionStorage . setItem ( viewKey , JSON . stringify ({ page : session . page . id , ... renderer . view () }));
};
let viewTimer = 0 ;
const viewChanged = () => {
clearTimeout ( viewTimer );
viewTimer = setTimeout ( saveView , 300 );
};
renderer . onZoomChanged = () => {
textEditor ? . layout ();
viewChanged ();
};
2026-10-14 01:54:26 +02:00
// The host's Fit and Normal scale ("A4" until 2026-10-14) reach everyone (Andy, 2026-10-08); a guest's
2026-10-10 19:01:20 +02:00
// are the guest's own.
2026-10-14 01:07:53 +02:00
// The page and view commands live in the Page menu (Andy, 2026-10-14:
2026-10-15 17:17:45 +02:00
// seldom used, and the one row is precious).
2026-10-14 01:07:53 +02:00
attachPopup ( element ( 'page_menu_button' ), element ( 'page_menu' ));
2026-10-15 17:17:45 +02:00
// Help (Andy, 2026-10-15): one click shows the manual, the tooltips of
// every command gathered into one window (Ctrl-click until then); two
// clicks within DOUBLE_TAP_MS show the note about the documentation
// site. Since one click opens a modal window, it must wait the
// interval out to be told from the first of two; the shape button's
// double click needs no such wait, its single click having chosen the
// tool already. Two clicks are counted here rather than by the
// browser's dblclick, which a pen or a touch screen does not always
// send.
2026-10-14 01:07:53 +02:00
const manualDialog = /** @type {HTMLDialogElement} */ ( element ( 'manual_dialog' ));
2026-10-15 17:17:45 +02:00
const helpNote = attachPopup ( element ( 'help' ), element ( 'help_popup' ), undefined , false );
const showManual = () => {
helpNote . close ();
const body = element ( 'manual_text' );
body . replaceChildren ( manualList ( document ));
2026-10-14 01:07:53 +02:00
manualDialog . showModal ();
2026-10-15 17:17:45 +02:00
body . focus ();
};
/** @type {ReturnType<typeof setTimeout> | null} */
let helpClick = null ;
element ( 'help' ). addEventListener ( 'click' , () => {
if ( helpClick !== null ) {
clearTimeout ( helpClick );
helpClick = null ;
helpNote . open ();
} else {
helpClick = setTimeout (() => {
helpClick = null ;
showManual ();
}, DOUBLE_TAP_MS );
}
2026-10-14 01:07:53 +02:00
});
element ( 'manual_close' ). addEventListener ( 'click' , () => manualDialog . close ());
element ( 'copy_manual' ). addEventListener ( 'click' , async () => {
try {
2026-10-15 17:17:45 +02:00
await navigator . clipboard . writeText ( manualText ( document ));
2026-10-14 01:07:53 +02:00
element ( 'copy_manual' ). textContent = 'Copied' ;
setTimeout (() => { element ( 'copy_manual' ). textContent = 'Copy' ; }, 1500 );
} catch ( error ) {
log ( `The manual could not be copied: ${ error } ` );
}
});
2026-10-10 19:01:20 +02:00
element ( 'zoom_fit' ). addEventListener ( 'click' , () => {
renderer . fit ();
session . sendViewCommand ( 'fit' );
});
element ( 'zoom_sheet' ). addEventListener ( 'click' , () => {
renderer . sheetScale ();
session . sendViewCommand ( 'sheet' );
});
2026-10-11 19:43:17 +02:00
// "Expand" makes the sheet itself fill the window at the present zoom,
// in both directions; nothing on it moves (Andy, 2026-10-08 for the
// width, both ways 2026-10-11).
element ( 'zoom_expand' ). addEventListener ( 'click' , () => {
const room = renderer . available ();
const expanded = session . expandPage ( room . width / renderer . cssPixelsPerUnit ,
room . height / renderer . cssPixelsPerUnit );
if ( expanded ) {
log ( `Page expanded to ${ session . page . baseWidth / 100 } by ${ session . page . baseHeight / 100 } cm` );
}
2026-10-10 19:01:20 +02:00
});
element ( 'zoom_shrink' ). addEventListener ( 'click' , () => {
if ( session . shrinkPage ()) {
log ( `Page shrunk to ${ session . page . baseWidth / 100 } by ${ session . page . baseHeight / 100 } cm` );
}
});
2026-10-06 15:58:02 +02:00
try {
const seen = JSON . parse ( sessionStorage . getItem ( viewKey ) ?? 'null' );
if ( seen && seen . page === session . page . id && ( seen . zoom === 'fit' || seen . zoom > 0 ) &&
Number . isFinite ( seen . x ) && Number . isFinite ( seen . y )) {
renderer . showView ( seen );
}
} catch {
// Nothing usable was remembered: the page is shown as a new one is.
}
element ( 'desk' ). addEventListener ( 'scroll' , viewChanged , { passive : true });
window . addEventListener ( 'pagehide' , saveView );
// The host drives: the host starts new pages, and may let a guest do so.
function showHostTools () {
2026-10-11 17:34:24 +02:00
// New and Image are the host's; Notebooks, in the same group so that
// nothing moves for the host, is everyone's (the chooser reserves
// loading).
for ( const id of [ 'new_page' , 'load_image' ]) element ( id ). hidden = ! session . mayMakePages ();
2026-10-10 19:01:20 +02:00
// The Hide tool is for the host and co-hosts; losing the right
// while it is chosen puts the pen back in hand.
const hideTool = /** @type {HTMLElement} */ ( document . querySelector ( '[data-tool=hide]' ));
hideTool . hidden = ! session . mayMakePages ();
if ( hideTool . hidden && tool === 'hide' ) /** @type {HTMLElement} */ ( document . querySelector ( '[data-tool=pen]' )). click ();
2026-10-11 19:43:17 +02:00
element ( 'zoom_expand' ). hidden = ! session . mayMakePages ();
2026-10-10 19:01:20 +02:00
element ( 'zoom_shrink' ). hidden = ! session . mayMakePages ();
2026-10-06 15:58:02 +02:00
}
showHostTools ();
2026-10-10 19:01:20 +02:00
element ( 'new_page' ). addEventListener ( 'click' , () => {
log ( 'New page asked for' );
session . newPage ();
});
2026-10-06 15:58:02 +02:00
/**
2026-10-10 19:01:20 +02:00
* Add an image file to the page, below the images already on it, at
* its natural size; moving and scaling it is done by hand afterwards.
2026-10-06 15:58:02 +02:00
* @param {Blob} file
*/
2026-10-10 19:01:20 +02:00
async function loadImage ( file ) {
2026-10-06 15:58:02 +02:00
if ( ! session . mayMakePages () || session . page . number === 0 ) return ;
try {
2026-10-10 19:01:20 +02:00
if ( ! session . addImage ( await prepareImage ( file ))) {
2026-10-06 15:58:02 +02:00
throw new Error ( 'The page has no room for another image.' );
}
} catch ( error ) {
2026-10-10 19:01:20 +02:00
passingNotice ( error instanceof Error && error . message ? error . message : 'That file is not an image.' , true );
2026-10-06 15:58:02 +02:00
}
}
const chooser = new NotebookChooser (( chosenNotebook , pageName , text ) => {
const kept = pageFromSvg ( text );
if ( ! kept ) {
2026-10-10 19:01:20 +02:00
passingNotice ( ` ${ pageName } is not a Kokkoja page.` , true );
2026-10-06 15:58:02 +02:00
return ;
}
2026-10-10 19:01:20 +02:00
log ( `Notebook page loaded: ${ chosenNotebook } / ${ pageName } ` );
2026-10-06 15:58:02 +02:00
session . loadPage ( kept . page );
2026-10-11 16:59:03 +02:00
}, () => {
// A deleted notebook leaves the toolbar's list.
showNotebooks ( sessionPicker ). catch (() => {});
2026-10-06 15:58:02 +02:00
});
2026-10-11 17:34:24 +02:00
element ( 'load_notebook_page' ). addEventListener ( 'click' , () => { chooser . open ( writer . notebook , session . mayMakePages ()); });
2026-10-06 15:58:02 +02:00
// Image files are chosen, and pictures of the page exported, through a
// chooser of our own, which begins where the notebooks are.
const fileChooser = new FileChooser ();
element ( 'load_image' ). addEventListener ( 'click' , async () => {
if ( notebooksInBrowser ()) {
// No local program to look through the files: the browser's own chooser.
const file = await pickImageFile ();
if ( file ) loadImage ( file );
return ;
}
const file = await fileChooser . chooseImage ();
2026-10-10 19:01:20 +02:00
if ( file ) loadImage ( file );
2026-10-06 15:58:02 +02:00
});
/**
* Save a picture of the page as it is shown here (layers hidden on this
* screen are left out) where the user chooses.
*/
async function exportPage () {
const page = session . page ;
if ( page . number === 0 ) return ;
textEditor ? . stop ();
// Anything still waiting to be saved is saved first, so that its
// "Saved" does not take the place of "Exported" a moment later.
await writer . writeNow ();
const now = new Date ();
const day = [ now . getFullYear (), now . getMonth () + 1 , now . getDate ()]
. map (( part ) => String ( part ). padStart ( 2 , '0' )). join ( '_' );
if ( notebooksInBrowser ()) {
download ( ` ${ day } _page_ ${ page . number } .png` ,
await pageToPng ( page , { session : roomId , hostId : session . hostId , hidden : renderer . hidden }));
2026-10-10 19:01:20 +02:00
log ( `Exported page ${ page . number } to the downloads` );
passingNotice ( 'Exported to your downloads' );
2026-10-06 15:58:02 +02:00
return ;
}
const saved = await fileChooser . chooseWhereToSave (
'Export' , ` ${ day } _page_ ${ page . number } .png` ,
async ( directory , name , replace ) => writePicture (
directory , name ,
await pageToPng ( page , { session : roomId , hostId : session . hostId , hidden : renderer . hidden }),
replace ));
if ( saved === null ) return ;
2026-10-10 19:01:20 +02:00
log ( `Exported page ${ page . number } to ${ saved } ` );
passingNotice ( `Exported to ${ saved } ` );
2026-10-06 15:58:02 +02:00
}
element ( 'export_page' ). addEventListener ( 'click' , () => { exportPage (); });
2026-10-10 19:01:20 +02:00
// The notebook: choosing one, making one, and keeping the page.
const sessionPicker = new NotebookPicker (
select ( 'session_notebook' ), input ( 'session_notebook_name' ),
( message ) => { if ( message ) passingNotice ( message , true ); },
( chosen ) => {
if ( chosen === writer . notebook ) return ;
sessionStorage . setItem ( notebookKey , chosen );
localStorage . setItem ( NOTEBOOK_KEY , chosen );
writer . changeNotebook ( chosen , ( text ) => {
const saved = pageFromSvg ( text );
return saved !== null && saved . session === roomId && saved . page . id === session . page . id ;
}). then (() => showNotebooks ( sessionPicker )). catch (() => {});
});
sessionPicker . fill ([], notebook );
sessionPicker . current = notebook ;
showNotebooks ( sessionPicker ). catch (() => {});
2026-10-06 15:58:02 +02:00
element ( 'keep_page' ). addEventListener ( 'click' , async () => {
2026-10-10 19:01:20 +02:00
const { kept , new : made } = await writer . keepPage ();
log ( kept ? ` ${ made ? 'Kept' : 'Already kept' } as ${ kept } ` : 'Nothing to keep' );
2026-10-06 15:58:02 +02:00
// In the browser the notebook is out of reach of the user's own
2026-10-10 19:01:20 +02:00
// programs: a page kept just now is also downloaded, which is how a
// page gets out. One already kept was downloaded then, and is not
// again (Andy, 2026-10-06: a second press should do nothing new).
if ( kept && made && notebooksInBrowser ()) {
2026-10-06 15:58:02 +02:00
const text = await readPage ( writer . notebook , kept );
if ( text !== null ) download ( kept , new Blob ([ text ], { type : 'image/svg+xml' }));
}
});
2026-10-10 19:01:20 +02:00
const sessionDialog = /** @type {HTMLDialogElement} */ ( element ( 'session_dialog' ));
element ( 'session_button' ). addEventListener ( 'click' , () => {
showLog ();
sessionDialog . showModal ();
});
element ( 'session_close' ). addEventListener ( 'click' , () => sessionDialog . close ());
// The log in the Session window, kept current while the window is open.
const logView = element ( 'event_log' );
function showLog () {
logView . textContent = logText ();
logView . scrollTop = logView . scrollHeight ;
}
watchLog (() => { if ( sessionDialog . open ) showLog (); });
element ( 'copy_log' ). addEventListener ( 'click' , async () => {
try {
await navigator . clipboard . writeText ( logText ());
element ( 'copy_log' ). textContent = 'Copied' ;
setTimeout (() => { element ( 'copy_log' ). textContent = 'Copy' ; }, 1500 );
} catch {
// The text is selectable; the user can copy it by hand.
}
});
2026-10-06 15:58:02 +02:00
element ( 'copy_invitation' ). addEventListener ( 'click' , async () => {
const field = input ( 'invitation' );
field . select ();
try {
await navigator . clipboard . writeText ( field . value );
element ( 'copy_invitation' ). textContent = 'Copied' ;
setTimeout (() => { element ( 'copy_invitation' ). textContent = 'Copy' ; }, 1500 );
} catch {
// The field is selected; the user can copy it with the keyboard.
}
});
if ( notebooksInBrowser ()) {
element ( 'keep_page' ). setAttribute ( 'data-help' ,
'Keep a dated copy of the page as it is now in this browser’ s notebook, and download it. ' +
'The page is also kept by itself whenever it is about to be replaced.' );
element ( 'export_page' ). setAttribute ( 'data-help' ,
'Download a picture of the page, as it is shown here, as a PNG file.' );
}
session . start ();
// For looking at the session from the browser's console during development.
Object . assign ( window , { kokkoja : {
2026-10-11 19:32:13 +02:00
session , renderer , writer , loadImage , textEditor , logText ,
2026-10-15 17:17:45 +02:00
manualText : () => manualText ( document ),
2026-10-11 16:59:03 +02:00
notebook : { listNotebooks , listKeptPages , readPage , readCurrentPage , notebooksInBrowser , discardNotebook , discardPage },
2026-10-06 15:58:02 +02:00
} });
}
/**
* Mark one button of a group as chosen.
* @param {Element} group
* @param {Element} chosen
*/
function press ( group , chosen ) {
for ( const button of group . querySelectorAll ( 'button' )) {
button . setAttribute ( 'aria-pressed' , String ( button === chosen ));
}
}
2026-10-14 01:07:53 +02:00
/** The closers of every popup, so that opening one closes the others. @type {Set<() => void>} */
const popupClosers = new Set ();
/**
* A popup of Kokkoja's own below a toolbar button (the colour palette,
2026-10-14 14:00:47 +02:00
* the Page menu, the Help note, the shapes; no system menu or dialog,
* 2026-10-14). The button's press opens it below the button, left edges
* aligned, or right edges when it would run past the window; a press
* anywhere else, Escape, or a press on an item marked `.menu_item`
* closes it.
2026-10-14 01:07:53 +02:00
* @param {HTMLElement} button
* @param {HTMLElement} popup
* @param {() => void} [onOpen] Fill the popup before it shows.
2026-10-14 14:00:47 +02:00
* @param {boolean} [byClick] Whether the button's click opens and closes
* it; the Shape button opens its menu by a hold instead.
* @returns {{open: () => void, close: () => void}}
2026-10-14 01:07:53 +02:00
*/
2026-10-14 14:00:47 +02:00
export function attachPopup ( button , popup , onOpen , byClick = true ) {
2026-10-14 01:07:53 +02:00
const close = () => {
popup . hidden = true ;
button . setAttribute ( 'aria-expanded' , 'false' );
};
popupClosers . add ( close );
2026-10-14 14:00:47 +02:00
const open = () => {
2026-10-14 01:07:53 +02:00
// One popup at a time (a keyboard's Enter opens without the press
// that would close the other).
for ( const other of popupClosers ) other ();
onOpen ? .();
const box = button . getBoundingClientRect ();
// Measured at the window's left edge: placed near the right edge, an
// absolute box shrinks to the room left and its text wraps.
popup . style . left = '0px' ;
popup . style . top = ` ${ box . bottom + 6 } px` ;
popup . hidden = false ;
button . setAttribute ( 'aria-expanded' , 'true' );
const width = popup . getBoundingClientRect (). width ;
const fits = box . left + width <= window . innerWidth - 8 ;
popup . style . left = ` ${ fits ? box . left : Math . max ( 8 , box . right - width ) } px` ;
2026-10-14 14:00:47 +02:00
};
if ( byClick ) {
button . addEventListener ( 'click' , () => {
if ( popup . hidden ) open ();
else close ();
});
}
2026-10-14 01:07:53 +02:00
popup . addEventListener ( 'click' , ( event ) => {
const item = /** @type {Element} */ ( event . target ). closest ( '.menu_item' );
// A label's click reaches here twice, once as the input's; close on the second.
if ( item && ! ( event . target instanceof HTMLLabelElement )) close ();
});
document . addEventListener ( 'pointerdown' , ( event ) => {
const target = /** @type {Node} */ ( event . target );
if ( ! popup . hidden && ! popup . contains ( target ) && ! button . contains ( target )) close ();
});
document . addEventListener ( 'keydown' , ( event ) => {
if ( event . key === 'Escape' && ! popup . hidden ) close ();
});
2026-10-14 14:00:47 +02:00
return { open , close };
2026-10-14 01:07:53 +02:00
}
2026-10-06 15:58:02 +02:00
/**
* @param {object} options
* @param {string} options.color Initial color.
* @param {number} options.width Initial width.
* @param {number} options.textSize
* @param {import('./page_model.js').TextFont} options.textFont
2026-10-14 14:00:47 +02:00
* @param {Shape} options.shape The Shape tool's shape to begin with.
* @param {string | null} options.fill Its fill colour, or none.
* @param {(tool: Tool) => void} options.setTool
* @param {(shape: Shape) => void} options.setShape
* @param {(fill: string | null) => void} options.setFill
2026-10-06 15:58:02 +02:00
* @param {(color: string) => void} options.setColor
* @param {(width: number) => void} options.setWidth
* @param {(size: number) => void} options.setTextSize
* @param {(font: import('./page_model.js').TextFont) => void} options.setTextFont
*/
function buildToolbar ( options ) {
2026-10-14 14:00:47 +02:00
const shapeButton = element ( 'shape_tool' );
/** The Shape button's hold has just opened the menu: the click that follows chooses nothing. */
let shapeMenuOpened = false ;
/** @param {Element} button */
const chooseTool = ( button ) => {
// The tool buttons stand in two groups since 2026-10-11 (Pen, Text
// and the shapes before the colours they use, Hide, Arrange and
// Eraser after), so the pressed one is marked among all of them, not
// among siblings.
for ( const other of document . querySelectorAll ( '[data-tool]' )) {
other . setAttribute ( 'aria-pressed' , String ( other === button ));
}
options . setTool ( /** @type {Tool} */ ( button . getAttribute ( 'data-tool' )));
};
2026-10-06 15:58:02 +02:00
for ( const button of document . querySelectorAll ( '[data-tool]' )) {
button . addEventListener ( 'click' , () => {
2026-10-14 14:00:47 +02:00
if ( button === shapeButton && shapeMenuOpened ) {
shapeMenuOpened = false ;
return ;
2026-10-11 13:17:22 +02:00
}
2026-10-14 14:00:47 +02:00
chooseTool ( button );
});
}
// The Shape button names the current shape, Rectangle to begin with,
// and a quick press chooses the tool; a press held for HOLD_MS, a
// press with the right button (a pen's barrel button) or a double
// click (Andy, 2026-10-14) opens the menu of shapes, and the shape
// chosen there is the button's from then on and the tool is chosen
// (Andy, 2026-10-14: one button for any number of shapes). The menu
// marks the current shape, and ends with the fill colours. The button
// keeps the width of its widest name, so that nothing in the toolbar
// moves when the shape changes.
const shapeMenu = element ( 'shape_menu' );
const shapePopup = attachPopup ( shapeButton , shapeMenu , undefined , false );
let widest = 0 ;
for ( const name of Object . values ( SHAPE_NAMES )) {
shapeButton . textContent = name ;
widest = Math . max ( widest , shapeButton . getBoundingClientRect (). width );
}
shapeButton . style . minWidth = ` ${ Math . ceil ( widest ) } px` ;
/** @param {Shape} shape */
const showShape = ( shape ) => {
shapeButton . textContent = SHAPE_NAMES [ shape ];
for ( const item of shapeMenu . querySelectorAll ( '[data-shape]' )) {
item . setAttribute ( 'aria-checked' , String ( item . getAttribute ( 'data-shape' ) === shape ));
}
};
showShape ( options . shape );
/** @type {ReturnType<typeof setTimeout> | null} */
let shapeHold = null ;
const cancelShapeHold = () => {
if ( shapeHold !== null ) clearTimeout ( shapeHold );
shapeHold = null ;
};
const openShapeMenu = () => {
shapeMenuOpened = true ;
shapePopup . open ();
};
shapeButton . addEventListener ( 'pointerdown' , ( event ) => {
if ( event . button !== 0 ) return ;
shapeMenuOpened = false ;
cancelShapeHold ();
shapeHold = setTimeout (() => {
shapeHold = null ;
openShapeMenu ();
}, HOLD_MS );
});
for ( const type of [ 'pointerup' , 'pointercancel' , 'pointerleave' ]) shapeButton . addEventListener ( type , cancelShapeHold );
shapeButton . addEventListener ( 'contextmenu' , ( event ) => {
event . preventDefault ();
cancelShapeHold ();
openShapeMenu ();
shapeMenuOpened = false ; // No click follows a secondary press.
});
// A double click, told from two clicks within DOUBLE_TAP_MS rather than
// from the browser's dblclick event, which a pen or a touch screen does
// not always send; both clicks have chosen the tool already.
let lastShapeClick = - Infinity ;
shapeButton . addEventListener ( 'click' , () => {
const now = performance . now ();
if ( now - lastShapeClick <= DOUBLE_TAP_MS ) {
lastShapeClick = - Infinity ;
openShapeMenu ();
shapeMenuOpened = false ;
} else {
lastShapeClick = now ;
}
});
// The fill colours: the toolbar's colours, those added to it, and
// "none", a state of their own (Andy, 2026-10-14). A press keeps the
// menu open.
const fills = element ( 'shape_fill' );
/** @param {string | null} value */
const addFill = ( value ) => {
const button = document . createElement ( 'button' );
button . type = 'button' ;
button . className = `swatch ${ value === null ? ' none' : '' } ` ;
if ( value !== null ) button . style . setProperty ( '--swatch' , value );
button . setAttribute ( 'aria-label' , value === null ? 'No fill' : `Fill ${ value } ` );
button . setAttribute ( 'data-help' , value === null ? 'Draw the shapes unfilled.' : 'Fill the shapes with this color.' );
button . setAttribute ( 'aria-pressed' , String ( value === options . fill ));
button . addEventListener ( 'click' , () => {
press ( fills , button );
options . setFill ( value );
});
fills . append ( button );
};
addFill ( null );
for ( const value of COLORS ) addFill ( value );
for ( const item of shapeMenu . querySelectorAll ( '[data-shape]' )) {
item . addEventListener ( 'click' , () => {
const chosen = shapeNamed ( item . getAttribute ( 'data-shape' ));
options . setShape ( chosen );
showShape ( chosen );
chooseTool ( shapeButton );
2026-10-06 15:58:02 +02:00
});
}
const colors = element ( 'colors' );
const palette = element ( 'color_palette' );
const more = element ( 'more_colors' );
/** @param {string} color */
const addSwatch = ( color ) => {
const button = document . createElement ( 'button' );
button . type = 'button' ;
button . className = 'swatch' ;
button . style . setProperty ( '--swatch' , color );
button . setAttribute ( 'aria-label' , `Color ${ color } ` );
button . setAttribute ( 'data-help' , 'Write in this color.' );
button . setAttribute ( 'aria-pressed' , String ( color === options . color ));
button . addEventListener ( 'click' , () => {
press ( colors , button );
options . setColor ( color );
});
colors . insertBefore ( button , more );
return button ;
};
/** @type {string[]} */
let added = [];
try {
added = JSON . parse ( localStorage . getItem ( COLORS_KEY ) ?? '[]' );
} catch {
added = [];
}
for ( const color of COLORS ) addSwatch ( color );
for ( const color of added ) {
2026-10-14 14:00:47 +02:00
if ( /^#[0-9a-f]{6}$/ . test ( color )) {
addSwatch ( color );
addFill ( color );
}
2026-10-06 15:58:02 +02:00
}
// "+" opens a palette below it; a chosen colour becomes a swatch before
// the "+" and is the colour from then on.
/** @param {string} color */
const choose = ( color ) => {
palette . hidden = true ;
const existing = [... colors . querySelectorAll ( '.swatch' )]
. find (( swatch ) => swatch . getAttribute ( 'aria-label' ) === `Color ${ color } ` );
const button = existing ?? addSwatch ( color );
if ( ! existing ) {
added . push ( color );
localStorage . setItem ( COLORS_KEY , JSON . stringify ( added ));
2026-10-14 14:00:47 +02:00
addFill ( color ); // The shapes' fills follow the toolbar's colours.
2026-10-06 15:58:02 +02:00
}
press ( colors , button );
options . setColor ( color );
};
2026-10-10 19:01:20 +02:00
// A colour that is already in the row is missing from the palette, its
// place in the grid left empty (Andy's teacher, 2026-10-08).
const inRow = () => new Set ([... colors . querySelectorAll ( '.swatch:not(.more)' )]
. map (( swatch ) => swatch . getAttribute ( 'aria-label' ) ? . replace ( 'Color ' , '' )));
const showPalette = () => {
palette . replaceChildren ();
const taken = inRow ();
for ( const color of PALETTE ) {
if ( taken . has ( color )) {
const gap = document . createElement ( 'span' );
gap . className = 'swatch missing' ;
palette . append ( gap );
continue ;
}
const button = document . createElement ( 'button' );
button . type = 'button' ;
button . className = 'swatch' ;
button . style . setProperty ( '--swatch' , color );
button . setAttribute ( 'aria-label' , `Color ${ color } ` );
button . addEventListener ( 'click' , () => choose ( color ));
palette . append ( button );
}
};
2026-10-14 01:07:53 +02:00
attachPopup ( more , palette , showPalette );
2026-10-06 15:58:02 +02:00
const widths = element ( 'widths' );
for ( const width of WIDTHS ) {
const button = document . createElement ( 'button' );
button . type = 'button' ;
button . className = 'width_choice' ;
button . setAttribute ( 'aria-label' , `Line width ${ width / 10 } mm` );
button . setAttribute ( 'data-help' , `Write with a ${ width / 10 } mm line. Pressing harder widens it.` );
button . setAttribute ( 'aria-pressed' , String ( width === options . width ));
const line = document . createElement ( 'span' );
line . style . height = ` ${ Math . max ( 1 , Math . round ( width / 2.5 )) } px` ;
button . append ( line );
button . addEventListener ( 'click' , () => {
press ( widths , button );
options . setWidth ( width );
});
widths . append ( button );
}
// The text size is a number of points; any size works, since the fonts
// scale. The font is one of the three generic families.
const size = input ( 'text_size' );
size . value = String ( Math . round ( options . textSize / UNITS_PER_POINT ));
const applySize = () => {
const points = Math . max ( LEAST_TEXT_POINTS , Math . min ( MOST_TEXT_POINTS , Number ( size . value ) || 0 ));
size . value = String ( points );
options . setTextSize ( Math . round ( points * UNITS_PER_POINT * 10 ) / 10 );
};
size . addEventListener ( 'change' , applySize );
size . addEventListener ( 'input' , () => {
if ( Number ( size . value ) >= LEAST_TEXT_POINTS ) applySize ();
});
const font = /** @type {HTMLSelectElement} */ ( document . getElementById ( 'text_font' ));
font . value = options . textFont ;
font . style . fontFamily = options . textFont ;
font . addEventListener ( 'change' , () => {
const chosen = TEXT_FONTS . find (( family ) => family === font . value ) ?? 'sans-serif' ;
font . style . fontFamily = chosen ;
options . setTextFont ( chosen );
});
}
/**
* @param {Session} session
* @param {Renderer} renderer
*/
function showParticipants ( session , renderer ) {
const list = element ( 'participants' );
list . replaceChildren ();
for ( const participant of session . participantList ()) {
const item = document . createElement ( 'li' );
const present = participant . self || participant . peer !== null ;
item . classList . toggle ( 'absent' , ! present );
if ( session . isHost && ! participant . self ) {
2026-10-10 19:01:20 +02:00
// A checkbox with its label to the left, like Extend (Andy, 2026-10-09).
const allowLine = document . createElement ( 'label' );
allowLine . className = 'allow_line' ;
2026-10-11 19:43:17 +02:00
allowLine . setAttribute ( 'data-help' , 'Make this participant a co-host: they may then start pages, put images on the page and arrange them, load notebook pages, expand, shrink and hide, as you can.' );
2026-10-10 19:01:20 +02:00
const allow = document . createElement ( 'input' );
allow . type = 'checkbox' ;
2026-10-06 15:58:02 +02:00
allow . className = 'allow' ;
2026-10-10 19:01:20 +02:00
allow . checked = session . permitted . has ( participant . id );
allow . addEventListener ( 'change' , () => session . setPermitted ( participant . id , allow . checked ));
allowLine . append ( 'Co-host ' , allow );
item . append ( allowLine );
2026-10-06 15:58:02 +02:00
}
const label = document . createElement ( 'label' );
const visible = document . createElement ( 'input' );
visible . type = 'checkbox' ;
visible . checked = ! renderer . hidden . has ( participant . id );
2026-10-14 01:07:53 +02:00
visible . setAttribute ( 'aria-label' , 'Shown' );
2026-10-06 15:58:02 +02:00
visible . setAttribute ( 'data-help' , 'Show or hide this participant’ s writing on your screen only.' );
visible . addEventListener ( 'change' , () => renderer . setLayerVisible ( participant . id , visible . checked ));
label . append ( visible , ` ${ participant . name } ` );
item . append ( label );
const roles = [];
if ( participant . self ) roles . push ( 'you' );
if ( participant . host ) roles . push ( 'host' );
2026-10-10 19:01:20 +02:00
else if ( ! session . isHost && session . permitted . has ( participant . id )) roles . push ( 'co-host' );
2026-10-06 15:58:02 +02:00
if ( ! present ) roles . push ( 'away' );
if ( roles . length ) {
const role = document . createElement ( 'span' );
role . className = 'role' ;
role . textContent = roles . join ( ', ' );
item . append ( role );
}
list . append ( item );
}
}
// Startup.
/** @param {any} error */
function failedToEnter ( error ) {
entered = false ;
showStart ( String ( error ? . message ?? error ));
}
element ( 'start_session' ). addEventListener ( 'click' , () => {
startSession (). catch ( failedToEnter );
});
element ( 'join_session' ). addEventListener ( 'click' , () => {
joinSession (). catch ( failedToEnter );
});
/**
* Begin where this browser tab left off. A tab that was in a session and
* is reloaded goes straight back into it. A tab that arrives with an
* invitation in its address shows the start screen with the invitation
* filled in, so that the name and the notebook can be chosen.
*/
async function resume () {
const invitationText = invitationInAddress ();
const invitation = parseInvitation ( invitationText );
const name = localStorage . getItem ( NAME_KEY );
if ( invitation && name ) {
const { roomId } = await deriveSession ( invitation . secret );
const notebook = sessionStorage . getItem ( notebookKeyFor ( roomId ));
if ( notebook ) {
await enterSession ( invitationText , name , notebook );
return ;
}
}
showStart ();
}
enableTooltips ();
2026-10-10 19:01:20 +02:00
guardSelection ();
log ( `Browser: ${ navigator . userAgent } ; window ${ innerWidth } × ${ innerHeight } , pixel ratio ${ devicePixelRatio } ` );
2026-10-06 15:58:02 +02:00
const token = addressValues (). get ( 'token' );
if ( token ) {
rememberToken ( token );
setAddress ( invitationInAddress ());
}
/** Learn how this client was served, then begin. */
async function startUp () {
const served = await ( await fetch ( 'config.json' )). json ();
config = {
relay : String ( served . relay ?? '' ),
storage : served . storage === 'browser' ? 'browser' : 'files' ,
site : String ( served . site ?? '' ),
};
if ( config . storage === 'browser' ) useBrowserStorage ();
await resume ();
}
if ( ! window . isSecureContext ) {
showStart ( 'Kokkoja must be opened from this computer (http://localhost) or over HTTPS.' );
} else {
startUp (). catch ( failedToEnter );
}