958 lines
35 KiB
JavaScript
958 lines
35 KiB
JavaScript
// @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 {
|
|||
|
|
NotebookWriter, keepCurrentPage, keepIdentity, listKeptPages, listNotebooks, notebooksInBrowser,
|
|||
|
|
readCurrentPage, readIdentity, readPage, rememberToken, useBrowserStorage,
|
|||
|
|
} from './notebook.js';
|
|||
|
|
import { download, pickImageFile } from './browser_files.js';
|
|||
|
|
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';
|
|||
|
|
import { Renderer, ZOOM_STEP } from './renderer.js';
|
|||
|
|
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';
|
|||
|
|
|
|||
|
|
const COLORS = ['#1c1c1e', '#1f4fd8', '#d42a2a', '#1c8a3c'];
|
|||
|
|
/** Everyone begins in black (Andy, 2026-10-03; the host began in red before). */
|
|||
|
|
const DEFAULT_COLOR = COLORS[0];
|
|||
|
|
|
|||
|
|
/** Nominal line widths in page units (0.1 mm). */
|
|||
|
|
const WIDTHS = [4, 7, 12];
|
|||
|
|
|
|||
|
|
/** 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;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 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';
|
|||
|
|
const DEFAULT_NOTEBOOK = '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));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 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) ?? '';
|
|||
|
|
input('notebook').value = localStorage.getItem(NOTEBOOK_KEY) ?? DEFAULT_NOTEBOOK;
|
|||
|
|
const pending = invitationInAddress();
|
|||
|
|
if (pending) input('invitation_entry').value = pending;
|
|||
|
|
// 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)}`;
|
|||
|
|
showNotebooks().catch((error) => {
|
|||
|
|
element('notebook_note').textContent = String(error.message ?? error);
|
|||
|
|
element('notebook_note').classList.add('problem');
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Offer the existing notebooks and say where notebooks are kept. */
|
|||
|
|
async function showNotebooks() {
|
|||
|
|
const { directory, notebooks } = await listNotebooks();
|
|||
|
|
const choices = element('notebook_choices');
|
|||
|
|
choices.replaceChildren();
|
|||
|
|
for (const notebook of notebooks) {
|
|||
|
|
const option = document.createElement('option');
|
|||
|
|
option.value = notebook;
|
|||
|
|
choices.append(option);
|
|||
|
|
}
|
|||
|
|
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));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** @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;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** @returns {string} The notebook entered. */
|
|||
|
|
function enteredNotebook() {
|
|||
|
|
const notebook = input('notebook').value.trim() || DEFAULT_NOTEBOOK;
|
|||
|
|
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;
|
|||
|
|
|
|||
|
|
const isHost = identity.id === invitation.hostId;
|
|||
|
|
let tool = /** @type {'pen' | 'eraser' | 'text'} */ ('pen');
|
|||
|
|
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() {
|
|||
|
|
element('page_number').textContent = session.page.number > 0
|
|||
|
|
? `Page ${session.page.number}` : 'Waiting for the host’s page';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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) => {
|
|||
|
|
textEditor?.stop();
|
|||
|
|
// 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: () => {
|
|||
|
|
renderer.showImages();
|
|||
|
|
writer.writeNow();
|
|||
|
|
},
|
|||
|
|
onPageResized: (byImages) => {
|
|||
|
|
// An image added or taken back does not rescale what is in view.
|
|||
|
|
if (byImages) renderer.holdScale();
|
|||
|
|
renderer.resize();
|
|||
|
|
textEditor?.layout();
|
|||
|
|
writer.changed();
|
|||
|
|
},
|
|||
|
|
onPermissionsChanged: () => {
|
|||
|
|
showHostTools();
|
|||
|
|
showParticipants(session, renderer);
|
|||
|
|
},
|
|||
|
|
onParticipantsChanged: () => {
|
|||
|
|
syncLayers();
|
|||
|
|
showParticipants(session, renderer);
|
|||
|
|
},
|
|||
|
|
onStatus: (status, detail) => {
|
|||
|
|
const text = { connecting: 'Connecting…', connected: 'Connected', failed: detail ?? 'Disconnected' };
|
|||
|
|
element('status').textContent = text[status];
|
|||
|
|
element('status').classList.toggle('problem', status !== 'connected');
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const writer = new NotebookWriter(
|
|||
|
|
notebook,
|
|||
|
|
() => (session.page.number > 0 ? fileOf(session.page) : null),
|
|||
|
|
(message, problem) => {
|
|||
|
|
element('save_status').textContent = message;
|
|||
|
|
element('save_status').classList.toggle('problem', problem);
|
|||
|
|
});
|
|||
|
|
element('save_status').textContent = savedPage ? 'Continued from the notebook' : '';
|
|||
|
|
|
|||
|
|
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));
|
|||
|
|
|
|||
|
|
textEditor = new TextEditor(pageElement, renderer, {
|
|||
|
|
setText: (block) => session.setText(block),
|
|||
|
|
removeText: (id) => session.removeTexts([id]),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
attachPenInput(pageElement, {
|
|||
|
|
toPage: (clientX, clientY) => renderer.pageFromClient(clientX, clientY),
|
|||
|
|
tool: () => tool,
|
|||
|
|
canWrite: () => session.page.number > 0,
|
|||
|
|
beginStroke: () => {
|
|||
|
|
textEditor?.stop();
|
|||
|
|
renderer.holdFit = true;
|
|||
|
|
session.beginStroke(color, width);
|
|||
|
|
},
|
|||
|
|
addPoints: (points) => session.addPoints(points),
|
|||
|
|
endStroke: () => {
|
|||
|
|
session.endStroke();
|
|||
|
|
renderer.holdFit = false;
|
|||
|
|
if (renderer.zoom === 'fit') renderer.resize();
|
|||
|
|
},
|
|||
|
|
eraseAlong: (x0, y0, x1, y1) => {
|
|||
|
|
textEditor?.stop();
|
|||
|
|
const radius = renderer.pageLength(ERASER_RADIUS);
|
|||
|
|
session.erase(strokesTouched(session.ownLayer, x0, y0, x1, y1, radius));
|
|||
|
|
session.removeTexts(textsTouched(session.ownLayer, x0, y0, x1, y1, radius));
|
|||
|
|
},
|
|||
|
|
eraseEnded: (x, y, reach) => {
|
|||
|
|
// A tap of the eraser on the image we have just added takes it back.
|
|||
|
|
const image = session.removableImage;
|
|||
|
|
if (image && reach < TAP_DISTANCE && x >= image.x && x <= image.x + image.width &&
|
|||
|
|
y >= image.y && y <= image.y + image.height) {
|
|||
|
|
session.removeAddedImage();
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
previewText: (x0, y0, x1) => {
|
|||
|
|
if (Math.abs(x1 - x0) >= TAP_DISTANCE) textEditor?.showPreview(x0, y0, x1);
|
|||
|
|
else textEditor?.hidePreview();
|
|||
|
|
},
|
|||
|
|
placeText: (x0, y0, x1, y1) => {
|
|||
|
|
if (!textEditor) return;
|
|||
|
|
textEditor.hidePreview();
|
|||
|
|
const editing = textEditor.block;
|
|||
|
|
textEditor.stop();
|
|||
|
|
const own = textAt(session.ownLayer, x0, y0);
|
|||
|
|
if (Math.hypot(x1 - x0, y1 - y0) < TAP_DISTANCE) {
|
|||
|
|
// A tap: into our own text to change it, or a new block here.
|
|||
|
|
if (own && own !== editing) textEditor.edit(own);
|
|||
|
|
else if (!own) textEditor.begin(x0, y0, null, { color, size: textSize, font: textFont });
|
|||
|
|
} else {
|
|||
|
|
// A drag along the top edge: a new block that wraps at its width.
|
|||
|
|
textEditor.begin(Math.min(x0, x1), y0, Math.max(100, Math.round(Math.abs(x1 - x0))),
|
|||
|
|
{ color, size: textSize, font: textFont });
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
onReport: (report) => {
|
|||
|
|
element('pen_report').textContent =
|
|||
|
|
`Input: ${report.pointerType}, pressure ${report.pressure.toFixed(2)}, ` +
|
|||
|
|
`${report.pointsPerSecond} points per second`;
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
buildToolbar({
|
|||
|
|
color, width, textSize, textFont,
|
|||
|
|
setTool: (value) => {
|
|||
|
|
if (value !== 'text') textEditor?.stop();
|
|||
|
|
tool = value;
|
|||
|
|
// Only the chosen tool's settings show: the pen's colours and line
|
|||
|
|
// widths, the text's colours, size and font, nothing for the eraser.
|
|||
|
|
element('colors').hidden = value === 'eraser';
|
|||
|
|
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.
|
|||
|
|
const showZoom = () => {
|
|||
|
|
element('zoom_fit').setAttribute('aria-pressed', String(renderer.zoom === 'fit'));
|
|||
|
|
};
|
|||
|
|
// 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 = () => {
|
|||
|
|
showZoom();
|
|||
|
|
textEditor?.layout();
|
|||
|
|
viewChanged();
|
|||
|
|
};
|
|||
|
|
element('zoom_fit').addEventListener('click', () => renderer.setZoom('fit'));
|
|||
|
|
element('zoom_in').addEventListener('click', () => renderer.zoomBy(ZOOM_STEP));
|
|||
|
|
element('zoom_out').addEventListener('click', () => renderer.zoomBy(1 / ZOOM_STEP));
|
|||
|
|
showZoom();
|
|||
|
|
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() {
|
|||
|
|
element('host_tools').hidden = !session.mayMakePages();
|
|||
|
|
}
|
|||
|
|
showHostTools();
|
|||
|
|
element('new_page').addEventListener('click', () => session.newPage());
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Add an image file to the page, below the images already on it.
|
|||
|
|
* @param {Blob} file
|
|||
|
|
* @param {boolean} [shrink] Whether an image wider than the sheet is
|
|||
|
|
* scaled down to it.
|
|||
|
|
*/
|
|||
|
|
async function loadImage(file, shrink = true) {
|
|||
|
|
if (!session.mayMakePages() || session.page.number === 0) return;
|
|||
|
|
try {
|
|||
|
|
if (!session.addImage(await prepareImage(file), shrink)) {
|
|||
|
|
throw new Error('The page has no room for another image.');
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
element('save_status').textContent =
|
|||
|
|
error instanceof Error && error.message ? error.message : 'That file is not an image.';
|
|||
|
|
element('save_status').classList.add('problem');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
const chooser = new NotebookChooser((chosenNotebook, pageName, text) => {
|
|||
|
|
const kept = pageFromSvg(text);
|
|||
|
|
if (!kept) {
|
|||
|
|
element('save_status').textContent = `${pageName} is not a Kokkoja page.`;
|
|||
|
|
element('save_status').classList.add('problem');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
session.loadPage(kept.page);
|
|||
|
|
});
|
|||
|
|
element('load_notebook_page').addEventListener('click', () => { chooser.open(writer.notebook); });
|
|||
|
|
|
|||
|
|
// 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();
|
|||
|
|
if (file) loadImage(file, fileChooser.shrinksWideImages());
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 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 }));
|
|||
|
|
element('save_status').textContent = 'Exported to your downloads';
|
|||
|
|
element('save_status').classList.remove('problem');
|
|||
|
|
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;
|
|||
|
|
element('save_status').textContent = `Exported to ${saved}`;
|
|||
|
|
element('save_status').classList.remove('problem');
|
|||
|
|
}
|
|||
|
|
element('export_page').addEventListener('click', () => { exportPage(); });
|
|||
|
|
|
|||
|
|
// The notebook: its name, and keeping the page.
|
|||
|
|
const notebookField = input('session_notebook');
|
|||
|
|
notebookField.value = notebook;
|
|||
|
|
showNotebooks().catch(() => {});
|
|||
|
|
notebookField.addEventListener('change', () => {
|
|||
|
|
const chosen = notebookField.value.trim();
|
|||
|
|
if (!chosen || chosen === writer.notebook) {
|
|||
|
|
notebookField.value = 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()).catch(() => {});
|
|||
|
|
});
|
|||
|
|
element('keep_page').addEventListener('click', async () => {
|
|||
|
|
const kept = await writer.keepPage();
|
|||
|
|
// In the browser the notebook is out of reach of the user's own
|
|||
|
|
// programs: a kept page is also downloaded, which is how a page gets out.
|
|||
|
|
if (kept && notebooksInBrowser()) {
|
|||
|
|
const text = await readPage(writer.notebook, kept);
|
|||
|
|
if (text !== null) download(kept, new Blob([text], { type: 'image/svg+xml' }));
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
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: {
|
|||
|
|
session, renderer, writer, loadImage, textEditor,
|
|||
|
|
notebook: { listNotebooks, listKeptPages, readPage, readCurrentPage, notebooksInBrowser },
|
|||
|
|
} });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 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
|
|||
|
|
* @param {(tool: 'pen' | 'eraser' | 'text') => void} options.setTool
|
|||
|
|
* @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', () => {
|
|||
|
|
press(/** @type {Element} */ (button.parentElement), button);
|
|||
|
|
const chosen = button.getAttribute('data-tool');
|
|||
|
|
options.setTool(chosen === 'eraser' ? 'eraser' : chosen === 'text' ? 'text' : 'pen');
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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);
|
|||
|
|
};
|
|||
|
|
for (const color of PALETTE) {
|
|||
|
|
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);
|
|||
|
|
}
|
|||
|
|
more.addEventListener('click', () => {
|
|||
|
|
if (!palette.hidden) {
|
|||
|
|
palette.hidden = true;
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
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) {
|
|||
|
|
const allow = document.createElement('button');
|
|||
|
|
allow.type = 'button';
|
|||
|
|
allow.className = 'allow';
|
|||
|
|
allow.textContent = 'pages';
|
|||
|
|
allow.setAttribute('data-help', 'Let this participant start new pages and load images.');
|
|||
|
|
const allowed = session.permitted.has(participant.id);
|
|||
|
|
allow.setAttribute('aria-pressed', String(allowed));
|
|||
|
|
allow.addEventListener('click', () => session.setPermitted(participant.id, !allowed));
|
|||
|
|
item.append(allow);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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');
|
|||
|
|
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();
|
|||
|
|
|
|||
|
|
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);
|
|||
|
|
}
|