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 {
LEAST_TEXT_POINTS , MAX_IMAGE_BYTES , MOST_TEXT_POINTS , TEXT_FONTS , TEXT_SIZE ,
UNITS_PER_POINT , strokesTouched , textAt , textsTouched ,
} from './page_model.js' ;
import { 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' ;
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-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' ;
/**
* 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-10 19:01:20 +02:00
let tool = /** @type {'pen' | 'eraser' | 'text' | 'hide'} */ ( 'pen' );
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 );
writer . changed ();
},
onStrokeProgress : ( layerId , stroke ) => {
syncLayers ();
renderer . strokeProgress ( layerId , stroke );
if ( stroke . done ) writer . changed ();
},
onTextChanged : ( layerId ) => {
syncLayers ();
renderer . redrawLayer ( layerId );
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-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 ();
},
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-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 ) => {
log ( `The host's view: ${ command === 'fit' ? 'Fit' : 'A4' } ` );
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 );
},
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-11 13:17:22 +02:00
// The drawing group keeps the width of its widest state, the pen's
// colours and widths, so that the page-and-view group, centred between
// it and the session group, stays put when the tool changes (Andy,
// 2026-10-11). The floor rises if the group ever grows, as when a
// colour is added.
// The settings (colours, widths, font and size) keep their widest
// room likewise, so that Eraser, Hide and Undo, which stand after them
// since 2026-10-11, do not jump when a tool shows fewer settings.
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).
// A block being typed is finished first, so that the undo is of it.
const undo = () => {
textEditor ? . stop ();
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-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 = '' ;
/**
* What a hold of the pen is handling: a Hide rectangle, a text block 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).
* @typedef {object} Held
* @property {'hide' | 'text' | 'image'} kind
* @property {number} index Of the Hide rectangle or image.
* @property {string} id Of the text block.
* @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 ;
}
const block = session . ownLayer . texts . get ( h . id );
return block ? textBounds ( block ) : null ;
}
/** @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 );
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-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 ;
renderer . showGrip ( currentHeldRect ( found ), found . corner );
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 ;
const rect = heldRect ( held , now , x , y , held . kind === 'image' );
if ( held . kind === 'image' ) {
held . index = session . placeImageByHand ( held . index , rect );
} else if ( held . kind === 'hide' ) {
session . placeHideByHand ( held . index , rect );
} 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 ();
}
renderer . showGrip ( currentHeldRect ( held ), held . corner );
},
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` );
if ( held . kind !== 'text' ) session . sendPageNow ();
2026-10-11 13:17:22 +02:00
if ( held . kind === 'text' ) session . endTextEdit ( held . id , 'hold' );
if ( held . kind === 'hide' ) session . endHideEdit ();
2026-10-10 19:01:20 +02:00
held = null ;
renderer . showGrip ( null );
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 ) => {
if ( Math . abs ( x1 - x0 ) >= TAP_DISTANCE || Math . abs ( y1 - y0 ) >= TAP_DISTANCE ) {
textEditor ? . showPreview ( x0 , y0 , x1 , y1 );
} else {
textEditor ? . hidePreview ();
}
2026-10-06 15:58:02 +02:00
},
2026-10-10 19:01:20 +02:00
placeRect : ( x0 , y0 , x1 , y1 ) => {
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 ({
color , width , textSize , textFont ,
setTool : ( value ) => {
if ( value !== 'text' ) textEditor ? . stop ();
2026-10-10 19:01:20 +02:00
if ( value !== tool ) trace ( `Tool: ${ value } ` );
2026-10-06 15:58:02 +02:00
tool = value ;
// Only the chosen tool's settings show: the pen's colours and line
// widths, the text's colours, size and font, nothing for the eraser.
2026-10-10 19:01:20 +02:00
element ( 'colors' ). hidden = value === 'eraser' || value === 'hide' ;
2026-10-06 15:58:02 +02:00
element ( 'widths' ). hidden = value !== 'pen' ;
element ( 'text_style' ). hidden = value !== 'text' ;
},
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-10 19:01:20 +02:00
// The host's Fit and A4 reach everyone (Andy, 2026-10-08); a guest's
// are the guest's own.
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-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 ));
}
}
/**
* @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-10 19:01:20 +02:00
* @param {(tool: 'pen' | 'eraser' | 'text' | 'hide') => void} options.setTool
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 ) {
for ( const button of document . querySelectorAll ( '[data-tool]' )) {
button . addEventListener ( 'click' , () => {
2026-10-11 13:17:22 +02:00
// The tool buttons stand in two groups since 2026-10-11 (Pen and
// Text before the colours they use, Eraser and Hide 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 ));
}
2026-10-06 15:58:02 +02:00
const chosen = button . getAttribute ( 'data-tool' );
2026-10-10 19:01:20 +02:00
options . setTool ( chosen === 'eraser' ? 'eraser' : chosen === 'text' ? 'text' : chosen === 'hide' ? 'hide' : 'pen' );
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 ) {
if ( /^#[0-9a-f]{6}$/ . test ( color )) addSwatch ( color );
}
// "+" 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 ));
}
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-06 15:58:02 +02:00
more . addEventListener ( 'click' , () => {
if ( ! palette . hidden ) {
palette . hidden = true ;
return ;
}
2026-10-10 19:01:20 +02:00
showPalette ();
2026-10-06 15:58:02 +02:00
const box = more . getBoundingClientRect ();
palette . style . left = ` ${ box . left } px` ;
palette . style . top = ` ${ box . bottom + 6 } px` ;
palette . hidden = false ;
});
document . addEventListener ( 'pointerdown' , ( event ) => {
if ( ! palette . hidden && ! palette . contains ( /** @type {Node} */ ( event . target )) && event . target !== more ) {
palette . hidden = true ;
}
});
document . addEventListener ( 'keydown' , ( event ) => {
if ( event . key === 'Escape' ) palette . hidden = true ;
});
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 );
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 );
}