Kokkoja as of 2026-10-10: the teacher's requests, the Session window, notices, the QR code, the license

This commit is contained in:
ack committed 2026-10-10 19:01:20 +02:00
1 parent 95c80d04c9
commit 1d087af633
32 files changed
+4894 -867

No files matched your search

+703
View File
@@ -0,0 +1,703 @@
# License
Kokkoja is free software: Copyright © 2026 Andy Kopra.
You may use, study, copy, modify and distribute it, with or without
changes, under the terms of the GNU General Public License as published
by the Free Software Foundation, either version 3 of the License or, at
your option, any later version. The full text of version 3 follows this
notice. Kokkoja is distributed in the hope that it will be useful but
WITHOUT ANY WARRANTY; see the License for details.
SPDX-License-Identifier: GPL-3.0-or-later
## The name
As an additional term under section 7(e) of the License: the name
"Kokkoja" is a trade name of the author. Nothing in this License grants
the right to use it for a modified version, or for any other software,
in a way that suggests the author's endorsement or that the software is
the author's. A modified version that is distributed must carry a
different name, or make its origin and its changes plain. Ordinary
references to Kokkoja by name, such as "based on Kokkoja", remain free.
Contact: Andy Kopra <ack@acm.org>
## GNU General Public License, version 3
```
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
```
+7 -3
View File
@@ -7,7 +7,9 @@ replaces the call's screen sharing and annotation. It was made first for
teaching a foreign language over a video call, and it serves any work teaching a foreign language over a video call, and it serves any work
that two or more people would otherwise do over one sheet of paper. that two or more people would otherwise do over one sheet of paper.
Kokkoja is free and open-source software. Kokkoja is free software under the GNU General Public License, version
3 or later: see [LICENSE.md](LICENSE.md), which also says how the name
may be used.
## Documentation ## Documentation
@@ -43,5 +45,7 @@ Documents directory.
* `relay/`: the relay server, Python with the `websockets` library. * `relay/`: the relay server, Python with the `websockets` library.
* `deploy/`: how a Kokkoja server is set up (relay plus web site). * `deploy/`: how a Kokkoja server is set up (relay plus web site).
* `docs/`: the documentation above. * `docs/`: the documentation above.
* `tests/`: the tests (`python3 -m unittest discover tests`; the * `tests/`: the tests (`python3 -m unittest discover tests`); the
end-to-end test needs Firefox and geckodriver). end-to-end test needs Firefox and geckodriver, and the QR code's test
needs Node.js and `pip install -r tests/requirements.txt`.
* `LICENSE.md`: the license.
+64 -36
View File
@@ -15,17 +15,19 @@
<input id="name" type="text" maxlength="40" autocomplete="off"> <input id="name" type="text" maxlength="40" autocomplete="off">
<label for="notebook">Notebook for the pages of this session</label> <label for="notebook">Notebook for the pages of this session</label>
<input id="notebook" type="text" maxlength="100" autocomplete="off" list="notebook_choices"> <select id="notebook" class="notebook_pick"
<datalist id="notebook_choices"></datalist> data-help="The notebook this session’s pages are saved in. The last entry makes a new notebook."></select>
<input id="notebook_name" type="text" maxlength="100" autocomplete="off" spellcheck="false"
placeholder="Name of the new notebook" aria-label="Name of the new notebook" hidden>
<p id="notebook_note" class="note notebook_directory"></p> <p id="notebook_note" class="note notebook_directory"></p>
<section> <section id="start_choice">
<h2>Start a session</h2> <h2>Start a session</h2>
<p>You will be given an invitation to send to the others.</p> <p>You will be given an invitation to send to the others.</p>
<button id="start_session" type="button">Start a session</button> <button id="start_session" type="button">Start a session</button>
</section> </section>
<section> <section id="join_choice">
<h2>Join a session</h2> <h2>Join a session</h2>
<label for="invitation_entry">Invitation</label> <label for="invitation_entry">Invitation</label>
<input id="invitation_entry" type="text" autocomplete="off" spellcheck="false"> <input id="invitation_entry" type="text" autocomplete="off" spellcheck="false">
@@ -42,9 +44,11 @@
<button type="button" data-tool="pen" aria-pressed="true" <button type="button" data-tool="pen" aria-pressed="true"
data-help="Write on the page. Only your own writing can be erased.">Pen</button> data-help="Write on the page. Only your own writing can be erased.">Pen</button>
<button type="button" data-tool="text" aria-pressed="false" <button type="button" data-tool="text" aria-pressed="false"
data-help="Type on the page. Tap where the text should begin, or drag along its top edge to set the width at which it wraps. Tap your own text to change or move it.">Text</button> data-help="Type on the page. Tap where the text should begin, or drag open a rectangle: a red box, white inside, in which the text wraps and which grows downward as you type. Tap your own text to change it; rest the pen on it to move it, or near a corner of a box to resize it.">Text</button>
<button type="button" data-tool="eraser" aria-pressed="false" <button type="button" data-tool="eraser" aria-pressed="false"
data-help="Erase your own strokes and text by moving over them.">Eraser</button> data-help="Erase your own strokes and text by moving over them. A tap on an image, or on a Hide rectangle, removes it, if you are the host or a co-host (or added the image yourself).">Eraser</button>
<button type="button" data-tool="hide" aria-pressed="false" hidden
data-help="Hide part of the page for everyone: drag open a rectangle, and what lies under it is covered in white, marked by blue corners. Rest the pen on it to move it, or near a corner to resize it; a tap of the eraser removes it.">Hide</button>
</div> </div>
<div class="group" id="colors" role="radiogroup" aria-label="Color"> <div class="group" id="colors" role="radiogroup" aria-label="Color">
<button id="more_colors" type="button" class="swatch more" <button id="more_colors" type="button" class="swatch more"
@@ -61,54 +65,81 @@
<input id="text_size" type="number" min="6" max="100" step="1" value="14" <input id="text_size" type="number" min="6" max="100" step="1" value="14"
aria-label="Text size in points" data-help="Text size in points."> aria-label="Text size in points" data-help="Text size in points.">
</div> </div>
<div class="toolbar_right"> <div class="group" aria-label="View">
<div class="group" role="radiogroup" aria-label="Zoom"> <button id="zoom_fit" type="button"
<button id="zoom_fit" type="button" aria-pressed="true" data-help="Show the whole page in the window. Pressed by the host, it does so on every screen. Ctrl (Command on a Mac) with the scroll wheel, or with a pen stroke up or down, zooms about the pointer.">Fit</button>
data-help="Show the whole page in the window. Ctrl and the scroll wheel zoom in and out.">Fit</button> <button id="zoom_sheet" type="button"
<button id="zoom_in" type="button" data-help="Zoom in by a step. Ctrl and the scroll wheel do the same about the pointer.">+</button> data-help="Show the page at the scale a new page has: one A4 sheet filling the window. A shrunk page is then seen at its size on the sheet. Pressed by the host, it does so on every screen.">A4</button>
<button id="zoom_out" type="button" data-help="Zoom out by a step.">−</button> <button id="zoom_widen" type="button" hidden
data-help="Make the page wider, for everyone, until it fills the window at this zoom. Nothing on it moves.">Widen</button>
<button id="zoom_shrink" type="button" hidden
data-help="Make the page, for everyone, just large enough for what is on it, with the margin around. Nothing on it moves.">Shrink</button>
<label class="toggle" data-help="Let the page grow when your writing reaches an edge. Off, your writing stops at the edge. Images always get the room they need.">
Extend <input id="extend" type="checkbox" checked>
</label>
</div> </div>
<div class="toolbar_right">
<div class="group" id="host_tools" hidden> <div class="group" id="host_tools" hidden>
<button id="new_page" type="button" <button id="new_page" type="button"
data-help="Start a new blank page for everyone. The current page is kept in each notebook.">New</button> data-help="Start a new blank page for everyone. The current page is kept in each notebook.">New</button>
<button id="load_image" type="button" <button id="load_image" type="button"
data-help="Put an image file, such as a scanned exercise, on the page for everyone. It goes below the images already there. The view keeps its scale; scroll down to a new image. For a page of its own, press New first. An image you have just added can be taken back with a tap of the eraser, until anything else is done on the page.">Image</button> data-help="Put an image file, such as a scanned exercise, on the page for everyone: below the images already there, centred, at its own size. Rest the pen on an image to move it, or near a corner to scale it; it comes to the front. A tap of the eraser on an image removes it; writing on it stays.">Image</button>
<button id="load_notebook_page" type="button" <button id="load_notebook_page" type="button"
data-help="Start a new page for everyone from a page kept in a notebook, with everything on it. The current page is kept in each notebook.">Notebook</button> data-help="Start a new page for everyone from a page kept in a notebook, with everything on it. The current page is kept in each notebook.">Notebook</button>
</div> </div>
<span id="page_number" class="page_number"></span> <div class="group" aria-label="Notebook">
<span id="status" class="status"></span> <select id="session_notebook" class="notebook_pick" aria-label="Current notebook"
data-help="The notebook the page is saved in. Choosing another continues the page there; the last entry makes a new notebook."></select>
<input id="session_notebook_name" type="text" maxlength="100" autocomplete="off" spellcheck="false"
placeholder="Name of the new notebook" aria-label="Name of the new notebook" hidden>
<button id="keep_page" type="button"
data-help="Keep a dated copy of the page as it is now. The page is also kept by itself whenever it is about to be replaced.">Keep</button>
<button id="export_page" type="button"
data-help="Save a picture of the page, as it is shown here, in a PNG file at a place you choose.">Export</button>
</div>
<button id="session_button" type="button"
data-help="Who is here, and the invitation to send to others.">Session</button>
</div> </div>
</header> </header>
<div id="notice" class="notice" hidden></div>
<div class="workspace"> <div class="workspace">
<div id="desk" class="desk"> <div id="desk" class="desk">
<div id="page" class="page"></div> <div id="page" class="page"></div>
</div> </div>
</div>
</div>
<aside class="panel"> <dialog id="session_dialog" class="chooser session">
<h2>Participants</h2> <header>
<h2>Session</h2>
<button id="session_close" type="button" aria-label="Close">×</button>
</header>
<div class="session_body">
<h3>Participants</h3>
<ul id="participants"></ul> <ul id="participants"></ul>
<h3>Invitation</h3>
<h2>Invitation</h2> <div class="invitation_line">
<input id="invitation" type="text" readonly spellcheck="false"> <input id="invitation" type="text" readonly spellcheck="false">
<button id="copy_invitation" type="button" <button id="copy_invitation" type="button"
data-help="Copy the invitation, to send to the others by any means.">Copy</button> data-help="Copy the invitation, to send to the others by any means.">Copy</button>
<h2><label for="session_notebook">Current notebook</label></h2>
<input id="session_notebook" type="text" maxlength="100" autocomplete="off"
list="notebook_choices">
<button id="keep_page" type="button"
data-help="Keep a dated copy of the page as it is now. The page is also kept by itself whenever it is about to be replaced.">Keep page</button>
<button id="export_page" type="button"
data-help="Save a picture of the page, as it is shown here, in a PNG file at a place you choose.">Export</button>
<p id="save_status" class="note"></p>
<p class="note notebook_directory"></p>
<p id="pen_report" class="report"></p>
</aside>
</div> </div>
</div> <div id="invitation_code" class="invitation_code" hidden
data-help="The invitation as a QR code. Point the camera of a phone or a tablet at it, and the link opens there."></div>
<p class="note session_state">
<span id="page_number" class="page_number"></span>
<span id="status" class="status"></span>
<span id="save_status" class="save_status"></span>
</p>
<p class="note notebook_directory"></p>
<h3>Log</h3>
<div class="log_line">
<pre id="event_log" class="event_log"></pre>
<button id="copy_log" type="button"
data-help="Copy the log: what happened in this session and every press and release of the pen, for finding a fault.">Copy</button>
</div>
</div>
</dialog>
<dialog id="notebook_chooser" class="chooser"> <dialog id="notebook_chooser" class="chooser">
<header> <header>
@@ -136,9 +167,6 @@
<nav id="files_directories" class="files_directories"></nav> <nav id="files_directories" class="files_directories"></nav>
<div id="files_entries" class="files_entries"></div> <div id="files_entries" class="files_entries"></div>
</div> </div>
<label id="files_shrink_line" class="files_shrink">
<input id="files_shrink" type="checkbox" checked> Shrink an image wider than the page to fit it
</label>
<div id="files_save" class="files_save" hidden> <div id="files_save" class="files_save" hidden>
<input id="files_name" type="text" spellcheck="false" autocomplete="off" <input id="files_name" type="text" spellcheck="false" autocomplete="off"
aria-label="File name"> aria-label="File name">
+2 -3
View File
@@ -137,9 +137,8 @@ export const backend = {
async keepPage(notebook) { async keepPage(notebook) {
return transaction('readwrite', async (pages) => { return transaction('readwrite', async (pages) => {
const current = /** @type {StoredPage | undefined} */ (await result(pages.get([notebook, CURRENT_PAGE]))); const current = /** @type {StoredPage | undefined} */ (await result(pages.get([notebook, CURRENT_PAGE])));
if (!current || !(current.text.includes('data-stroke=') || current.text.includes('<image'))) { const blank = !['data-stroke=', 'data-text=', '<image'].some((mark) => current?.text.includes(mark));
return { kept: null, new: false }; if (!current || blank) return { kept: null, new: false };
}
const kept = (await pagesOf(pages, notebook)).filter((page) => KEPT_PAGE.test(page.name)); const kept = (await pagesOf(pages, notebook)).filter((page) => KEPT_PAGE.test(page.name));
const same = kept.find((page) => page.text === current.text); const same = kept.find((page) => page.text === current.text);
if (same) return { kept: same.name, new: false }; if (same) return { kept: same.name, new: false };
+2 -1
View File
@@ -53,6 +53,8 @@ export class NotebookChooser {
try { try {
const { notebooks } = await listNotebooks(); const { notebooks } = await listNotebooks();
this.showNotebooks(notebooks); this.showNotebooks(notebooks);
// The notebook being written in is chosen already, its pages shown;
// the list is always there to see (Andy, 2026-10-06).
const chosen = notebook && notebooks.includes(notebook) ? notebook const chosen = notebook && notebooks.includes(notebook) ? notebook
: notebooks.length === 1 ? notebooks[0] : ''; : notebooks.length === 1 ? notebooks[0] : '';
await this.showPages(chosen); await this.showPages(chosen);
@@ -65,7 +67,6 @@ export class NotebookChooser {
showNotebooks(notebooks) { showNotebooks(notebooks) {
const list = element('chooser_notebooks'); const list = element('chooser_notebooks');
list.replaceChildren(); list.replaceChildren();
list.hidden = notebooks.length <= 1;
for (const notebook of notebooks) { for (const notebook of notebooks) {
const button = document.createElement('button'); const button = document.createElement('button');
button.type = 'button'; button.type = 'button';
-17
View File
@@ -13,9 +13,6 @@ import { listFiles, readFile } from './files.js';
/** Where the chooser was last used, for each of its two purposes. */ /** Where the chooser was last used, for each of its two purposes. */
const DIRECTORY_KEYS = { image: 'kokkoja_image_directory', export: 'kokkoja_export_directory' }; const DIRECTORY_KEYS = { image: 'kokkoja_image_directory', export: 'kokkoja_export_directory' };
/** Whether an image wider than the page is shrunk to fit it: 'no' if not. */
const SHRINK_KEY = 'kokkoja_image_shrink';
/** The longer side of the small pictures of image files, in pixels. */ /** The longer side of the small pictures of image files, in pixels. */
const SMALL_PICTURE_SIDE = 240; const SMALL_PICTURE_SIDE = 240;
/** No small picture is made of a file larger than this. */ /** No small picture is made of a file larger than this. */
@@ -34,11 +31,6 @@ export class FileChooser {
this.dialog = /** @type {HTMLDialogElement} */ (element('file_chooser')); this.dialog = /** @type {HTMLDialogElement} */ (element('file_chooser'));
this.directoryField = /** @type {HTMLInputElement} */ (element('files_directory')); this.directoryField = /** @type {HTMLInputElement} */ (element('files_directory'));
this.nameField = /** @type {HTMLInputElement} */ (element('files_name')); this.nameField = /** @type {HTMLInputElement} */ (element('files_name'));
this.shrinkBox = /** @type {HTMLInputElement} */ (element('files_shrink'));
this.shrinkBox.checked = localStorage.getItem(SHRINK_KEY) !== 'no';
this.shrinkBox.addEventListener('change', () => {
localStorage.setItem(SHRINK_KEY, this.shrinkBox.checked ? 'yes' : 'no');
});
/** @type {'image' | 'export'} */ /** @type {'image' | 'export'} */
this.purpose = 'image'; this.purpose = 'image';
/** @type {import('./files.js').DirectoryListing | null} */ /** @type {import('./files.js').DirectoryListing | null} */
@@ -84,14 +76,6 @@ export class FileChooser {
return this.open('image', 'Image'); return this.open('image', 'Image');
} }
/**
* @returns {boolean} Whether the user wants an image wider than the page
* shrunk to fit it (the other choice keeps its size and widens the page).
*/
shrinksWideImages() {
return this.shrinkBox.checked;
}
/** /**
* Let the user choose a directory and a name, and save there. * Let the user choose a directory and a name, and save there.
* @param {string} title * @param {string} title
@@ -119,7 +103,6 @@ export class FileChooser {
this.purpose = purpose; this.purpose = purpose;
element('files_title').textContent = title; element('files_title').textContent = title;
element('files_save').hidden = purpose !== 'export'; element('files_save').hidden = purpose !== 'export';
element('files_shrink_line').hidden = purpose !== 'image';
element('files_directories').replaceChildren(); element('files_directories').replaceChildren();
element('files_entries').replaceChildren(); element('files_entries').replaceChildren();
this.dialog.showModal(); this.dialog.showModal();
+9 -1
View File
@@ -11,13 +11,21 @@
/** Resolutions outside this range are mistakes, not information. */ /** Resolutions outside this range are mistakes, not information. */
const LEAST_DPI = 30; const LEAST_DPI = 30;
const MOST_DPI = 2400; const MOST_DPI = 2400;
/**
* The nominal screen resolutions that cameras, phones and screenshots
* write without meaning them; an image so marked arrives at the default
* size instead (Andy, 2026-10-08, with every image now placed at its
* natural size: at 72 dpi a phone's photo would be over a metre wide).
*/
const NOMINAL_DPI = [72, 96];
/** /**
* @param {number | null} dpi * @param {number | null} dpi
* @returns {number | null} * @returns {number | null}
*/ */
function plausible(dpi) { function plausible(dpi) {
return dpi !== null && dpi >= LEAST_DPI && dpi <= MOST_DPI ? dpi : null; if (dpi === null || dpi < LEAST_DPI || dpi > MOST_DPI) return null;
return NOMINAL_DPI.some((nominal) => Math.abs(dpi - nominal) < 0.5) ? null : dpi;
} }
/** /**
+58
View File
@@ -0,0 +1,58 @@
// @ts-check
/**
* A record of the singular events of a session in the browser's console
* (F12), for finding faults: pages, images, notebook pages, exports, the
* co-host right, the connection, and what the input device delivers.
* Nothing continuous goes here (Andy, 2026-10-08).
*
* Since 2026-10-10 the same record, plus a trace of the input events
* (every press, release and cancellation, not the movement between), is
* kept in memory and shown in the Session window with a Copy button, so
* that a guest on a tablet, with no console to open, can send it when
* the pen misbehaves (Andy's iPad, where the Pencil stopped for a while
* now and then).
*/
/** How many entries are kept; the oldest go first. */
const KEPT = 400;
/** @type {string[]} */
const entries = [];
/** @type {(() => void) | null} Told of every new entry. */
let listener = null;
/** @returns {string} The time to the millisecond, HH:MM:SS.mmm. */
function now() {
const date = new Date();
const two = (/** @type {number} */ n) => String(n).padStart(2, '0');
return `${two(date.getHours())}:${two(date.getMinutes())}:${two(date.getSeconds())}.` +
String(date.getMilliseconds()).padStart(3, '0');
}
/** @param {string} what */
function keep(what) {
entries.push(`${now()} ${what}`);
if (entries.length > KEPT) entries.shift();
listener?.();
}
/** A singular event: the console and the record. @param {string} what */
export function log(what) {
console.log(`Kokkoja ${new Date().toLocaleTimeString()}: ${what}`);
keep(what);
}
/** An input event: the record only. @param {string} what */
export function trace(what) {
keep(what);
}
/** @returns {string} The record, one entry a line. */
export function logText() {
return entries.join('\n');
}
/** @param {(() => void) | null} onEntry Called at every new entry. */
export function watchLog(onEntry) {
listener = onEntry;
}
+570 -105
View File
@@ -13,6 +13,7 @@ import {
readCurrentPage, readIdentity, readPage, rememberToken, useBrowserStorage, readCurrentPage, readIdentity, readPage, rememberToken, useBrowserStorage,
} from './notebook.js'; } from './notebook.js';
import { download, pickImageFile } from './browser_files.js'; import { download, pickImageFile } from './browser_files.js';
import { DEFAULT_NOTEBOOK, NotebookPicker } from './notebook_picker.js';
import { pageFromSvg, pageToSvg } from './page_file.js'; import { pageFromSvg, pageToSvg } from './page_file.js';
import { hashOf } from './crypto.js'; import { hashOf } from './crypto.js';
import { imageResolution } from './image_info.js'; import { imageResolution } from './image_info.js';
@@ -21,7 +22,10 @@ import {
UNITS_PER_POINT, strokesTouched, textAt, textsTouched, UNITS_PER_POINT, strokesTouched, textAt, textsTouched,
} from './page_model.js'; } from './page_model.js';
import { attachPenInput } from './pen_input.js'; import { attachPenInput } from './pen_input.js';
import { Renderer, ZOOM_STEP } from './renderer.js'; 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';
import { Session } from './session.js'; import { Session } from './session.js';
import { NotebookChooser } from './chooser.js'; import { NotebookChooser } from './chooser.js';
import { FileChooser } from './file_chooser.js'; import { FileChooser } from './file_chooser.js';
@@ -29,13 +33,23 @@ import { writePicture } from './files.js';
import { pageToPng } from './page_picture.js'; import { pageToPng } from './page_picture.js';
import { TextEditor } from './text_editor.js'; import { TextEditor } from './text_editor.js';
import { enableTooltips } from './tooltip.js'; import { enableTooltips } from './tooltip.js';
import { guardSelection } from './selection_guard.js';
import { QUIET_ZONE, qrModules, qrPath } from './qr_code.js';
const COLORS = ['#1c1c1e', '#1f4fd8', '#d42a2a', '#1c8a3c']; /**
* 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'];
/** Everyone begins in black (Andy, 2026-10-03; the host began in red before). */ /** Everyone begins in black (Andy, 2026-10-03; the host began in red before). */
const DEFAULT_COLOR = COLORS[0]; const DEFAULT_COLOR = COLORS[0];
/** Nominal line widths in page units (0.1 mm). */ /** Nominal line widths in page units (0.1 mm); the last is twice the one before (2026-10-08). */
const WIDTHS = [4, 7, 12]; 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';
/** Radius of the eraser on the screen, in CSS pixels. */ /** Radius of the eraser on the screen, in CSS pixels. */
const ERASER_RADIUS = 10; const ERASER_RADIUS = 10;
@@ -66,7 +80,6 @@ const PALETTE = [
'#ffffff', '#bdbdbd', '#757575', '#424242', '#8d6e63', '#5d4037', '#3e2723', '#000000', '#ffffff', '#bdbdbd', '#757575', '#424242', '#8d6e63', '#5d4037', '#3e2723', '#000000',
]; ];
const NOTEBOOK_KEY = 'kokkoja_notebook'; const NOTEBOOK_KEY = 'kokkoja_notebook';
const DEFAULT_NOTEBOOK = 'Notebook';
/** /**
* Where the installed Kokkoja program serves the client, for a guest who * Where the installed Kokkoja program serves the client, for a guest who
@@ -104,6 +117,20 @@ function input(id) {
return /** @type {HTMLInputElement} */ (document.getElementById(id)); return /** @type {HTMLInputElement} */ (document.getElementById(id));
} }
/**
* @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; },
() => {});
/** /**
* Our identity: the signing keys that make us the same participant in * Our identity: the signing keys that make us the same participant in
* every session. The local program keeps them, so that they survive the * every session. The local program keeps them, so that they survive the
@@ -129,30 +156,37 @@ function showStart(message = '') {
element('start').hidden = false; element('start').hidden = false;
element('start_message').textContent = message; element('start_message').textContent = message;
input('name').value = localStorage.getItem(NAME_KEY) ?? ''; input('name').value = localStorage.getItem(NAME_KEY) ?? '';
input('notebook').value = localStorage.getItem(NOTEBOOK_KEY) ?? DEFAULT_NOTEBOOK; const remembered = localStorage.getItem(NOTEBOOK_KEY) ?? DEFAULT_NOTEBOOK;
startPicker.fill([], remembered);
startPicker.current = remembered;
const pending = invitationInAddress(); const pending = invitationInAddress();
if (pending) input('invitation_entry').value = pending; if (pending) input('invitation_entry').value = pending;
// 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);
// From a web site, a guest who has the Kokkoja program installed may // From a web site, a guest who has the Kokkoja program installed may
// prefer it, for a notebook of real files. // prefer it, for a notebook of real files.
const installed = /** @type {HTMLAnchorElement} */ (element('open_installed')); const installed = /** @type {HTMLAnchorElement} */ (element('open_installed'));
installed.hidden = !(pending && notebooksInBrowser()); installed.hidden = !(pending && notebooksInBrowser());
installed.href = `${INSTALLED_ADDRESS}#join=${encodeURIComponent(pending)}`; installed.href = `${INSTALLED_ADDRESS}#join=${encodeURIComponent(pending)}`;
showNotebooks().catch((error) => { showNotebooks(startPicker).catch((error) => {
element('notebook_note').textContent = String(error.message ?? error); element('notebook_note').textContent = String(error.message ?? error);
element('notebook_note').classList.add('problem'); element('notebook_note').classList.add('problem');
}); });
} }
/** Offer the existing notebooks and say where notebooks are kept. */ /**
async function showNotebooks() { * 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) {
const { directory, notebooks } = await listNotebooks(); const { directory, notebooks } = await listNotebooks();
const choices = element('notebook_choices'); const current = picker.value;
choices.replaceChildren(); picker.fill(notebooks, current);
for (const notebook of notebooks) { picker.current = current;
const option = document.createElement('option');
option.value = notebook;
choices.append(option);
}
for (const note of document.querySelectorAll('.notebook_directory')) { for (const note of document.querySelectorAll('.notebook_directory')) {
note.textContent = directory ? `Notebooks are kept in ${directory}` note.textContent = directory ? `Notebooks are kept in ${directory}`
: 'Notebooks are kept in this browser, on this computer. “Keep page” also downloads the page.'; : 'Notebooks are kept in this browser, on this computer. “Keep page” also downloads the page.';
@@ -169,6 +203,96 @@ function addressValues() {
return new URLSearchParams(location.hash.slice(1)); return new URLSearchParams(location.hash.slice(1));
} }
/** 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);
}
/** @returns {string} The invitation in the page address, or ''. */ /** @returns {string} The invitation in the page address, or ''. */
function invitationInAddress() { function invitationInAddress() {
return addressValues().get('join') ?? ''; return addressValues().get('join') ?? '';
@@ -195,9 +319,9 @@ function enteredName() {
return name; return name;
} }
/** @returns {string} The notebook entered. */ /** @returns {string} The notebook chosen. */
function enteredNotebook() { function enteredNotebook() {
const notebook = input('notebook').value.trim() || DEFAULT_NOTEBOOK; const notebook = startPicker.value || DEFAULT_NOTEBOOK;
localStorage.setItem(NOTEBOOK_KEY, notebook); localStorage.setItem(NOTEBOOK_KEY, notebook);
return notebook; return notebook;
} }
@@ -327,9 +451,10 @@ async function enterSession(invitationText, name, notebook) {
// installed. The link carries the invitation itself. // installed. The link carries the invitation itself.
const plainInvitation = formatInvitation(invitation); const plainInvitation = formatInvitation(invitation);
input('invitation').value = config.site ? formatInvitationLink(config.site, plainInvitation) : plainInvitation; input('invitation').value = config.site ? formatInvitationLink(config.site, plainInvitation) : plainInvitation;
showInvitationCode(config.site ? input('invitation').value : '');
const isHost = identity.id === invitation.hostId; const isHost = identity.id === invitation.hostId;
let tool = /** @type {'pen' | 'eraser' | 'text'} */ ('pen'); let tool = /** @type {'pen' | 'eraser' | 'text' | 'hide'} */ ('pen');
let color = DEFAULT_COLOR; let color = DEFAULT_COLOR;
let width = WIDTHS[1]; let width = WIDTHS[1];
let textSize = TEXT_SIZE; let textSize = TEXT_SIZE;
@@ -353,9 +478,12 @@ async function enterSession(invitationText, name, notebook) {
const fileOf = (page) => pageToSvg(page, { session: roomId, hostId: invitation.hostId }); const fileOf = (page) => pageToSvg(page, { session: roomId, hostId: invitation.hostId });
function showPageNumber() { function showPageNumber() {
element('page_number').textContent = session.page.number > 0 const waiting = session.page.number === 0;
? `Page ${session.page.number}` : 'Waiting for the host’s page'; element('page_number').textContent = waiting ? 'No page yet' : `Page ${session.page.number}`;
standingNotice('page', waiting ? 'Waiting for the host’s page' : '', false);
} }
/** @type {ReturnType<typeof setTimeout> | undefined} */
let connectionNoticeTimer;
const session = new Session( const session = new Session(
{ {
@@ -379,7 +507,15 @@ async function enterSession(invitationText, name, notebook) {
writer.changed(); writer.changed();
}, },
onPageReplaced: (oldPage, newPage) => { onPageReplaced: (oldPage, newPage) => {
textEditor?.stop(); 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);
}
// Nothing is lost: the page being left is kept in the notebook. // Nothing is lost: the page being left is kept in the notebook.
writer.pageReplaced(oldPage.number > 0 ? fileOf(oldPage) : null); writer.pageReplaced(oldPage.number > 0 ? fileOf(oldPage) : null);
renderer.setPage(newPage); renderer.setPage(newPage);
@@ -389,17 +525,25 @@ async function enterSession(invitationText, name, notebook) {
showPageNumber(); showPageNumber();
}, },
onImagesChanged: () => { onImagesChanged: () => {
log(`Page ${session.page.number}: ${session.page.images.length} images, ${session.page.hides.length} hides`);
renderer.showImages(); renderer.showImages();
renderer.drawHides();
writer.writeNow(); writer.writeNow();
}, },
onPageResized: (byImages) => { onPageResized: () => {
// An image added or taken back does not rescale what is in view. // The view keeps its scale; the page extends beyond the window.
if (byImages) renderer.holdScale();
renderer.resize(); renderer.resize();
textEditor?.layout(); textEditor?.layout();
writer.changed(); writer.changed();
}, },
onViewCommand: (command) => {
log(`The host's view: ${command === 'fit' ? 'Fit' : 'A4'}`);
if (command === 'fit') renderer.fit();
else renderer.sheetScale();
},
onPermissionsChanged: () => { onPermissionsChanged: () => {
log(`May start pages: ${session.mayMakePages() ? 'yes' : 'no'}` +
(session.isHost ? `; guests allowed: ${session.permitted.size}` : ''));
showHostTools(); showHostTools();
showParticipants(session, renderer); showParticipants(session, renderer);
}, },
@@ -409,19 +553,43 @@ async function enterSession(invitationText, name, notebook) {
}, },
onStatus: (status, detail) => { onStatus: (status, detail) => {
const text = { connecting: 'Connecting…', connected: 'Connected', failed: detail ?? 'Disconnected' }; const text = { connecting: 'Connecting…', connected: 'Connected', failed: detail ?? 'Disconnected' };
log(`Relay: ${text[status]}`);
element('status').textContent = text[status]; element('status').textContent = text[status];
element('status').classList.toggle('problem', status !== 'connected'); element('status').classList.toggle('problem', status !== 'connected');
// 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);
}, },
}); });
const writer = new NotebookWriter( const writer = new NotebookWriter(
notebook, notebook,
() => (session.page.number > 0 ? fileOf(session.page) : null), () => (session.page.number > 0 ? fileOf(session.page) : null),
(message, problem) => { {
element('save_status').textContent = message; onSaved: (time) => {
element('save_status').classList.toggle('problem', problem); showSaveState(`Saved at ${time}`, false);
standingNotice('saving', '');
},
onProblem: (message) => {
showSaveState(message, true);
standingNotice('saving', message);
},
onKept: (message) => passingNotice(message),
}); });
element('save_status').textContent = savedPage ? 'Continued from the notebook' : ''; showSaveState(savedPage ? 'Continued from the notebook' : '', false);
const pageElement = element('page'); const pageElement = element('page');
renderer = new Renderer(pageElement, session.page); renderer = new Renderer(pageElement, session.page);
@@ -436,75 +604,305 @@ async function enterSession(invitationText, name, notebook) {
textEditor?.layout(); textEditor?.layout();
}).observe(/** @type {Element} */ (pageElement.parentElement)); }).observe(/** @type {Element} */ (pageElement.parentElement));
// 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();
textEditor = new TextEditor(pageElement, renderer, { textEditor = new TextEditor(pageElement, renderer, {
setText: (block) => session.setText(block), setText: (block) => session.setText(block),
removeText: (id) => session.removeTexts([id]), removeText: (id) => session.removeTexts([id]),
// 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?.(),
}); });
attachPenInput(pageElement, { // 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;
/** @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,
});
/**
* @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 = {
toPage: (clientX, clientY) => renderer.pageFromClient(clientX, clientY), toPage: (clientX, clientY) => renderer.pageFromClient(clientX, clientY),
tool: () => tool, tool: () => tool,
canWrite: () => session.page.number > 0, canWrite: () => session.page.number > 0,
beginStroke: () => { beginStroke: () => {
textEditor?.stop(); textEditor?.stop();
renderer.holdFit = true; strokeInProgress = session.beginStroke(color, width);
session.beginStroke(color, width); },
addPoints: (points) => {
const inside = withinPage(points);
if (inside.length) session.addPoints(inside);
}, },
addPoints: (points) => session.addPoints(points),
endStroke: () => { endStroke: () => {
strokeInProgress = null;
session.endStroke(); session.endStroke();
renderer.holdFit = false; },
if (renderer.zoom === 'fit') renderer.resize(); 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);
} 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);
} 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;
session.endStroke();
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();
held = null;
renderer.showGrip(null);
}, },
eraseAlong: (x0, y0, x1, y1) => { eraseAlong: (x0, y0, x1, y1) => {
textEditor?.stop(); textEditor?.stop();
const radius = renderer.pageLength(ERASER_RADIUS); const radius = renderer.pageLength(ERASER_RADIUS);
session.erase(strokesTouched(session.ownLayer, x0, y0, x1, y1, radius)); const strokes = strokesTouched(session.ownLayer, x0, y0, x1, y1, radius);
session.removeTexts(textsTouched(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 };
}, },
eraseEnded: (x, y, reach) => { eraseEnded: (x, y, reach) => {
// A tap of the eraser on the image we have just added takes it back. // A tap of the eraser on a Hide rectangle or an image removes it
const image = session.removableImage; // (the right to make pages, or having added the image).
if (image && reach < TAP_DISTANCE && x >= image.x && x <= image.x + image.width && if (reach >= TAP_DISTANCE) return;
y >= image.y && y <= image.y + image.height) { const hide = session.page.hideAt(x, y, 0);
session.removeAddedImage(); if (hide && session.removeHide(hide.index)) {
log(`Hide ${hide.index + 1} removed`);
return;
}
const found = session.page.imageAt(x, y, 0);
if (found && session.removeImage(found.index)) log(`Image ${found.index + 1} removed`);
},
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();
} }
}, },
previewText: (x0, y0, x1) => { placeRect: (x0, y0, x1, y1) => {
if (Math.abs(x1 - x0) >= TAP_DISTANCE) textEditor?.showPreview(x0, y0, x1);
else textEditor?.hidePreview();
},
placeText: (x0, y0, x1, y1) => {
if (!textEditor) return; if (!textEditor) return;
textEditor.hidePreview(); textEditor.hidePreview();
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;
}
const editing = textEditor.block; const editing = textEditor.block;
textEditor.stop(); textEditor.stop();
const own = textAt(session.ownLayer, x0, y0); const own = textAt(session.ownLayer, x0, y0);
if (Math.hypot(x1 - x0, y1 - y0) < TAP_DISTANCE) { if (!dragged) {
// A tap: into our own text to change it, or a new block here. // 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).
if (own && own !== editing) textEditor.edit(own); if (own && own !== editing) textEditor.edit(own);
else if (!own) textEditor.begin(x0, y0, null, { color, size: textSize, font: textFont }); else if (!own && !editing) textEditor.begin(x0, y0, null, null, { color, size: textSize, font: textFont });
} else { } else {
// A drag along the top edge: a new block that wraps at its width. // A rectangle dragged open: at least one line tall.
textEditor.begin(Math.min(x0, x1), y0, Math.max(100, Math.round(Math.abs(x1 - x0))), const line = Math.ceil(textSize * (TOP_GAP + LINE_HEIGHT));
textEditor.begin(rect.left, rect.top, rect.width, Math.max(rect.height, line),
{ color, size: textSize, font: textFont }); { color, size: textSize, font: textFont });
} }
}, },
onReport: (report) => { onReport: (report) => {
element('pen_report').textContent = // To the console once, and again when the kind of device changes.
`Input: ${report.pointerType}, pressure ${report.pressure.toFixed(2)}, ` + if (report.pointerType === reportedDevice || report.pointsPerSecond === 0) return;
`${report.pointsPerSecond} points per second`; reportedDevice = report.pointerType;
log(`Input: ${report.pointerType}, pressure ${report.pressure.toFixed(2)}, ` +
`${report.pointsPerSecond} points per second`);
}, },
}); };
attachPenInput(pageElement, penTarget);
buildToolbar({ buildToolbar({
color, width, textSize, textFont, color, width, textSize, textFont,
setTool: (value) => { setTool: (value) => {
if (value !== 'text') textEditor?.stop(); if (value !== 'text') textEditor?.stop();
if (value !== tool) trace(`Tool: ${value}`);
tool = value; tool = value;
// Only the chosen tool's settings show: the pen's colours and line // 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. // widths, the text's colours, size and font, nothing for the eraser.
element('colors').hidden = value === 'eraser'; element('colors').hidden = value === 'eraser' || value === 'hide';
element('widths').hidden = value !== 'pen'; element('widths').hidden = value !== 'pen';
element('text_style').hidden = value !== 'text'; element('text_style').hidden = value !== 'text';
}, },
@@ -524,9 +922,6 @@ async function enterSession(invitationText, name, notebook) {
}); });
// Zoom is each person's own; it changes nothing on the page. // 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, // 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 // 2026-10-05: the least surprise): the zoom and the place scrolled to
// are remembered in the tab, for the page they belong to. // are remembered in the tab, for the page they belong to.
@@ -541,14 +936,31 @@ async function enterSession(invitationText, name, notebook) {
viewTimer = setTimeout(saveView, 300); viewTimer = setTimeout(saveView, 300);
}; };
renderer.onZoomChanged = () => { renderer.onZoomChanged = () => {
showZoom();
textEditor?.layout(); textEditor?.layout();
viewChanged(); viewChanged();
}; };
element('zoom_fit').addEventListener('click', () => renderer.setZoom('fit')); // The host's Fit and A4 reach everyone (Andy, 2026-10-08); a guest's
element('zoom_in').addEventListener('click', () => renderer.zoomBy(ZOOM_STEP)); // are the guest's own.
element('zoom_out').addEventListener('click', () => renderer.zoomBy(1 / ZOOM_STEP)); element('zoom_fit').addEventListener('click', () => {
showZoom(); renderer.fit();
session.sendViewCommand('fit');
});
element('zoom_sheet').addEventListener('click', () => {
renderer.sheetScale();
session.sendViewCommand('sheet');
});
// "Widen" makes the sheet itself wider, until it fills the window at
// the present zoom; nothing on it moves (Andy, 2026-10-08).
element('zoom_widen').addEventListener('click', () => {
const room = renderer.available().width / renderer.cssPixelsPerUnit;
const widened = session.widenPage(room);
if (widened) log(`Page widened to ${session.page.baseWidth / 100} cm`);
});
element('zoom_shrink').addEventListener('click', () => {
if (session.shrinkPage()) {
log(`Page shrunk to ${session.page.baseWidth / 100} by ${session.page.baseHeight / 100} cm`);
}
});
try { try {
const seen = JSON.parse(sessionStorage.getItem(viewKey) ?? 'null'); const seen = JSON.parse(sessionStorage.getItem(viewKey) ?? 'null');
if (seen && seen.page === session.page.id && (seen.zoom === 'fit' || seen.zoom > 0) && if (seen && seen.page === session.page.id && (seen.zoom === 'fit' || seen.zoom > 0) &&
@@ -564,35 +976,42 @@ async function enterSession(invitationText, name, notebook) {
// The host drives: the host starts new pages, and may let a guest do so. // The host drives: the host starts new pages, and may let a guest do so.
function showHostTools() { function showHostTools() {
element('host_tools').hidden = !session.mayMakePages(); element('host_tools').hidden = !session.mayMakePages();
// 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();
element('zoom_widen').hidden = !session.mayMakePages();
element('zoom_shrink').hidden = !session.mayMakePages();
} }
showHostTools(); showHostTools();
element('new_page').addEventListener('click', () => session.newPage()); element('new_page').addEventListener('click', () => {
log('New page asked for');
session.newPage();
});
/** /**
* Add an image file to the page, below the images already on it. * 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.
* @param {Blob} file * @param {Blob} file
* @param {boolean} [shrink] Whether an image wider than the sheet is
* scaled down to it.
*/ */
async function loadImage(file, shrink = true) { async function loadImage(file) {
if (!session.mayMakePages() || session.page.number === 0) return; if (!session.mayMakePages() || session.page.number === 0) return;
try { try {
if (!session.addImage(await prepareImage(file), shrink)) { if (!session.addImage(await prepareImage(file))) {
throw new Error('The page has no room for another image.'); throw new Error('The page has no room for another image.');
} }
} catch (error) { } catch (error) {
element('save_status').textContent = passingNotice(error instanceof Error && error.message ? error.message : 'That file is not an image.', true);
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 chooser = new NotebookChooser((chosenNotebook, pageName, text) => {
const kept = pageFromSvg(text); const kept = pageFromSvg(text);
if (!kept) { if (!kept) {
element('save_status').textContent = `${pageName} is not a Kokkoja page.`; passingNotice(`${pageName} is not a Kokkoja page.`, true);
element('save_status').classList.add('problem');
return; return;
} }
log(`Notebook page loaded: ${chosenNotebook} / ${pageName}`);
session.loadPage(kept.page); session.loadPage(kept.page);
}); });
element('load_notebook_page').addEventListener('click', () => { chooser.open(writer.notebook); }); element('load_notebook_page').addEventListener('click', () => { chooser.open(writer.notebook); });
@@ -608,7 +1027,7 @@ async function enterSession(invitationText, name, notebook) {
return; return;
} }
const file = await fileChooser.chooseImage(); const file = await fileChooser.chooseImage();
if (file) loadImage(file, fileChooser.shrinksWideImages()); if (file) loadImage(file);
}); });
/** /**
@@ -628,8 +1047,8 @@ async function enterSession(invitationText, name, notebook) {
if (notebooksInBrowser()) { if (notebooksInBrowser()) {
download(`${day}_page_${page.number}.png`, download(`${day}_page_${page.number}.png`,
await pageToPng(page, { session: roomId, hostId: session.hostId, hidden: renderer.hidden })); await pageToPng(page, { session: roomId, hostId: session.hostId, hidden: renderer.hidden }));
element('save_status').textContent = 'Exported to your downloads'; log(`Exported page ${page.number} to the downloads`);
element('save_status').classList.remove('problem'); passingNotice('Exported to your downloads');
return; return;
} }
const saved = await fileChooser.chooseWhereToSave( const saved = await fileChooser.chooseWhereToSave(
@@ -639,38 +1058,64 @@ async function enterSession(invitationText, name, notebook) {
await pageToPng(page, { session: roomId, hostId: session.hostId, hidden: renderer.hidden }), await pageToPng(page, { session: roomId, hostId: session.hostId, hidden: renderer.hidden }),
replace)); replace));
if (saved === null) return; if (saved === null) return;
element('save_status').textContent = `Exported to ${saved}`; log(`Exported page ${page.number} to ${saved}`);
element('save_status').classList.remove('problem'); passingNotice(`Exported to ${saved}`);
} }
element('export_page').addEventListener('click', () => { exportPage(); }); element('export_page').addEventListener('click', () => { exportPage(); });
// The notebook: its name, and keeping the page. // The notebook: choosing one, making one, and keeping the page.
const notebookField = input('session_notebook'); const sessionPicker = new NotebookPicker(
notebookField.value = notebook; select('session_notebook'), input('session_notebook_name'),
showNotebooks().catch(() => {}); (message) => { if (message) passingNotice(message, true); },
notebookField.addEventListener('change', () => { (chosen) => {
const chosen = notebookField.value.trim(); if (chosen === writer.notebook) return;
if (!chosen || chosen === writer.notebook) {
notebookField.value = writer.notebook;
return;
}
sessionStorage.setItem(notebookKey, chosen); sessionStorage.setItem(notebookKey, chosen);
localStorage.setItem(NOTEBOOK_KEY, chosen); localStorage.setItem(NOTEBOOK_KEY, chosen);
writer.changeNotebook(chosen, (text) => { writer.changeNotebook(chosen, (text) => {
const saved = pageFromSvg(text); const saved = pageFromSvg(text);
return saved !== null && saved.session === roomId && saved.page.id === session.page.id; return saved !== null && saved.session === roomId && saved.page.id === session.page.id;
}).then(() => showNotebooks()).catch(() => {}); }).then(() => showNotebooks(sessionPicker)).catch(() => {});
}); });
sessionPicker.fill([], notebook);
sessionPicker.current = notebook;
showNotebooks(sessionPicker).catch(() => {});
element('keep_page').addEventListener('click', async () => { element('keep_page').addEventListener('click', async () => {
const kept = await writer.keepPage(); const { kept, new: made } = await writer.keepPage();
log(kept ? `${made ? 'Kept' : 'Already kept'} as ${kept}` : 'Nothing to keep');
// In the browser the notebook is out of reach of the user's own // 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. // programs: a page kept just now is also downloaded, which is how a
if (kept && notebooksInBrowser()) { // 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()) {
const text = await readPage(writer.notebook, kept); const text = await readPage(writer.notebook, kept);
if (text !== null) download(kept, new Blob([text], { type: 'image/svg+xml' })); if (text !== null) download(kept, new Blob([text], { type: 'image/svg+xml' }));
} }
}); });
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.
}
});
element('copy_invitation').addEventListener('click', async () => { element('copy_invitation').addEventListener('click', async () => {
const field = input('invitation'); const field = input('invitation');
field.select(); field.select();
@@ -716,7 +1161,7 @@ function press(group, chosen) {
* @param {number} options.width Initial width. * @param {number} options.width Initial width.
* @param {number} options.textSize * @param {number} options.textSize
* @param {import('./page_model.js').TextFont} options.textFont * @param {import('./page_model.js').TextFont} options.textFont
* @param {(tool: 'pen' | 'eraser' | 'text') => void} options.setTool * @param {(tool: 'pen' | 'eraser' | 'text' | 'hide') => void} options.setTool
* @param {(color: string) => void} options.setColor * @param {(color: string) => void} options.setColor
* @param {(width: number) => void} options.setWidth * @param {(width: number) => void} options.setWidth
* @param {(size: number) => void} options.setTextSize * @param {(size: number) => void} options.setTextSize
@@ -727,7 +1172,7 @@ function buildToolbar(options) {
button.addEventListener('click', () => { button.addEventListener('click', () => {
press(/** @type {Element} */ (button.parentElement), button); press(/** @type {Element} */ (button.parentElement), button);
const chosen = button.getAttribute('data-tool'); const chosen = button.getAttribute('data-tool');
options.setTool(chosen === 'eraser' ? 'eraser' : chosen === 'text' ? 'text' : 'pen'); options.setTool(chosen === 'eraser' ? 'eraser' : chosen === 'text' ? 'text' : chosen === 'hide' ? 'hide' : 'pen');
}); });
} }
@@ -778,7 +1223,20 @@ function buildToolbar(options) {
press(colors, button); press(colors, button);
options.setColor(color); options.setColor(color);
}; };
// 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) { 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'); const button = document.createElement('button');
button.type = 'button'; button.type = 'button';
button.className = 'swatch'; button.className = 'swatch';
@@ -787,11 +1245,13 @@ function buildToolbar(options) {
button.addEventListener('click', () => choose(color)); button.addEventListener('click', () => choose(color));
palette.append(button); palette.append(button);
} }
};
more.addEventListener('click', () => { more.addEventListener('click', () => {
if (!palette.hidden) { if (!palette.hidden) {
palette.hidden = true; palette.hidden = true;
return; return;
} }
showPalette();
const box = more.getBoundingClientRect(); const box = more.getBoundingClientRect();
palette.style.left = `${box.left}px`; palette.style.left = `${box.left}px`;
palette.style.top = `${box.bottom + 6}px`; palette.style.top = `${box.bottom + 6}px`;
@@ -861,15 +1321,17 @@ function showParticipants(session, renderer) {
item.classList.toggle('absent', !present); item.classList.toggle('absent', !present);
if (session.isHost && !participant.self) { if (session.isHost && !participant.self) {
const allow = document.createElement('button'); // A checkbox with its label to the left, like Extend (Andy, 2026-10-09).
allow.type = 'button'; const allowLine = document.createElement('label');
allowLine.className = 'allow_line';
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, widen, shrink and hide, as you can.');
const allow = document.createElement('input');
allow.type = 'checkbox';
allow.className = 'allow'; allow.className = 'allow';
allow.textContent = 'pages'; allow.checked = session.permitted.has(participant.id);
allow.setAttribute('data-help', 'Let this participant start new pages and load images.'); allow.addEventListener('change', () => session.setPermitted(participant.id, allow.checked));
const allowed = session.permitted.has(participant.id); allowLine.append('Co-host ', allow);
allow.setAttribute('aria-pressed', String(allowed)); item.append(allowLine);
allow.addEventListener('click', () => session.setPermitted(participant.id, !allowed));
item.append(allow);
} }
const label = document.createElement('label'); const label = document.createElement('label');
@@ -884,6 +1346,7 @@ function showParticipants(session, renderer) {
const roles = []; const roles = [];
if (participant.self) roles.push('you'); if (participant.self) roles.push('you');
if (participant.host) roles.push('host'); if (participant.host) roles.push('host');
else if (!session.isHost && session.permitted.has(participant.id)) roles.push('co-host');
if (!present) roles.push('away'); if (!present) roles.push('away');
if (roles.length) { if (roles.length) {
const role = document.createElement('span'); const role = document.createElement('span');
@@ -932,6 +1395,8 @@ async function resume() {
} }
enableTooltips(); enableTooltips();
guardSelection();
log(`Browser: ${navigator.userAgent}; window ${innerWidth}×${innerHeight}, pixel ratio ${devicePixelRatio}`);
const token = addressValues().get('token'); const token = addressValues().get('token');
if (token) { if (token) {
+16 -15
View File
@@ -205,12 +205,15 @@ export class NotebookWriter {
* @param {string} notebook * @param {string} notebook
* @param {() => string | null} content Produces the file of the present * @param {() => string | null} content Produces the file of the present
* page, or null if there is no page to save yet. * page, or null if there is no page to save yet.
* @param {(message: string, problem: boolean) => void} onStatus * @param {object} events
* @param {(time: string) => void} events.onSaved The page was written, at that time of day.
* @param {(message: string) => void} events.onKept What keeping the page did.
* @param {(message: string) => void} events.onProblem A write or keep failed.
*/ */
constructor(notebook, content, onStatus) { constructor(notebook, content, events) {
this.notebook = notebook; this.notebook = notebook;
this.content = content; this.content = content;
this.onStatus = onStatus; this.events = events;
/** @type {number | null} */ /** @type {number | null} */
this.timer = null; this.timer = null;
/** Time of the oldest change not yet written, or 0. */ /** Time of the oldest change not yet written, or 0. */
@@ -232,7 +235,7 @@ export class NotebookWriter {
*/ */
enqueue(work) { enqueue(work) {
this.queue = this.queue.then(work).catch((error) => { this.queue = this.queue.then(work).catch((error) => {
this.onStatus(error instanceof Error ? error.message : String(error), true); this.events.onProblem(error instanceof Error ? error.message : String(error));
// The page is still in memory: try to write it again shortly. // The page is still in memory: try to write it again shortly.
if (this.timer === null) this.timer = setTimeout(() => this.writeNow(), RETRY_MS); if (this.timer === null) this.timer = setTimeout(() => this.writeNow(), RETRY_MS);
}); });
@@ -272,27 +275,25 @@ export class NotebookWriter {
const text = this.content(); const text = this.content();
if (text === null) return; if (text === null) return;
await this.put(text); await this.put(text);
this.onStatus(`Saved at ${new Date().toLocaleTimeString()}`, false); this.events.onSaved(new Date().toLocaleTimeString());
}); });
} }
/** /**
* The user asked to keep the page as it is now. * The user asked to keep the page as it is now.
* @returns {Promise<string | null>} The name of the kept page, or null * @returns {Promise<{kept: string | null, new: boolean}>} The name of
* if there was nothing to keep. * the kept page, or null if there was nothing to keep, and whether
* the copy was made just now.
*/ */
keepPage() { keepPage() {
this.writeNow(); this.writeNow();
/** @type {string | null} */ let result = { kept: /** @type {string | null} */ (null), new: false };
let kept = null;
return this.enqueue(async () => { return this.enqueue(async () => {
const result = await keepCurrentPage(this.notebook); result = await keepCurrentPage(this.notebook);
kept = result.kept; this.events.onKept(
this.onStatus(
result.kept === null ? 'The page is blank: nothing to keep' result.kept === null ? 'The page is blank: nothing to keep'
: result.new ? `Kept as ${result.kept}` : `Already kept as ${result.kept}`, : result.new ? `Kept as ${result.kept}` : `Already kept as ${result.kept}`);
false); }).then(() => result);
}).then(() => kept);
} }
/** /**
+151
View File
@@ -0,0 +1,151 @@
// @ts-check
/**
* A notebook picker: a list of the notebooks with "New notebook…" as its
* last entry, which asks for the name in a field shown in the list's
* place (not in a browser dialog). Used on the start screen and in the
* session's panel. A notebook exists once a page is written into it,
* so making one is naming it.
*/
import { listNotebooks } from './notebook.js';
/** The notebook used when none is chosen: the one for whatever has no notebook of its own. */
export const DEFAULT_NOTEBOOK = 'General';
/** The value of the "New notebook…" entry; no notebook can be named so. */
const NEW = '/new';
/** What the local program accepts as a notebook name (NAME in kokkoja.py). */
const NAME_ALLOWED = /^[^\x00-\x1f/\\:*?"<>|.][^\x00-\x1f/\\:*?"<>|]{0,119}$/;
const NAME_REFUSED = 'A notebook name cannot contain / \\ : * ? " < > | and cannot begin with a dot.';
export class NotebookPicker {
/**
* @param {HTMLSelectElement} select
* @param {HTMLInputElement} entry The field for a new name.
* @param {(message: string) => void} say Explains a refused name; '' clears.
* @param {(notebook: string) => void} onChosen The choice changed.
*/
constructor(select, entry, say, onChosen) {
this.select = select;
this.entry = entry;
this.say = say;
this.onChosen = onChosen;
/** The choice before "New notebook…" was picked, to return to. */
this.previous = '';
select.addEventListener('change', () => {
if (select.value === NEW) this.beginNew();
else onChosen(select.value);
});
entry.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
this.confirm();
} else if (event.key === 'Escape') {
event.preventDefault();
this.cancel();
}
});
// Leaving the field with a usable name confirms it; with none, gives up.
entry.addEventListener('blur', () => {
if (entry.hidden) return;
if (NAME_ALLOWED.test(entry.value.trim())) this.confirm();
else this.cancel();
});
}
/** @returns {string} The chosen notebook. */
get value() {
return this.select.value === NEW ? this.previous : this.select.value;
}
/**
* List the notebooks, with the given one chosen (added to the list if
* it is not there yet, since a notebook exists once a page is written).
* @param {string} current
*/
async show(current) {
let names = /** @type {string[]} */ ([]);
try {
names = (await listNotebooks()).notebooks;
} catch {
// Without the list, the current notebook alone is offered.
}
this.fill(names, current);
}
/**
* @param {string[]} names
* @param {string} current
*/
fill(names, current) {
const all = [...new Set([...names, current])].filter(Boolean).sort((a, b) => a.localeCompare(b));
this.select.replaceChildren();
for (const name of all) {
const option = document.createElement('option');
option.value = name;
option.textContent = name;
this.select.append(option);
}
const make = document.createElement('option');
make.value = NEW;
make.textContent = 'New notebook…';
this.select.append(make);
this.select.value = current;
}
beginNew() {
this.previous = this.previous || this.select.options[0]?.value || '';
this.say('');
this.select.hidden = true;
this.entry.value = '';
this.entry.hidden = false;
this.entry.focus();
}
confirm() {
const name = this.entry.value.trim();
if (!NAME_ALLOWED.test(name)) {
this.say(name ? NAME_REFUSED : '');
if (!name) this.cancel();
return;
}
this.say('');
if (![...this.select.options].some((option) => option.value === name)) {
const option = document.createElement('option');
option.value = name;
option.textContent = name;
this.select.insertBefore(option, this.select.options[this.select.options.length - 1]);
}
this.select.value = name;
this.previous = name;
this.endEntry();
this.onChosen(name);
}
cancel() {
this.select.value = this.previous;
this.endEntry();
}
endEntry() {
this.entry.hidden = true;
this.select.hidden = false;
}
/**
* The choice, made from outside; it is also the one to return to if
* "New notebook…" is given up.
* @param {string} name
*/
set current(name) {
this.previous = name;
this.select.value = name;
}
/** @returns {string} */
get current() {
return this.value;
}
}
+69 -10
View File
@@ -28,15 +28,22 @@
import { fromBase64, toBase64 } from './crypto.js'; import { fromBase64, toBase64 } from './crypto.js';
import { import {
Page, POINT_SIZE, cleanImage, cleanName, cleanPageDescriptor, cleanStroke, cleanText, POINT_SIZE, Page, cleanHide, cleanImage, cleanName, cleanPageDescriptor, cleanStroke, cleanText, pointCount, textBounds, validId,
pointCount, validId,
} from './page_model.js'; } from './page_model.js';
import { partAt } from './stroke_geometry.js'; import { partAt } from './stroke_geometry.js';
import { LEFT_GAP, LINE_HEIGHT, TOP_GAP } from './text_layout.js'; import { LEFT_GAP, baselineOf } from './text_layout.js';
/** @typedef {import('./page_model.js').Stroke} Stroke */ /** @typedef {import('./page_model.js').Stroke} Stroke */
const FORMAT_VERSION = '1'; const FORMAT_VERSION = '1';
/** A text rectangle's frame: the palette's red, 0.2 mm (Andy's teacher, 2026-10-08). */
export const TEXT_FRAME_COLOR = '#e53935';
export const TEXT_FRAME_WIDTH = 2;
/**
* Free text is marked by an L at its top-left corner, two segments this
* long (5 mm; 3 mm first), in the frame's red (Andy, 2026-10-09).
*/
export const TEXT_MARK = 50;
const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'; const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
/** /**
@@ -58,7 +65,7 @@ function imageElement(image) {
text = toBase64(image.data); text = toBase64(image.data);
imageText.set(image.data, text); imageText.set(image.data, text);
} }
return `<image data-hash="${image.hash}" x="${image.x}" y="${image.y}"` + return `<image data-hash="${image.hash}"${image.by ? ` data-by="${image.by}"` : ''} x="${image.x}" y="${image.y}"` +
` width="${image.width}" height="${image.height}"` + ` width="${image.width}" height="${image.height}"` +
` preserveAspectRatio="none" href="data:${image.type};base64,${text}"/>`; ` preserveAspectRatio="none" href="data:${image.type};base64,${text}"/>`;
} }
@@ -86,15 +93,26 @@ function escapedContent(text) {
* @returns {string} * @returns {string}
*/ */
function textElements(block) { function textElements(block) {
// Each line at its baseline, placed as on the screen.
const lines = block.lines.map((line, i) => const lines = block.lines.map((line, i) =>
`<tspan x="${number(block.x + block.size * LEFT_GAP)}"` + `<tspan x="${number(block.x + block.size * LEFT_GAP)}"` +
` y="${number(block.y + block.size * (TOP_GAP + i * LINE_HEIGHT))}">${escapedContent(line)}</tspan>`); ` y="${number(block.y + baselineOf(block.size, block.font, i))}">${escapedContent(line)}</tspan>`);
const bounds = textBounds(block);
// A text rectangle's red frame, or free text's corner mark, is part of
// the page (a rectangle's white ground is written beneath all layers:
// see pageToSvg).
const frame = block.height === null
? `<path d="M${block.x} ${block.y + TEXT_MARK}V${block.y}H${block.x + TEXT_MARK}"` +
` fill="none" stroke="${TEXT_FRAME_COLOR}" stroke-width="${TEXT_FRAME_WIDTH}"/>`
: `<rect x="${bounds.left}" y="${bounds.top}" width="${bounds.width}" height="${bounds.height}"` +
` fill="none" stroke="${TEXT_FRAME_COLOR}" stroke-width="${TEXT_FRAME_WIDTH}"/>`;
return `<g data-text="${escaped(block.id)}" data-x="${block.x}" data-y="${block.y}"` + return `<g data-text="${escaped(block.id)}" data-x="${block.x}" data-y="${block.y}"` +
` data-width="${block.width ?? ''}" data-size="${block.size}" data-font="${block.font}"` + ` data-width="${block.width ?? ''}" data-height="${block.height ?? ''}"` +
` data-size="${block.size}" data-font="${block.font}"` +
` data-box="${block.boxWidth} ${block.boxHeight}">` + ` data-box="${block.boxWidth} ${block.boxHeight}">` +
`<desc>${escapedContent(block.text)}</desc>` + `<desc>${escapedContent(block.text)}</desc>${frame}` +
`<text font-family="${block.font}" font-size="${block.size}" fill="${block.color}"` + `<text font-family="${block.font}" font-size="${block.size}" fill="${block.color}"` +
` dominant-baseline="text-before-edge" xml:space="preserve">${lines.join('')}</text></g>`; ` xml:space="preserve">${lines.join('')}</text></g>`;
} }
/** /**
@@ -153,7 +171,9 @@ export function pageToSvg(page, { session, hostId, hidden }) {
`<svg xmlns="${SVG_NAMESPACE}" data-kokkoja="${FORMAT_VERSION}"` + `<svg xmlns="${SVG_NAMESPACE}" data-kokkoja="${FORMAT_VERSION}"` +
` data-session="${escaped(session)}" data-host="${escaped(hostId)}"` + ` data-session="${escaped(session)}" data-host="${escaped(hostId)}"` +
` data-page="${escaped(page.id)}" data-number="${page.number}"` + ` data-page="${escaped(page.id)}" data-number="${page.number}"` +
` data-base="${page.baseWidth} ${page.baseHeight}" data-revision="${page.imagesRevision}"` + ` data-base="${page.baseWidth} ${page.baseHeight}` +
`${page.baseLeft || page.baseTop ? ` ${page.baseLeft} ${page.baseTop}` : ''}"` +
` data-revision="${page.imagesRevision}"` +
` viewBox="${page.left} ${page.top} ${page.width} ${page.height}"` + ` viewBox="${page.left} ${page.top} ${page.width} ${page.height}"` +
` width="${page.width / 100}cm" height="${page.height / 100}cm">`, ` width="${page.width / 100}cm" height="${page.height / 100}cm">`,
`<title>Kokkoja page ${page.number}</title>`, `<title>Kokkoja page ${page.number}</title>`,
@@ -164,6 +184,20 @@ export function pageToSvg(page, { session, hostId, hidden }) {
const shown = imageElement(image); const shown = imageElement(image);
if (shown) lines.push(shown); if (shown) lines.push(shown);
} }
// The white grounds of the text rectangles: above the images, beneath
// every layer, so that they hide the image and never anyone's writing.
const grounds = [];
for (const id of page.layerOrder(hostId)) {
const layer = page.layers.get(id);
if (!layer || hidden?.has(id)) continue;
for (const block of layer.texts.values()) {
if (block.height === null) continue;
const b = textBounds(block);
grounds.push(`<rect data-ground="${escaped(block.id)}" x="${b.left}" y="${b.top}"` +
` width="${b.width}" height="${b.height}" fill="#ffffff"/>`);
}
}
if (grounds.length) lines.push('<g data-grounds="">', ...grounds, '</g>');
for (const id of page.layerOrder(hostId)) { for (const id of page.layerOrder(hostId)) {
const layer = page.layers.get(id); const layer = page.layers.get(id);
if (!layer || layer.isEmpty() || hidden?.has(id)) continue; if (!layer || layer.isEmpty() || hidden?.has(id)) continue;
@@ -178,6 +212,15 @@ export function pageToSvg(page, { session, hostId, hidden }) {
for (const block of layer.texts.values()) lines.push(textElements(block)); for (const block of layer.texts.values()) lines.push(textElements(block));
lines.push('</g>'); lines.push('</g>');
} }
// Hide rectangles: white, above all writing.
if (page.hides.length) {
lines.push('<g data-hides="">');
for (const hide of page.hides) {
lines.push(`<rect data-hide="${escaped(hide.id)}" x="${hide.x}" y="${hide.y}"` +
` width="${hide.width}" height="${hide.height}" fill="#ffffff"/>`);
}
lines.push('</g>');
}
lines.push('</svg>', ''); lines.push('</svg>', '');
return lines.join('\n'); return lines.join('\n');
} }
@@ -195,14 +238,19 @@ export function pageFromSvg(text) {
if (box.length !== 4 || !(box[2] > 0) || !(box[3] > 0)) return null; if (box.length !== 4 || !(box[2] > 0) || !(box[3] > 0)) return null;
const base = (root.getAttribute('data-base') ?? '').split(/\s+/).map(Number); const base = (root.getAttribute('data-base') ?? '').split(/\s+/).map(Number);
// data-base: the sheet's width and height, then its left and top edges
// when they are not 0 (a sheet changed by "Widen" or "Shrink").
const descriptor = cleanPageDescriptor({ const descriptor = cleanPageDescriptor({
id: root.getAttribute('data-page'), number: Number(root.getAttribute('data-number')), id: root.getAttribute('data-page'), number: Number(root.getAttribute('data-number')),
width: base.length === 2 ? base[0] : box[2], height: base.length === 2 ? base[1] : box[3], width: base.length >= 2 ? base[0] : box[2], height: base.length >= 2 ? base[1] : box[3],
left: base.length >= 3 ? base[2] : 0, top: base.length >= 4 ? base[3] : 0,
revision: Number(root.getAttribute('data-revision') ?? 0), revision: Number(root.getAttribute('data-revision') ?? 0),
}); });
if (!descriptor) return null; if (!descriptor) return null;
const page = new Page(descriptor.width, descriptor.height, descriptor.id, descriptor.number); const page = new Page(descriptor.width, descriptor.height, descriptor.id, descriptor.number);
page.baseLeft = descriptor.left;
page.baseTop = descriptor.top;
page.imagesRevision = descriptor.revision; page.imagesRevision = descriptor.revision;
/** @type {Map<string, Uint8Array<ArrayBuffer>>} An image shown twice is held once. */ /** @type {Map<string, Uint8Array<ArrayBuffer>>} An image shown twice is held once. */
const held = new Map(); const held = new Map();
@@ -215,11 +263,20 @@ export function pageFromSvg(text) {
type: address[1], size: data.length, hash, type: address[1], size: data.length, hash,
x: Number(shown.getAttribute('x')), y: Number(shown.getAttribute('y')), x: Number(shown.getAttribute('x')), y: Number(shown.getAttribute('y')),
width: Number(shown.getAttribute('width')), height: Number(shown.getAttribute('height')), width: Number(shown.getAttribute('width')), height: Number(shown.getAttribute('height')),
by: shown.getAttribute('data-by') ?? '',
}); });
if (!image) continue; if (!image) continue;
held.set(hash, data); held.set(hash, data);
page.images.push({ ...image, data }); page.images.push({ ...image, data });
} }
for (const shown of root.querySelectorAll('g[data-hides] rect[data-hide]')) {
const hide = cleanHide({
id: shown.getAttribute('data-hide'),
x: Number(shown.getAttribute('x')), y: Number(shown.getAttribute('y')),
width: Number(shown.getAttribute('width')), height: Number(shown.getAttribute('height')),
});
if (hide) page.hides.push(hide);
}
for (const group of root.querySelectorAll('g[data-layer]')) { for (const group of root.querySelectorAll('g[data-layer]')) {
const layerId = group.getAttribute('data-layer'); const layerId = group.getAttribute('data-layer');
if (!validId(layerId)) continue; if (!validId(layerId)) continue;
@@ -237,11 +294,13 @@ export function pageFromSvg(text) {
} }
for (const element of group.querySelectorAll('g[data-text]')) { for (const element of group.querySelectorAll('g[data-text]')) {
const widthText = element.getAttribute('data-width') ?? ''; const widthText = element.getAttribute('data-width') ?? '';
const heightText = element.getAttribute('data-height') ?? '';
const box = (element.getAttribute('data-box') ?? '').split(' ').map(Number); const box = (element.getAttribute('data-box') ?? '').split(' ').map(Number);
const block = cleanText({ const block = cleanText({
id: element.getAttribute('data-text'), id: element.getAttribute('data-text'),
x: Number(element.getAttribute('data-x')), y: Number(element.getAttribute('data-y')), x: Number(element.getAttribute('data-x')), y: Number(element.getAttribute('data-y')),
width: widthText === '' ? null : Number(widthText), width: widthText === '' ? null : Number(widthText),
height: heightText === '' ? null : Number(heightText),
size: Number(element.getAttribute('data-size')), size: Number(element.getAttribute('data-size')),
font: element.getAttribute('data-font') ?? 'sans-serif', font: element.getAttribute('data-font') ?? 'sans-serif',
color: element.querySelector('text')?.getAttribute('fill'), color: element.querySelector('text')?.getAttribute('fill'),
+252 -62
View File
@@ -8,21 +8,24 @@
*/ */
/** A blank page is A4. */ /** A blank page is A4. */
export const BLANK_PAGE_WIDTH = 2100; // An A4 sheet in landscape (Andy, 2026-10-08; portrait until then).
export const BLANK_PAGE_HEIGHT = 2970; export const BLANK_PAGE_WIDTH = 2970;
export const BLANK_PAGE_HEIGHT = 2100;
/** /**
* The page grows to keep this much room around everything written on it, * The page grows to keep this much room around everything written on it,
* in steps of the same amount so that it does not shift with every pen * in steps of the same amount so that it does not shift with every pen
* point. 8 mm, Andy's choice (2026-10-03; 15 mm before). * point; "Shrink" leaves the same room. 5 mm, Andy's choice (2026-10-08;
* 8 mm from 2026-10-03, 15 mm before that).
*/ */
export const WRITING_MARGIN = 80; export const WRITING_MARGIN = 50;
/** /**
* An image is placed this far inside the sheet's edge. 8 mm, Andy's * An image is placed this far inside the sheet's edge, and below the
* choice; it is deliberately a separate setting from WRITING_MARGIN. * image above. 5 mm, Andy's choice (2026-10-08; 8 mm before); it is
* deliberately a separate setting from WRITING_MARGIN.
*/ */
export const IMAGE_MARGIN = 80; export const IMAGE_MARGIN = 50;
/** /**
* @typedef {object} Stroke * @typedef {object} Stroke
@@ -45,6 +48,11 @@ export const POINT_SIZE = 3;
* @property {number} x * @property {number} x
* @property {number} y * @property {number} y
* @property {number | null} width Page units, or null for no wrapping. * @property {number | null} width Page units, or null for no wrapping.
* @property {number | null} height With a width: the block is a *text
* rectangle* (Andy's teacher, 2026-10-08), drawn with a red frame on
* a white ground that hides the image beneath, wrapping inside and
* growing downward by lines when the text needs more. Null for free
* text, which is only as large as its lines.
* @property {string} text As typed, with the writer's line breaks. * @property {string} text As typed, with the writer's line breaks.
* @property {string[]} lines As shown, laid out by the writer's client. * @property {string[]} lines As shown, laid out by the writer's client.
* @property {number} size Font size in page units. * @property {number} size Font size in page units.
@@ -163,17 +171,62 @@ export function cleanText(received) {
!received.lines.every((/** @type {unknown} */ line) => typeof line === 'string') || !received.lines.every((/** @type {unknown} */ line) => typeof line === 'string') ||
![received.x, received.y].every((v) => Number.isFinite(v) && Math.abs(v) <= 10 * MAX_PAGE_SIZE) || ![received.x, received.y].every((v) => Number.isFinite(v) && Math.abs(v) <= 10 * MAX_PAGE_SIZE) ||
(received.width !== null && !(received.width >= 10 && received.width <= MAX_PAGE_SIZE)) || (received.width !== null && !(received.width >= 10 && received.width <= MAX_PAGE_SIZE)) ||
(received.height != null && !(received.height >= 10 && received.height <= 10 * MAX_PAGE_SIZE)) ||
!(received.size >= 10 && received.size <= 400) || !(received.size >= 10 && received.size <= 400) ||
!TEXT_FONTS.includes(received.font) || !TEXT_FONTS.includes(received.font) ||
!(received.boxWidth >= 0 && received.boxWidth <= MAX_PAGE_SIZE) || !(received.boxWidth >= 0 && received.boxWidth <= MAX_PAGE_SIZE) ||
!(received.boxHeight >= 0 && received.boxHeight <= 10 * MAX_PAGE_SIZE)) return null; !(received.boxHeight >= 0 && received.boxHeight <= 10 * MAX_PAGE_SIZE)) return null;
return { return {
id: received.id, x: received.x, y: received.y, width: received.width, id: received.id, x: received.x, y: received.y, width: received.width,
height: received.height == null || received.width === null ? null : received.height,
text: received.text, lines: received.lines.slice(), size: received.size, font: received.font, text: received.text, lines: received.lines.slice(), size: received.size, font: received.font,
color: received.color, boxWidth: received.boxWidth, boxHeight: received.boxHeight, color: received.color, boxWidth: received.boxWidth, boxHeight: received.boxHeight,
}; };
} }
/**
* The rectangle a text block takes up: its own, grown to its lines, for a
* text rectangle; the lines' box for free text.
* @param {TextBlock} block
* @returns {Rectangle}
*/
export function textBounds(block) {
if (block.width !== null && block.height !== null) {
return { left: block.x, top: block.y, width: block.width, height: Math.max(block.height, block.boxHeight) };
}
return { left: block.x, top: block.y, width: block.boxWidth, height: block.boxHeight };
}
/**
* A *Hide* rectangle: white, drawn above all writing, hiding what lies
* beneath (Andy's teacher, 2026-10-08). A page object like an image,
* made, moved and removed by those who may make pages, unaffected by
* anyone hiding a layer on their own screen.
* @typedef {object} HideRect
* @property {string} id
* @property {number} x
* @property {number} y
* @property {number} width
* @property {number} height
*/
/**
* @param {any} received
* @returns {HideRect | null}
*/
export function cleanHide(received) {
if (!received || !validId(received.id) ||
![received.x, received.y].every((v) => Number.isFinite(v) && Math.abs(v) <= MAX_PAGE_SIZE) ||
![received.width, received.height].every((v) => v >= 1 && v <= MAX_PAGE_SIZE)) return null;
return {
id: received.id, x: Math.round(received.x), y: Math.round(received.y),
width: Math.round(received.width), height: Math.round(received.height),
};
}
/** A page holds at most this many Hide rectangles. */
export const MAX_PAGE_HIDES = 50;
export class Layer { export class Layer {
/** /**
* @param {string} id The participant's id. * @param {string} id The participant's id.
@@ -205,6 +258,9 @@ export class Layer {
* @property {number} y * @property {number} y
* @property {number} width * @property {number} width
* @property {number} height * @property {number} height
* @property {string} by The participant who added it ('' if unknown):
* they may remove it even without the right to make pages (Andy,
* 2026-10-08).
* @property {Uint8Array<ArrayBuffer> | null} data Null until the bytes * @property {Uint8Array<ArrayBuffer> | null} data Null until the bytes
* have arrived from another participant. * have arrived from another participant.
*/ */
@@ -225,11 +281,17 @@ function withoutData(image) {
* @typedef {object} PageDescriptor * @typedef {object} PageDescriptor
* @property {string} id * @property {string} id
* @property {number} number Counts the pages of a session from 1. * @property {number} number Counts the pages of a session from 1.
* @property {number} left The sheet's left edge: 0 to begin with; less
* once "Widen" has grown the sheet, which grows it equally on both
* sides so that nothing on it moves; anything once "Shrink" has made
* the sheet the contents' bounding box (Andy, 2026-10-08).
* @property {number} top The sheet's top edge: 0 until "Shrink".
* @property {number} width * @property {number} width
* @property {number} height * @property {number} height
* @property {Omit<PageImage, 'data'>[]} images From the top of the page down. * @property {Omit<PageImage, 'data'>[]} images In stacking order, the last in front.
* @property {number} revision Counts the changes made to `images`, so * @property {HideRect[]} hides The Hide rectangles, in stacking order.
* that of two lists the later one can be told. * @property {number} revision Counts the changes made to `images`, `hides`
* and the sheet, so that of two lists the later one can be told.
*/ */
/** /**
@@ -262,46 +324,83 @@ export function cleanImage(received) {
![received.width, received.height].every((v) => v >= 1 && v <= MAX_PAGE_SIZE)) return null; ![received.width, received.height].every((v) => v >= 1 && v <= MAX_PAGE_SIZE)) return null;
return { return {
type: received.type, size: received.size, hash: received.hash, type: received.type, size: received.size, hash: received.hash,
x: received.x, y: received.y, width: received.width, height: received.height, data: null, x: received.x, y: received.y, width: received.width, height: received.height,
by: validId(received.by) ? received.by : '', data: null,
}; };
} }
/** /**
* Where an image of a given natural size goes on a page: centred across * Where an image goes on the page, and the sheet that then holds it.
* the A4 sheet, inside the margin (Andy, 2026-10-03: his teacher's scans
* are often partial and wider than they are tall). The first image lies
* at the top of the sheet; each later one lies below the lowest image
* already there, the margin apart, so that a page can be made of a series
* of images (Andy, 2026-10-05).
* *
* An image wider than the sheet's width inside the margin is scaled down * Below the lowest image on the page, or at the page's top if there is
* to it, or, if `shrink` is false, keeps its natural size: it then begins * none, the margin apart, centred across the page's width, at its
* at the left margin and the sheet is widened to hold it (Andy, * natural size: one way for every image (Andy, 2026-10-08, dropping the
* 2026-10-05, as a choice to try beside the scaling). * shrink-to-fit choice and composition mode, since an image can be moved
* and scaled by hand). An image wider than the page widens the sheet
* equally on both sides; one wider than a page can be is scaled to that.
* @param {Page} page
* @param {number} naturalWidth In page units. * @param {number} naturalWidth In page units.
* @param {number} naturalHeight * @param {number} naturalHeight
* @param {number} [below] The bottom edge of the lowest image already on * @returns {{x: number, y: number, width: number, height: number, sheet: Rectangle}}
* the page; 0 if there is none. * The image's rectangle, and the sheet that holds it and everything
* @param {boolean} [shrink] * the sheet held before.
* @returns {{x: number, y: number, width: number, height: number,
* pageWidth: number, pageHeight: number}}
* The image's rectangle, and how large the sheet must be to hold it.
*/ */
export function placeImage(naturalWidth, naturalHeight, below = 0, shrink = true) { export function placeImage(page, naturalWidth, naturalHeight) {
const room = BLANK_PAGE_WIDTH - 2 * IMAGE_MARGIN; const scale = Math.min(1, (MAX_PAGE_SIZE - 2 * IMAGE_MARGIN) / naturalWidth);
// At its natural size an image may still be no wider than a page can be.
const scale = Math.min(1, (shrink ? room : MAX_PAGE_SIZE - 2 * IMAGE_MARGIN) / naturalWidth);
const width = Math.round(naturalWidth * scale); const width = Math.round(naturalWidth * scale);
const height = Math.round(naturalHeight * scale); const height = Math.round(naturalHeight * scale);
const x = width <= room ? Math.round((BLANK_PAGE_WIDTH - width) / 2) : IMAGE_MARGIN; const x = page.left + Math.round((page.width - width) / 2);
const below = page.images.length ? page.imagesBottom() : page.top;
const y = Math.round(below) + IMAGE_MARGIN; const y = Math.round(below) + IMAGE_MARGIN;
return { return { x, y, width, height, sheet: sheetHolding(page, { left: x, top: y, width, height }) };
x, y, width, height,
pageWidth: Math.max(BLANK_PAGE_WIDTH, x + width + IMAGE_MARGIN),
pageHeight: Math.max(BLANK_PAGE_HEIGHT, y + height + IMAGE_MARGIN),
};
} }
/**
* Which of some rectangles lies under a point, the last (frontmost) first,
* and whether the point is within `reach` of one of its corners, which
* counts before lying inside.
* @param {Rectangle[]} rects
* @param {number} x
* @param {number} y
* @param {number} reach
* @returns {{index: number, corner: [number, number] | null} | null}
* The corner as (0 or 1, 0 or 1): left or right, top or bottom.
*/
export function rectAt(rects, x, y, reach) {
for (let index = rects.length - 1; index >= 0; index -= 1) {
const r = rects[index];
for (const cx of [0, 1]) {
for (const cy of [0, 1]) {
if (Math.hypot(x - (r.left + cx * r.width), y - (r.top + cy * r.height)) <= reach) {
return { index, corner: [cx, cy] };
}
}
}
if (x >= r.left && x <= r.left + r.width && y >= r.top && y <= r.top + r.height) {
return { index, corner: null };
}
}
return null;
}
/**
* The sheet that holds what it holds now and the given rectangle with
* the image margin around it.
* @param {Page} page
* @param {Rectangle} rect
* @returns {Rectangle}
*/
export function sheetHolding(page, rect) {
const left = Math.min(page.baseLeft, rect.left - IMAGE_MARGIN);
const top = Math.min(page.baseTop, rect.top - IMAGE_MARGIN);
const right = Math.max(page.baseLeft + page.baseWidth, rect.left + rect.width + IMAGE_MARGIN);
const bottom = Math.max(page.baseTop + page.baseHeight, rect.top + rect.height + IMAGE_MARGIN);
return { left, top, width: right - left, height: bottom - top };
}
/** An image is never scaled below this, in page units (1 cm). */
export const LEAST_IMAGE_SIDE = 100;
/** /**
* Check a page descriptor that came from outside. * Check a page descriptor that came from outside.
* @param {any} received * @param {any} received
@@ -320,9 +419,20 @@ export function cleanPageDescriptor(received) {
if (!image) return null; if (!image) return null;
images.push(withoutData(image)); images.push(withoutData(image));
} }
const listedHides = received.hides ?? [];
if (!Array.isArray(listedHides) || listedHides.length > MAX_PAGE_HIDES) return null;
const hides = [];
for (const one of listedHides) {
const hide = cleanHide(one);
if (!hide) return null;
hides.push(hide);
}
/** @param {any} value */
const edge = (value) => (Number.isFinite(value) && Math.abs(value) <= MAX_PAGE_SIZE ? Math.round(value) : 0);
return { return {
id: received.id, number: received.number, width: received.width, height: received.height, id: received.id, number: received.number, left: edge(received.left), top: edge(received.top),
images, width: received.width, height: received.height,
images, hides,
revision: Number.isInteger(received.revision) && received.revision >= 0 ? received.revision : 0, revision: Number.isInteger(received.revision) && received.revision >= 0 ? received.revision : 0,
}; };
} }
@@ -340,12 +450,16 @@ export class Page {
* The size of the sheet before anything is written on it: A4, or taller * The size of the sheet before anything is written on it: A4, or taller
* if its images need the room. * if its images need the room.
*/ */
this.baseLeft = 0;
this.baseTop = 0;
this.baseWidth = width; this.baseWidth = width;
this.baseHeight = height; this.baseHeight = height;
this.id = id; this.id = id;
this.number = number; this.number = number;
/** @type {PageImage[]} From the top of the page down. */ /** @type {PageImage[]} From the top of the page down. */
this.images = []; this.images = [];
/** @type {HideRect[]} In stacking order. */
this.hides = [];
/** Counts the changes made to `images`. */ /** Counts the changes made to `images`. */
this.imagesRevision = 0; this.imagesRevision = 0;
/** The page's present rectangle. See updateExtent(). */ /** The page's present rectangle. See updateExtent(). */
@@ -362,28 +476,47 @@ export class Page {
/** @returns {PageDescriptor} */ /** @returns {PageDescriptor} */
descriptor() { descriptor() {
return { return {
id: this.id, number: this.number, width: this.baseWidth, height: this.baseHeight, id: this.id, number: this.number, left: this.baseLeft, top: this.baseTop,
width: this.baseWidth, height: this.baseHeight,
images: this.images.map(withoutData), images: this.images.map(withoutData),
hides: this.hides.map((hide) => ({ ...hide })),
revision: this.imagesRevision, revision: this.imagesRevision,
}; };
} }
/**
* The Hide rectangle under a point, and whether the point is near one of
* its corners, the frontmost first; as imageAt.
* @param {number} x
* @param {number} y
* @param {number} reach
* @returns {{index: number, corner: [number, number] | null} | null}
*/
hideAt(x, y, reach) {
return rectAt(this.hides.map((hide) => ({ left: hide.x, top: hide.y, width: hide.width, height: hide.height })),
x, y, reach);
}
/**
* The image under a point, and whether the point is near one of its
* corners: the frontmost (last) image whose rectangle holds the point
* or has a corner within `reach` of it.
* @param {number} x
* @param {number} y
* @param {number} reach Page units.
* @returns {{index: number, corner: [number, number] | null} | null}
* The corner as (0 or 1, 0 or 1): left or right, top or bottom.
*/
imageAt(x, y, reach) {
return rectAt(this.images.map((image) => ({ left: image.x, top: image.y, width: image.width, height: image.height })),
x, y, reach);
}
/** @returns {number} The bottom edge of the lowest image; 0 if there is none. */ /** @returns {number} The bottom edge of the lowest image; 0 if there is none. */
imagesBottom() { imagesBottom() {
return Math.max(0, ...this.images.map((image) => image.y + image.height)); return Math.max(0, ...this.images.map((image) => image.y + image.height));
} }
/** @returns {number} How tall the sheet must be to hold its images: A4 at least. */
sheetHeight() {
return Math.max(BLANK_PAGE_HEIGHT, this.imagesBottom() + IMAGE_MARGIN);
}
/** @returns {number} How wide the sheet must be to hold its images: A4 at least. */
sheetWidth() {
return Math.max(BLANK_PAGE_WIDTH,
...this.images.map((image) => image.x + image.width + IMAGE_MARGIN));
}
/** @returns {boolean} Whether its images have made the sheet larger than A4. */ /** @returns {boolean} Whether its images have made the sheet larger than A4. */
isLargerThanSheet() { isLargerThanSheet() {
return this.baseWidth > BLANK_PAGE_WIDTH || this.baseHeight > BLANK_PAGE_HEIGHT; return this.baseWidth > BLANK_PAGE_WIDTH || this.baseHeight > BLANK_PAGE_HEIGHT;
@@ -407,7 +540,38 @@ export class Page {
* @returns {Rectangle} * @returns {Rectangle}
*/ */
baseExtent() { baseExtent() {
return { left: 0, top: 0, width: this.baseWidth, height: this.baseHeight }; return { left: this.baseLeft, top: this.baseTop, width: this.baseWidth, height: this.baseHeight };
}
/**
* The smallest sheet that holds everything on the page with the margin
* around it: what "Shrink" makes the sheet (Andy, 2026-10-08).
* @returns {Rectangle | null} Null if the page is blank.
*/
contentSheet() {
/** @type {Rectangle | null} */
let box = this.strokeBounds ? { ...this.strokeBounds } : null;
for (const item of [...this.images, ...this.hides]) {
if (!box) {
box = { left: item.x, top: item.y, width: item.width, height: item.height };
continue;
}
const right = Math.max(box.left + box.width, item.x + item.width);
const bottom = Math.max(box.top + box.height, item.y + item.height);
box.left = Math.min(box.left, item.x);
box.top = Math.min(box.top, item.y);
box.width = right - box.left;
box.height = bottom - box.top;
}
if (!box) return null;
// The greater of the two margins (Andy, 2026-10-08), exactly, the same
// on all four sides (a grid made them uneven, Andy, 2026-10-08).
const margin = Math.max(WRITING_MARGIN, IMAGE_MARGIN);
return {
left: Math.floor(box.left - margin), top: Math.floor(box.top - margin),
width: Math.ceil(box.left + box.width + margin) - Math.floor(box.left - margin),
height: Math.ceil(box.top + box.height + margin) - Math.floor(box.top - margin),
};
} }
/** /**
@@ -440,7 +604,8 @@ export class Page {
* @param {TextBlock} block * @param {TextBlock} block
*/ */
includeText(block) { includeText(block) {
const corners = [block.x, block.y, 0, block.x + block.boxWidth, block.y + block.boxHeight, 0]; const bounds = textBounds(block);
const corners = [bounds.left, bounds.top, 0, bounds.left + bounds.width, bounds.top + bounds.height, 0];
this.includeStroke({ id: '', color: '', width: 0, points: corners, done: true }); this.includeStroke({ id: '', color: '', width: 0, points: corners, done: true });
} }
@@ -453,6 +618,22 @@ export class Page {
} }
} }
/**
* Whether a point of a stroke of the given nominal width lies where the
* page would not grow to hold it: inside the edges by the margin and
* the stroke's reach. With "Extend" off, writing stops there (Andy,
* 2026-10-08), so that the page keeps its size on everyone's screen.
* @param {number} x
* @param {number} y
* @param {number} width
* @returns {boolean}
*/
holdsWriting(x, y, width) {
const inset = WRITING_MARGIN + width * 2; // The reach of includeStroke.
return x >= this.left + inset && x <= this.left + this.width - inset &&
y >= this.top + inset && y <= this.top + this.height - inset;
}
/** /**
* Set the page's rectangle from what is on it: the base rectangle, * Set the page's rectangle from what is on it: the base rectangle,
* widened to hold every stroke with the margin around it, in steps of * widened to hold every stroke with the margin around it, in steps of
@@ -467,11 +648,17 @@ export class Page {
let bottom = base.top + base.height; let bottom = base.top + base.height;
const b = this.strokeBounds; const b = this.strokeBounds;
if (b) { if (b) {
// Writing that comes within the margin of a sheet's edge moves that
// edge out in steps of the margin, counted from the sheet's edge
// (not from a fixed grid, since 2026-10-08: a sheet that "Shrink"
// made exact stays exact until writing reaches its margin).
const step = WRITING_MARGIN; const step = WRITING_MARGIN;
left = Math.min(left, Math.floor((b.left - WRITING_MARGIN) / step) * step); /** @param {number} short How far the writing's margin reaches past an edge. */
top = Math.min(top, Math.floor((b.top - WRITING_MARGIN) / step) * step); const steps = (short) => (short > 0 ? Math.ceil(short / step) * step : 0);
right = Math.max(right, Math.ceil((b.left + b.width + WRITING_MARGIN) / step) * step); left -= steps(left - (b.left - WRITING_MARGIN));
bottom = Math.max(bottom, Math.ceil((b.top + b.height + WRITING_MARGIN) / step) * step); top -= steps(top - (b.top - WRITING_MARGIN));
right += steps(b.left + b.width + WRITING_MARGIN - right);
bottom += steps(b.top + b.height + WRITING_MARGIN - bottom);
} }
const changed = left !== this.left || top !== this.top || const changed = left !== this.left || top !== this.top ||
right - left !== this.width || bottom - top !== this.height; right - left !== this.width || bottom - top !== this.height;
@@ -489,7 +676,10 @@ export class Page {
*/ */
static from(descriptor) { static from(descriptor) {
const page = new Page(descriptor.width, descriptor.height, descriptor.id, descriptor.number); const page = new Page(descriptor.width, descriptor.height, descriptor.id, descriptor.number);
page.baseLeft = descriptor.left;
page.baseTop = descriptor.top;
page.images = descriptor.images.map((image) => ({ ...image, data: null })); page.images = descriptor.images.map((image) => ({ ...image, data: null }));
page.hides = descriptor.hides.map((hide) => ({ ...hide }));
page.imagesRevision = descriptor.revision; page.imagesRevision = descriptor.revision;
page.updateExtent(); page.updateExtent();
return page; return page;
@@ -567,11 +757,12 @@ export function textsTouched(layer, x0, y0, x1, y1, radius) {
const touched = []; const touched = [];
const steps = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0) / radius)); const steps = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0) / radius));
for (const block of layer.texts.values()) { for (const block of layer.texts.values()) {
const b = textBounds(block);
for (let s = 0; s <= steps; s++) { for (let s = 0; s <= steps; s++) {
const x = x0 + (x1 - x0) * s / steps; const x = x0 + (x1 - x0) * s / steps;
const y = y0 + (y1 - y0) * s / steps; const y = y0 + (y1 - y0) * s / steps;
if (x >= block.x - radius && x <= block.x + block.boxWidth + radius && if (x >= b.left - radius && x <= b.left + b.width + radius &&
y >= block.y - radius && y <= block.y + block.boxHeight + radius) { y >= b.top - radius && y <= b.top + b.height + radius) {
touched.push(block.id); touched.push(block.id);
break; break;
} }
@@ -588,9 +779,8 @@ export function textsTouched(layer, x0, y0, x1, y1, radius) {
*/ */
export function textAt(layer, x, y) { export function textAt(layer, x, y) {
for (const block of [...layer.texts.values()].reverse()) { for (const block of [...layer.texts.values()].reverse()) {
if (x >= block.x && x <= block.x + block.boxWidth && y >= block.y && y <= block.y + block.boxHeight) { const b = textBounds(block); // A text rectangle counts whole, empty part included.
return block; if (x >= b.left && x <= b.left + b.width && y >= b.top && y <= b.top + b.height) return block;
}
} }
return null; return null;
} }
+174 -21
View File
@@ -5,6 +5,7 @@
*/ */
import { rounded } from './page_model.js'; import { rounded } from './page_model.js';
import { trace } from './log.js';
/** Bit in PointerEvent.buttons for the eraser end of a pen. */ /** Bit in PointerEvent.buttons for the eraser end of a pen. */
const ERASER_BUTTON = 32; const ERASER_BUTTON = 32;
@@ -19,23 +20,53 @@ const NOMINAL_PRESSURE = 0.4;
* @typedef {object} PenTarget * @typedef {object} PenTarget
* @property {(clientX: number, clientY: number) => [number, number]} toPage * @property {(clientX: number, clientY: number) => [number, number]} toPage
* Convert a window position to page units. * Convert a window position to page units.
* @property {() => 'pen' | 'eraser' | 'text'} tool The tool selected in the toolbar. * @property {() => 'pen' | 'eraser' | 'text' | 'hide'} tool The tool selected in the toolbar.
* @property {() => boolean} canWrite False while there is no page to write on. * @property {() => boolean} canWrite False while there is no page to write on.
* @property {() => void} beginStroke * @property {() => void} beginStroke
* @property {(points: number[]) => void} addPoints * @property {(points: number[]) => void} addPoints
* @property {() => void} endStroke * @property {() => void} endStroke
* @property {(x0: number, y0: number, x1: number, y1: number) => void} eraseAlong * @property {(x0: number, y0: number, x1: number, y1: number) => {strokes: number, texts: number}} eraseAlong
* Erases one's own strokes and text blocks along the movement and says how many.
* @property {(x: number, y: number, reach: number) => void} [eraseEnded] * @property {(x: number, y: number, reach: number) => void} [eraseEnded]
* The eraser went down at (x, y) and has come up again, having moved * The eraser went down at (x, y) and has come up again, having moved
* no farther than `reach` from there. * no farther than `reach` from there.
* @property {(x0: number, y0: number, x1: number, y1: number) => void} placeText * @property {(x0: number, y0: number, x1: number, y1: number) => void} placeRect
* With the text tool, the pointer went down at (x0, y0) and up at (x1, y1). * With the text or Hide tool, the pointer went down at (x0, y0) and up
* @property {(x0: number, y0: number, x1: number) => void} [previewText] * at (x1, y1): a tap, or a rectangle dragged open.
* With the text tool, the pointer is down at (x0, y0) and now at x1. * @property {(x0: number, y0: number, x1: number, y1: number) => void} [previewRect]
* With the text or Hide tool, the pointer is down at (x0, y0) and now at (x1, y1).
* @property {(clientX: number, clientY: number) => boolean} [beginHold]
* The pointer has rested on the page for HOLD_MS, with any tool: a
* hold. True if something under it took the hold (an image to move
* or scale); the stroke or text placement begun is then abandoned
* here, and the pointer's movement goes to holdMove. With the
* eraser, erasing waits until the pointer moves or is lifted, so that
* a hold erases nothing (Andy, 2026-10-08: every tool can move).
* @property {(clientX: number, clientY: number) => void} [holdMove]
* @property {() => void} [holdEnd]
* @property {(clientX: number, clientY: number) => void} [beginZoom]
* The pointer went down with Ctrl (or Command) held: a zoom drag.
* @property {(pixelsUp: number) => void} [zoomTo]
* The zoom drag has moved this far up the screen (negative: down).
* @property {() => void} [endZoom]
* @property {(report: PenReport) => void} [onReport] * @property {(report: PenReport) => void} [onReport]
*/ */
/**
* A zoom drag shorter than this, in screen pixels, zooms nothing, so that
* a tap with Ctrl held is not a zoom.
*/
const ZOOM_DEAD_ZONE = 4;
/**
* A press that rests this long becomes a hold, which moves or scales the
* image under it (Andy's teacher, 2026-10-08; 600 ms so that a careful
* start of a stroke is not one). Moving farther than HOLD_SLACK_PX
* before then makes it a stroke after all.
*/
export const HOLD_MS = 600;
export const HOLD_SLACK_PX = 6;
/** /**
* What the input device is delivering, for judging pen support. * What the input device is delivering, for judging pen support.
* @typedef {object} PenReport * @typedef {object} PenReport
@@ -44,6 +75,13 @@ const NOMINAL_PRESSURE = 0.4;
* @property {number} pointsPerSecond * @property {number} pointsPerSecond
*/ */
/** @param {EventTarget | null} target @returns {string} The element, for the trace. */
function describeTarget(target) {
if (!(target instanceof Element)) return 'nothing';
const name = target.tagName.toLowerCase();
return target.id ? `${name}#${target.id}` : (target.className ? `${name}.${target.className}` : name);
}
/** /**
* @param {HTMLElement} surface The element that receives the pointer. * @param {HTMLElement} surface The element that receives the pointer.
* @param {PenTarget} target * @param {PenTarget} target
@@ -53,6 +91,28 @@ export function attachPenInput(surface, target) {
let activePointer = null; let activePointer = null;
let erasing = false; let erasing = false;
let placingText = false; let placingText = false;
let zooming = false;
let zoomStartY = 0;
/** @type {ReturnType<typeof setTimeout> | null} */
let holdTimer = null;
let holding = false;
let pressX = 0;
let pressY = 0;
/** @type {PointerEvent | null} The eraser's press, not yet erased with. */
let eraseWaiting = null;
/** The eraser's press erases now, if it was waiting. */
function eraseFromPress() {
if (!eraseWaiting) return;
const press = eraseWaiting;
eraseWaiting = null;
erase(press);
}
function cancelHoldTimer() {
if (holdTimer !== null) clearTimeout(holdTimer);
holdTimer = null;
}
let textStartX = 0; let textStartX = 0;
let textStartY = 0; let textStartY = 0;
let eraseStartX = 0; let eraseStartX = 0;
@@ -63,6 +123,9 @@ export function attachPenInput(surface, target) {
let lastY = NaN; let lastY = NaN;
let strokeStart = 0; let strokeStart = 0;
let strokePoints = 0; let strokePoints = 0;
/** What the eraser removed since its press, for the trace. */
let erasedStrokes = 0;
let erasedTexts = 0;
/** /**
* @param {PointerEvent} event * @param {PointerEvent} event
@@ -103,35 +166,77 @@ export function attachPenInput(surface, target) {
function erase(event) { function erase(event) {
for (const e of allEvents(event)) { for (const e of allEvents(event)) {
const [x, y] = target.toPage(e.clientX, e.clientY); const [x, y] = target.toPage(e.clientX, e.clientY);
target.eraseAlong(Number.isNaN(lastX) ? x : lastX, Number.isNaN(lastY) ? y : lastY, x, y); const removed = target.eraseAlong(Number.isNaN(lastX) ? x : lastX, Number.isNaN(lastY) ? y : lastY, x, y);
erasedStrokes += removed.strokes;
erasedTexts += removed.texts;
eraseReach = Math.max(eraseReach, Math.hypot(x - eraseStartX, y - eraseStartY)); eraseReach = Math.max(eraseReach, Math.hypot(x - eraseStartX, y - eraseStartY));
lastX = x; lastX = x;
lastY = y; lastY = y;
} }
} }
/** @param {PointerEvent} event @returns {string} The press, for the trace. */
function describe(event) {
const where = event.target === surface ? '' : ` on ${describeTarget(event.target)}`;
return `${event.pointerType} ${event.pointerId} buttons ${event.buttons} pressure ${event.pressure.toFixed(2)}${where}`;
}
surface.addEventListener('pointerdown', (event) => { surface.addEventListener('pointerdown', (event) => {
if (activePointer !== null || !target.canWrite() || event.target !== surface) return; /** @param {string} why */
const refuse = (why) => trace(`press ${describe(event)} refused: ${why}`);
if (activePointer !== null) return refuse(`pointer ${activePointer} still active`);
if (event.target !== surface) return refuse('not on the page');
if (event.pointerType === 'pen') penSeen = true; if (event.pointerType === 'pen') penSeen = true;
// Once a pen has been used, a touch is taken to be the resting hand. // Once a pen has been used, a touch is taken to be the resting hand.
if (event.pointerType === 'touch' && penSeen) return; if (event.pointerType === 'touch' && penSeen) return refuse('a touch after a pen: the resting hand');
if (event.pointerType === 'mouse' && event.button !== 0) return; if (event.pointerType === 'mouse' && event.button !== 0) return refuse('not the main button');
// With Ctrl (Command on a Mac) held, a drag up or down zooms and
// draws nothing (Andy, 2026-10-08).
zooming = (event.ctrlKey || event.metaKey) && target.beginZoom !== undefined;
if (!zooming && !target.canWrite()) return refuse('cannot write now');
event.preventDefault(); event.preventDefault();
surface.setPointerCapture(event.pointerId); surface.setPointerCapture(event.pointerId);
activePointer = event.pointerId; activePointer = event.pointerId;
trace(`press ${describe(event)}, ${target.tool()}${zooming ? ', zooming' : ''}`);
if (zooming) {
zoomStartY = event.clientY;
target.beginZoom?.(event.clientX, event.clientY);
return;
}
const tool = target.tool(); const tool = target.tool();
erasing = tool === 'eraser' || (event.buttons & ERASER_BUTTON) !== 0; erasing = tool === 'eraser' || (event.buttons & ERASER_BUTTON) !== 0;
placingText = tool === 'text' && !erasing; placingText = (tool === 'text' || tool === 'hide') && !erasing;
lastX = NaN; lastX = NaN;
lastY = NaN; lastY = NaN;
strokeStart = performance.now(); strokeStart = performance.now();
strokePoints = 0; strokePoints = 0;
erasedStrokes = 0;
erasedTexts = 0;
holding = false;
eraseWaiting = null;
// Resting the pointer, with any tool, may become a hold on an image.
if (target.beginHold) {
pressX = event.clientX;
pressY = event.clientY;
holdTimer = setTimeout(() => {
holdTimer = null;
if (activePointer === null) return;
if (target.beginHold?.(pressX, pressY)) {
holding = true;
placingText = false;
eraseWaiting = null;
} else {
eraseFromPress();
}
}, HOLD_MS);
}
if (placingText) { if (placingText) {
[textStartX, textStartY] = target.toPage(event.clientX, event.clientY); [textStartX, textStartY] = target.toPage(event.clientX, event.clientY);
} else if (erasing) { } else if (erasing) {
[eraseStartX, eraseStartY] = target.toPage(event.clientX, event.clientY); [eraseStartX, eraseStartY] = target.toPage(event.clientX, event.clientY);
eraseReach = 0; eraseReach = 0;
erase(event); if (target.beginHold) eraseWaiting = event;
else erase(event);
} else { } else {
target.beginStroke(); target.beginStroke();
draw(event); draw(event);
@@ -140,23 +245,53 @@ export function attachPenInput(surface, target) {
surface.addEventListener('pointermove', (event) => { surface.addEventListener('pointermove', (event) => {
if (event.pointerId !== activePointer) return; if (event.pointerId !== activePointer) return;
if (placingText) { if (holding) {
const [x] = target.toPage(event.clientX, event.clientY); target.holdMove?.(event.clientX, event.clientY);
target.previewText?.(textStartX, textStartY, x);
return; return;
} }
if (erasing) erase(event); if (holdTimer !== null && Math.hypot(event.clientX - pressX, event.clientY - pressY) > HOLD_SLACK_PX) {
else draw(event); cancelHoldTimer();
eraseFromPress();
}
if (zooming) {
const up = zoomStartY - event.clientY;
const beyond = Math.max(0, Math.abs(up) - ZOOM_DEAD_ZONE);
target.zoomTo?.(Math.sign(up) * beyond);
return;
}
if (placingText) {
const [x, y] = target.toPage(event.clientX, event.clientY);
target.previewRect?.(textStartX, textStartY, x, y);
return;
}
if (erasing) {
if (!eraseWaiting) erase(event);
} else {
draw(event);
}
}); });
/** @param {PointerEvent} event */ /** @param {PointerEvent} event */
function finish(event) { function finish(event) {
if (event.pointerId !== activePointer) return; if (event.pointerId !== activePointer) {
trace(`${event.type} ${event.pointerType} ${event.pointerId}, not the active pointer`);
return;
}
trace(`${event.type} ${event.pointerType} ${event.pointerId}, ` +
(erasing ? `erased ${erasedStrokes} strokes and ${erasedTexts} texts` : `${strokePoints} points`));
activePointer = null; activePointer = null;
if (placingText) { cancelHoldTimer();
if (holding) {
holding = false;
target.holdEnd?.();
} else if (zooming) {
zooming = false;
target.endZoom?.();
} else if (placingText) {
const [x, y] = target.toPage(event.clientX, event.clientY); const [x, y] = target.toPage(event.clientX, event.clientY);
if (event.type === 'pointerup') target.placeText(textStartX, textStartY, x, y); if (event.type === 'pointerup') target.placeRect(textStartX, textStartY, x, y);
} else if (erasing) { } else if (erasing) {
eraseFromPress();
if (event.type === 'pointerup') target.eraseEnded?.(eraseStartX, eraseStartY, eraseReach); if (event.type === 'pointerup') target.eraseEnded?.(eraseStartX, eraseStartY, eraseReach);
} else { } else {
target.endStroke(); target.endStroke();
@@ -164,7 +299,25 @@ export function attachPenInput(surface, target) {
} }
surface.addEventListener('pointerup', finish); surface.addEventListener('pointerup', finish);
surface.addEventListener('pointercancel', finish); surface.addEventListener('pointercancel', finish);
surface.addEventListener('lostpointercapture', (event) => {
if (event.pointerId === activePointer) trace(`capture of pointer ${event.pointerId} lost while active`);
});
for (const type of ['touchstart', 'touchend', 'touchcancel']) {
surface.addEventListener(type, (event) => {
const touches = [...(/** @type {TouchEvent} */ (event).changedTouches)]
.map((touch) => /** @type {{touchType?: string}} */ (touch).touchType ?? 'touch');
trace(`${type} ${touches.join(' ')}${event.target === surface ? '' : ` on ${describeTarget(event.target)}`}`);
});
}
// The side button of a pen asks for a context menu. // The side button of a pen asks for a context menu.
surface.addEventListener('contextmenu', (event) => event.preventDefault()); surface.addEventListener('contextmenu', (event) => event.preventDefault());
// Safari on iOS: cancelling pointerdown leaves the touch's own
// behaviour (selection, the magnifier, the callout) to WebKit; only a
// cancelled touchstart stops it. The text field is a child of the
// surface and keeps its touches, or it could not be tapped into.
surface.addEventListener('touchstart', (event) => {
if (event.target === surface) event.preventDefault();
}, { passive: false });
} }
+388
View File
@@ -0,0 +1,388 @@
// @ts-check
/**
* A QR code of a text, so that the invitation can be read by 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). Written for Kokkoja since
* the client uses no packages: byte mode, any version from 1 to 40,
* error correction level M, the mask chosen by the standard's penalty
* rules (ISO/IEC 18004). `tests/test_qr_code.py` compares every version
* and level with Nayuki's reference encoder, mask choice included.
*/
/** Error correction levels, as the format bits name them. */
const LEVELS = { L: 1, M: 0, Q: 3, H: 2 };
/** @typedef {keyof typeof LEVELS} Level */
/** Error correction codewords per block, by level then version (index 0 unused). */
const ECC_PER_BLOCK = {
L: [0, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],
M: [0, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28],
Q: [0, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],
H: [0, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],
};
/** Number of blocks, by level then version. */
const BLOCKS = {
L: [0, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25],
M: [0, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49],
Q: [0, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68],
H: [0, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81],
};
/** @param {number} version @returns {number} Modules not taken by function patterns. */
function rawDataModules(version) {
let result = (16 * version + 128) * version + 64;
if (version >= 2) {
const aligns = Math.floor(version / 7) + 2;
result -= (25 * aligns - 10) * aligns - 55;
if (version >= 7) result -= 36;
}
return result;
}
/** @param {number} version @param {Level} level @returns {number} Data codewords. */
function dataCodewords(version, level) {
return Math.floor(rawDataModules(version) / 8) - ECC_PER_BLOCK[level][version] * BLOCKS[level][version];
}
/** @param {number} version @returns {number} Bits of the byte count in byte mode. */
function countBits(version) {
return version < 10 ? 8 : 16;
}
/**
* @param {number} byteCount
* @param {Level} level
* @returns {number} The smallest version holding that many bytes.
*/
function versionFor(byteCount, level) {
for (let version = 1; version <= 40; version++) {
if (4 + countBits(version) + byteCount * 8 <= dataCodewords(version, level) * 8) return version;
}
throw new Error('Too long for a QR code');
}
/** @param {number} version @returns {number[]} Centres of the alignment patterns, one axis. */
function alignmentPositions(version) {
if (version === 1) return [];
const count = Math.floor(version / 7) + 2;
const size = version * 4 + 17;
const step = Math.floor((version * 8 + count * 3 + 5) / (count * 4 - 4)) * 2;
const result = [6];
for (let position = size - 7; result.length < count; position -= step) result.splice(1, 0, position);
return result;
}
/* Reed-Solomon over GF(2^8) with the standard's polynomial. */
/** @param {number} x @param {number} y @returns {number} */
function multiply(x, y) {
let z = 0;
for (let i = 7; i >= 0; i--) {
z = (z << 1) ^ ((z >>> 7) * 0x11d);
z ^= ((y >>> i) & 1) * x;
}
return z;
}
/** @param {number} degree @returns {number[]} The generator polynomial's coefficients, highest first after the leading 1. */
function generator(degree) {
let result = Array(degree - 1).fill(0).concat([1]);
let root = 1;
for (let i = 0; i < degree; i++) {
result = result.map((coefficient, j) => multiply(coefficient, root) ^ (j + 1 < result.length ? result[j + 1] : 0));
root = multiply(root, 2);
}
return result;
}
/** @param {number[]} data @param {number[]} divisor @returns {number[]} The remainder. */
function remainder(data, divisor) {
const result = Array(divisor.length).fill(0);
for (const byte of data) {
const factor = byte ^ /** @type {number} */ (result.shift());
result.push(0);
divisor.forEach((coefficient, i) => { result[i] ^= multiply(coefficient, factor); });
}
return result;
}
/**
* @param {Uint8Array} bytes
* @param {number} version
* @param {Level} level
* @returns {number[]} All codewords, data and error correction interleaved.
*/
function codewords(bytes, version, level) {
/** @type {number[]} */
const bits = [];
/** @param {number} value @param {number} count */
const append = (value, count) => { for (let i = count - 1; i >= 0; i--) bits.push((value >>> i) & 1); };
append(4, 4);
append(bytes.length, countBits(version));
for (const byte of bytes) append(byte, 8);
const capacity = dataCodewords(version, level) * 8;
append(0, Math.min(4, capacity - bits.length));
append(0, (8 - bits.length % 8) % 8);
for (let pad = 0xec; bits.length < capacity; pad ^= 0xec ^ 0x11) append(pad, 8);
/** @type {number[]} */
const data = [];
for (let i = 0; i < bits.length; i += 8) data.push(parseInt(bits.slice(i, i + 8).join(''), 2));
const blockCount = BLOCKS[level][version];
const eccCount = ECC_PER_BLOCK[level][version];
const total = Math.floor(rawDataModules(version) / 8);
const shortBlocks = blockCount - total % blockCount;
const shortLength = Math.floor(total / blockCount);
const divisor = generator(eccCount);
/** @type {number[][]} */
const blocks = [];
for (let i = 0, start = 0; i < blockCount; i++) {
const length = shortLength - eccCount + (i < shortBlocks ? 0 : 1);
const block = data.slice(start, start + length);
start += length;
const ecc = remainder(block, divisor);
if (i < shortBlocks) block.push(0); // A placeholder, skipped below.
blocks.push(block.concat(ecc));
}
/** @type {number[]} */
const result = [];
for (let i = 0; i < blocks[0].length; i++) {
blocks.forEach((block, j) => {
if (i !== shortLength - eccCount || j >= shortBlocks) result.push(block[i]);
});
}
return result;
}
/**
* The modules of a QR code: `modules[y][x]` is true where dark.
* @typedef {boolean[][]} Modules
*/
/**
* @param {Uint8Array} bytes
* @param {Level} level
* @returns {Modules}
*/
function encodeBytes(bytes, level) {
const version = versionFor(bytes.length, level);
const size = version * 4 + 17;
/** @type {Modules} */
const modules = Array.from({ length: size }, () => Array(size).fill(false));
/** @type {boolean[][]} Where function patterns are, not to be masked. */
const reserved = Array.from({ length: size }, () => Array(size).fill(false));
/** @param {number} x @param {number} y @param {boolean} dark */
const setFunction = (x, y, dark) => { modules[y][x] = dark; reserved[y][x] = true; };
// Timing patterns, finders with their separators, alignment patterns.
for (let i = 0; i < size; i++) {
setFunction(6, i, i % 2 === 0);
setFunction(i, 6, i % 2 === 0);
}
/** @param {number} cx @param {number} cy */
const finder = (cx, cy) => {
for (let dy = -4; dy <= 4; dy++) {
for (let dx = -4; dx <= 4; dx++) {
const x = cx + dx;
const y = cy + dy;
if (x < 0 || y < 0 || x >= size || y >= size) continue;
const distance = Math.max(Math.abs(dx), Math.abs(dy));
setFunction(x, y, distance !== 2 && distance !== 4);
}
}
};
finder(3, 3);
finder(size - 4, 3);
finder(3, size - 4);
const aligns = alignmentPositions(version);
aligns.forEach((cx, i) => {
aligns.forEach((cy, j) => {
const onFinder = (i === 0 && j === 0) || (i === 0 && j === aligns.length - 1) ||
(i === aligns.length - 1 && j === 0);
if (onFinder) return;
for (let dy = -2; dy <= 2; dy++) {
for (let dx = -2; dx <= 2; dx++) {
setFunction(cx + dx, cy + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
}
}
});
});
/** @param {number} mask */
const drawFormat = (mask) => {
const data = (LEVELS[level] << 3) | mask;
let rem = data;
for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
const bits = ((data << 10) | rem) ^ 0x5412;
/** @param {number} i @returns {boolean} */
const bit = (i) => ((bits >>> i) & 1) !== 0;
for (let i = 0; i <= 5; i++) setFunction(8, i, bit(i));
setFunction(8, 7, bit(6));
setFunction(8, 8, bit(7));
setFunction(7, 8, bit(8));
for (let i = 9; i < 15; i++) setFunction(14 - i, 8, bit(i));
for (let i = 0; i < 8; i++) setFunction(size - 1 - i, 8, bit(i));
for (let i = 8; i < 15; i++) setFunction(8, size - 15 + i, bit(i));
setFunction(8, size - 8, true);
};
drawFormat(0); // Reserves the places; redrawn with the chosen mask.
if (version >= 7) {
let rem = version;
for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25);
const bits = (version << 12) | rem;
for (let i = 0; i < 18; i++) {
const dark = ((bits >>> i) & 1) !== 0;
const a = size - 11 + (i % 3);
const b = Math.floor(i / 3);
setFunction(a, b, dark);
setFunction(b, a, dark);
}
}
// The codewords, in the zigzag the standard prescribes.
const words = codewords(bytes, version, level);
let i = 0;
for (let right = size - 1; right >= 1; right -= 2) {
if (right === 6) right = 5;
for (let vertical = 0; vertical < size; vertical++) {
for (let j = 0; j < 2; j++) {
const x = right - j;
const upward = ((right + 1) & 2) === 0;
const y = upward ? size - 1 - vertical : vertical;
if (reserved[y][x] || i >= words.length * 8) continue;
modules[y][x] = ((words[i >>> 3] >>> (7 - (i & 7))) & 1) !== 0;
i++;
}
}
}
// The mask with the least penalty.
/** @type {((x: number, y: number) => boolean)[]} */
const masks = [
(x, y) => (x + y) % 2 === 0,
(x, y) => y % 2 === 0,
(x, y) => x % 3 === 0,
(x, y) => (x + y) % 3 === 0,
(x, y) => (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0,
(x, y) => (x * y) % 2 + (x * y) % 3 === 0,
(x, y) => ((x * y) % 2 + (x * y) % 3) % 2 === 0,
(x, y) => ((x + y) % 2 + (x * y) % 3) % 2 === 0,
];
/** @param {number} mask */
const applyMask = (mask) => {
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
if (!reserved[y][x] && masks[mask](x, y)) modules[y][x] = !modules[y][x];
}
}
};
let best = 0;
let least = Infinity;
for (let mask = 0; mask < 8; mask++) {
applyMask(mask);
drawFormat(mask);
const score = penalty(modules);
if (score < least) {
least = score;
best = mask;
}
applyMask(mask);
}
applyMask(best);
drawFormat(best);
return modules;
}
/**
* The standard's four penalty rules, as Nayuki's reference encoder reads
* them: a finder-like pattern is a run of dark, light, dark, light, dark
* in the ratio 1:1:3:1:1 at any scale, with light four times its unit
* or more on a side (the edge counting as light), scored once a side.
* The tests compare the mask chosen with that encoder's.
* @param {Modules} modules
* @returns {number}
*/
function penalty(modules) {
const size = modules.length;
let score = 0;
/** @param {boolean[]} line */
const scoreLine = (line) => {
/** The lengths of the latest runs, the latest first. */
const runs = [0, 0, 0, 0, 0, 0, 0];
/** @param {number} length */
const addRun = (length) => {
if (runs[0] === 0) length += size; // The edge is light without end.
runs.pop();
runs.unshift(length);
};
/** Finder-like patterns ending with the light run just added. */
const patterns = () => {
const n = runs[1];
const core = n > 0 && runs[2] === n && runs[3] === n * 3 && runs[4] === n && runs[5] === n;
return (core && runs[0] >= n * 4 && runs[6] >= n ? 1 : 0) +
(core && runs[6] >= n * 4 && runs[0] >= n ? 1 : 0);
};
let color = false;
let run = 0;
for (const dark of line) {
if (dark === color) {
run++;
if (run === 5) score += 3;
else if (run > 5) score += 1;
continue;
}
addRun(run);
if (!color) score += patterns() * 40;
color = dark;
run = 1;
}
if (color) {
addRun(run);
run = 0;
}
addRun(run + size);
score += patterns() * 40;
};
for (let y = 0; y < size; y++) scoreLine(modules[y]);
for (let x = 0; x < size; x++) scoreLine(modules.map((row) => row[x]));
let dark = 0;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
if (modules[y][x]) dark++;
if (x + 1 < size && y + 1 < size) {
const a = modules[y][x];
if (modules[y][x + 1] === a && modules[y + 1][x] === a && modules[y + 1][x + 1] === a) score += 3;
}
}
}
const total = size * size;
const steps = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1;
return score + steps * 10;
}
/**
* @param {string} text
* @param {Level} [level]
* @returns {Modules}
*/
export function qrModules(text, level = 'M') {
return encodeBytes(new TextEncoder().encode(text), level);
}
/** The light border the standard requires, in modules. */
export const QUIET_ZONE = 4;
/**
* @param {Modules} modules
* @returns {string} An SVG path of the dark modules, one unit a module,
* for a viewBox of `-QUIET_ZONE -QUIET_ZONE size+2*QUIET_ZONE
* size+2*QUIET_ZONE`.
*/
export function qrPath(modules) {
/** @type {string[]} */
const parts = [];
modules.forEach((row, y) => {
row.forEach((dark, x) => { if (dark) parts.push(`M${x} ${y}h1v1h-1z`); });
});
return parts.join('');
}
+298 -57
View File
@@ -10,9 +10,10 @@
* stroke grows. * stroke grows.
*/ */
import { BLANK_PAGE_HEIGHT, BLANK_PAGE_WIDTH, POINT_SIZE, pointCount } from './page_model.js'; import { BLANK_PAGE_HEIGHT, BLANK_PAGE_WIDTH, POINT_SIZE, pointCount, textBounds } from './page_model.js';
import { TEXT_FRAME_COLOR, TEXT_FRAME_WIDTH, TEXT_MARK } from './page_file.js';
import { partAt, widthAt } from './stroke_geometry.js'; import { partAt, widthAt } from './stroke_geometry.js';
import { LEFT_GAP, LINE_HEIGHT, TOP_GAP, fontOf } from './text_layout.js'; import { LEFT_GAP, LINE_HEIGHT, baselineOf, fontOf } from './text_layout.js';
/** @typedef {import('./page_model.js').Page} Page */ /** @typedef {import('./page_model.js').Page} Page */
/** @typedef {import('./page_model.js').Stroke} Stroke */ /** @typedef {import('./page_model.js').Stroke} Stroke */
@@ -24,31 +25,140 @@ import { LEFT_GAP, LINE_HEIGHT, TOP_GAP, fontOf } from './text_layout.js';
*/ */
/** /**
* One step of zooming, by button or scroll wheel, is this factor (10%, * One click of the scroll wheel zooms by this factor (10%, Andy's choice
* Andy's first choice to try, 2026-10-03). * to try, 2026-10-03).
*/ */
export const ZOOM_STEP = 1.1; export const ZOOM_STEP = 1.1;
/**
* A pen drag with Ctrl held zooms by this factor for every 100 screen
* pixels of movement, continuously (Andy, 2026-10-08; a constant so that
* it can be tuned).
*/
export const ZOOM_PER_100_PIXELS = 2;
const LEAST_CSS_PIXELS_PER_UNIT = 0.05; const LEAST_CSS_PIXELS_PER_UNIT = 0.05;
const MOST_CSS_PIXELS_PER_UNIT = 8; const MOST_CSS_PIXELS_PER_UNIT = 8;
/** /**
* How the page is scaled on screen: fitted to the window, or a fixed * How near a corner of an image a press must be, in screen pixels, to
* number of CSS pixels per page unit. * scale the image rather than move it (Andy, 2026-10-08; a constant to
* tune). In screen pixels so that the handle is the same size under
* the pen at any zoom.
*/
export const CORNER_REACH_PX = 24;
/**
* How a text rectangle's frame is shown on screen: the whole rectangle,
* as Andy's teacher is used to, or only its corners, which is quieter
* (Andy, 2026-10-08: a constant, to try both). The page file and the
* export always hold the whole rectangle.
* @type {'rectangle' | 'corners'}
*/
export const TEXT_FRAME_STYLE = 'rectangle';
/** The length of a corner mark, in page units, when only corners are shown. */
const CORNER_MARK = 60;
/** Hide rectangles are marked by blue corners on screen, for everyone. */
const HIDE_CORNER_COLOR = '#1e88e5';
const HIDE_CORNER_WIDTH = 2;
/**
* How the page is scaled on screen: a number of CSS pixels per page unit.
* 'fit' asks for the scale at which the whole page fits the window, and
* is replaced by that number as soon as the page is shown: "Fit" is a
* command, not a mode (Andy, 2026-10-08), so a page that grows afterwards
* extends beyond the window instead of being refitted.
* @typedef {'fit' | number} Zoom * @typedef {'fit' | number} Zoom
*/ */
/** /**
* Draw a text block. * Stroke lines on the device pixel grid, so that they are as crisp as a
* CSS border and the same width: a line of `width` page units becomes a
* whole number of pixels, at least one, and its edges land on pixels.
* The lines are given in page units and drawn through the context's
* transform, which maps page units to device pixels.
* @param {CanvasRenderingContext2D} context
* @param {[number, number][][]} polylines
* @param {string} color
* @param {number} width
*/
function strokeCrisp(context, polylines, color, width) {
const t = context.getTransform();
const pixels = Math.max(1, Math.round(width * t.a));
/** @param {number} v */
const snap = (v) => Math.round(v);
context.save();
context.setTransform(1, 0, 0, 1, 0, 0);
context.strokeStyle = color;
context.lineWidth = pixels;
context.lineCap = 'butt';
context.lineJoin = 'miter';
for (const line of polylines) {
context.beginPath();
line.forEach(([x, y], i) => {
// A line's centre at half a pixel in: its edges on the grid.
const dx = snap(t.a * x + t.e);
const dy = snap(t.d * y + t.f);
const px = dx + (pixels % 2 ? 0.5 : 0);
const py = dy + (pixels % 2 ? 0.5 : 0);
if (i === 0) context.moveTo(px, py);
else context.lineTo(px, py);
});
context.stroke();
}
context.restore();
}
/**
* Draw the corners of a rectangle as short L-shaped marks.
* @param {CanvasRenderingContext2D} context
* @param {import('./page_model.js').Rectangle} r
* @param {string} color
* @param {number} width
*/
function drawCornerMarks(context, r, color, width) {
const mark = Math.min(CORNER_MARK, r.width / 2, r.height / 2);
/** @type {[number, number][][]} */
const lines = [];
for (const [cx, cy] of [[0, 0], [1, 0], [0, 1], [1, 1]]) {
const x = r.left + cx * r.width;
const y = r.top + cy * r.height;
lines.push([[x, y + (cy ? -mark : mark)], [x, y], [x + (cx ? -mark : mark), y]]);
}
strokeCrisp(context, lines, color, width);
}
/**
* Draw a text block: a text rectangle's red frame, then its lines.
* @param {CanvasRenderingContext2D} context * @param {CanvasRenderingContext2D} context
* @param {TextBlock} block * @param {TextBlock} block
*/ */
function drawText(context, block) { function drawText(context, block) {
if (block.height === null) {
// Free text: an L at its top-left corner.
strokeCrisp(context, [[[block.x, block.y + TEXT_MARK], [block.x, block.y], [block.x + TEXT_MARK, block.y]]],
TEXT_FRAME_COLOR, TEXT_FRAME_WIDTH);
} else {
const f = textBounds(block);
if (TEXT_FRAME_STYLE === 'rectangle') {
const r = f.left + f.width;
const b = f.top + f.height;
strokeCrisp(context, [[[f.left, f.top], [r, f.top], [r, b], [f.left, b], [f.left, f.top]]],
TEXT_FRAME_COLOR, TEXT_FRAME_WIDTH);
} else {
drawCornerMarks(context, f, TEXT_FRAME_COLOR, TEXT_FRAME_WIDTH);
}
}
context.font = fontOf(block.size, block.font); context.font = fontOf(block.size, block.font);
context.fillStyle = block.color; context.fillStyle = block.color;
context.textBaseline = 'top'; context.textBaseline = 'alphabetic';
// On whole device pixels, the lines a whole number of pixels apart, as
// the text field lays them out (TextEditor.layout): the same pixels in
// both, whatever the zoom (Andy, 2026-10-09).
const t = context.getTransform();
const x = Math.round(t.a * (block.x + block.size * LEFT_GAP) + t.e);
const first = Math.round(t.d * (block.y + baselineOf(block.size, block.font, 0)) + t.f);
const spacing = Math.round(t.d * block.size * LINE_HEIGHT);
block.lines.forEach((line, i) => { block.lines.forEach((line, i) => {
context.fillText(line, block.x + block.size * LEFT_GAP, context.fillText(line, (x - t.e) / t.a, (first + i * spacing - t.f) / t.d);
block.y + block.size * (TOP_GAP + i * LINE_HEIGHT));
}); });
} }
@@ -93,16 +203,31 @@ export class Renderer {
/** Region of the tail canvas in use, in page units, or null. */ /** Region of the tail canvas in use, in page units, or null. */
this.tailRegion = /** @type {Region | null} */ (null); this.tailRegion = /** @type {Region | null} */ (null);
/** The white grounds of text rectangles: above the images, beneath every layer. */
this.groundCanvas = this.makeCanvas();
this.groundCanvas.classList.add('grounds');
this.pageElement.append(this.groundCanvas);
this.tailCanvas = this.makeCanvas(); this.tailCanvas = this.makeCanvas();
this.tailCanvas.classList.add('tails'); this.tailCanvas.classList.add('tails');
this.pageElement.append(this.tailCanvas); this.pageElement.append(this.tailCanvas);
/** The Hide rectangles, above all writing, with their blue corners. */
this.hideCanvas = this.makeCanvas();
this.hideCanvas.classList.add('hides');
this.pageElement.append(this.hideCanvas);
/** /**
* The elements that show the page's images beneath the layers, one for * The elements that show the page's images beneath the layers, one for
* each image whose bytes are here, and the bytes each one shows. * each image, in the images' sequence, and the bytes each one shows.
* @type {{element: HTMLImageElement, data: Uint8Array | null}[]} * @type {{element: HTMLImageElement, data: Uint8Array | null, hash: string}[]}
*/ */
this.imageElements = []; this.imageElements = [];
/** The green cue around an image being handled by hand. */
this.grip = document.createElement('div');
this.grip.className = 'image_grip';
this.grip.hidden = true;
this.pageElement.append(this.grip);
/** @type {{rect: import('./page_model.js').Rectangle, corner: [number, number] | null} | null} */
this.gripShown = null;
/** Canvas pixels per page unit. */ /** Canvas pixels per page unit. */
this.scale = 1; this.scale = 1;
@@ -113,14 +238,10 @@ export class Renderer {
this.zoom = 'fit'; this.zoom = 'fit';
/** CSS pixels per page unit as last shown. */ /** CSS pixels per page unit as last shown. */
this.cssPixelsPerUnit = 1; this.cssPixelsPerUnit = 1;
/**
* True while the user is drawing a stroke. At "Fit" the page is not
* refitted then: if it were, writing past an edge would move the edge
* away from the pen with every step of growth.
*/
this.holdFit = false;
/** @type {(() => void) | null} Told when the zoom changes. */ /** @type {(() => void) | null} Told when the zoom changes. */
this.onZoomChanged = null; this.onZoomChanged = null;
/** A zoom by pen drag in progress: the scale it started from and its anchor. */
this.zoomDrag = /** @type {{zoom: number, clientX: number, clientY: number} | null} */ (null);
/** /**
* True until a page that its images have made larger than a sheet has * True until a page that its images have made larger than a sheet has
* been shown for the first time. Such a page is a series of exercises * been shown for the first time. Such a page is a series of exercises
@@ -137,9 +258,10 @@ export class Renderer {
this.editingText = null; this.editingText = null;
this.setPage(page); this.setPage(page);
// Ctrl and the scroll wheel zoom about the pointer, as elsewhere. // Ctrl (Command on a Mac) and the scroll wheel zoom about the
// pointer, as elsewhere.
this.desk().addEventListener('wheel', (event) => { this.desk().addEventListener('wheel', (event) => {
if (!event.ctrlKey) return; if (!(event.ctrlKey || event.metaKey)) return;
event.preventDefault(); event.preventDefault();
this.zoomBy(event.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP, event.clientX, event.clientY); this.zoomBy(event.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP, event.clientX, event.clientY);
}, { passive: false }); }, { passive: false });
@@ -150,6 +272,63 @@ export class Renderer {
return /** @type {HTMLElement} */ (this.pageElement.parentElement); return /** @type {HTMLElement} */ (this.pageElement.parentElement);
} }
/**
* The room the page can be shown in, in CSS pixels.
* @returns {{width: number, height: number}}
*/
available() {
const desk = this.desk();
const style = getComputedStyle(desk);
// offsetWidth, not clientWidth: a fitted page needs no scrollbars, so
// the room they take while zoomed in must not count.
return {
width: desk.offsetWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight),
height: desk.offsetHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom),
};
}
/**
* Show the page at the scale a new page has: that at which one A4
* sheet fits the window ("A4", Andy, 2026-10-08, so that a shrunk page
* is seen at its size on the sheet rather than blown up by "Fit").
*/
sheetScale() {
const { width, height } = this.available();
if (!(width > 0) || !(height > 0)) return;
this.setZoom(1 / Math.max(BLANK_PAGE_WIDTH / width, BLANK_PAGE_HEIGHT / height));
}
/** Show the whole page in the window. */
fit() {
this.setZoom('fit');
this.desk().scrollTop = 0;
this.desk().scrollLeft = 0;
}
/**
* A zoom by pen drag begins at this point, which stays under the pen.
* @param {number} clientX
* @param {number} clientY
*/
beginZoomDrag(clientX, clientY) {
this.zoomDrag = { zoom: this.cssPixelsPerUnit, clientX, clientY };
}
/**
* The pen of a zoom drag has moved this far up from where it began
* (negative: down). ZOOM_PER_100_PIXELS per 100 pixels, continuously.
* @param {number} pixelsUp
*/
zoomDragTo(pixelsUp) {
const drag = this.zoomDrag;
if (!drag) return;
this.setZoom(drag.zoom * ZOOM_PER_100_PIXELS ** (pixelsUp / 100), drag.clientX, drag.clientY);
}
endZoomDrag() {
this.zoomDrag = null;
}
/** /**
* Zoom in or out by a factor, from whatever the zoom is now. * Zoom in or out by a factor, from whatever the zoom is now.
* @param {number} factor * @param {number} factor
@@ -208,6 +387,7 @@ export class Renderer {
this.canvases.clear(); this.canvases.clear();
this.hidden.clear(); this.hidden.clear();
this.active.clear(); this.active.clear();
this.gripShown = null;
this.showImages(); this.showImages();
this.resize(); this.resize();
// Once more after the browser has laid the page out, in case anything // Once more after the browser has laid the page out, in case anything
@@ -249,18 +429,6 @@ export class Renderer {
this.onZoomChanged?.(); this.onZoomChanged?.();
} }
/**
* Keep the present scale from now on, if the page is being fitted to the
* window. Called when an image is added or taken back: the page then
* extends beyond the window, to be scrolled to, and what is in view
* stays as it is (Andy, 2026-10-05).
*/
holdScale() {
if (this.zoom !== 'fit' || this.shownLeft === null) return;
this.zoom = this.cssPixelsPerUnit;
this.onZoomChanged?.();
}
/** Place the image elements where the images lie on the page. */ /** Place the image elements where the images lie on the page. */
layoutImages() { layoutImages() {
const page = this.page; const page = this.page;
@@ -275,26 +443,27 @@ export class Renderer {
} }
/** /**
* Show the page's images whose bytes are here. An element that already * Show the page's images whose bytes are here, in their sequence (the
* shows the right bytes is left alone, so that adding an image does not * last in front). An element that already shows an image keeps it when
* make the others flicker. * the images are reordered, so that nothing flickers.
*/ */
showImages() { showImages() {
const images = this.page.images; const images = this.page.images;
for (const [i, image] of images.entries()) { const spare = this.imageElements.slice();
let shown = this.imageElements[i]; /** @type {typeof this.imageElements} */
const shownNow = [];
for (const image of images) {
const at = spare.findIndex((one) => one.hash === image.hash);
let shown = at >= 0 ? spare.splice(at, 1)[0] : null;
if (!shown) { if (!shown) {
const element = document.createElement('img'); const element = document.createElement('img');
element.className = 'page_image'; element.className = 'page_image';
element.alt = ''; element.alt = '';
element.draggable = false; element.draggable = false;
element.hidden = true; element.hidden = true;
// Beneath the layers, in the sequence of the images. shown = { element, data: null, hash: image.hash };
const before = this.imageElements[i - 1]?.element.nextSibling ?? this.pageElement.firstChild;
this.pageElement.insertBefore(element, before);
shown = this.imageElements[i] = { element, data: null };
} }
if (shown.data === image.data) continue; if (shown.data !== image.data) {
if (shown.element.src) URL.revokeObjectURL(shown.element.src); if (shown.element.src) URL.revokeObjectURL(shown.element.src);
shown.data = image.data; shown.data = image.data;
shown.element.hidden = !image.data; shown.element.hidden = !image.data;
@@ -304,13 +473,52 @@ export class Renderer {
shown.element.removeAttribute('src'); shown.element.removeAttribute('src');
} }
} }
for (const gone of this.imageElements.splice(images.length)) { shownNow.push(shown);
}
for (const gone of spare) {
if (gone.element.src) URL.revokeObjectURL(gone.element.src); if (gone.element.src) URL.revokeObjectURL(gone.element.src);
gone.element.remove(); gone.element.remove();
} }
// Beneath the layers, in the sequence of the images.
const firstLayer = this.pageElement.querySelector('canvas');
for (const shown of shownNow) this.pageElement.insertBefore(shown.element, firstLayer);
this.imageElements = shownNow;
this.layoutImages(); this.layoutImages();
} }
/**
* Show, or hide, the cue around an image being handled: its outline,
* and the corner being dragged if it is being scaled.
* @param {import('./page_model.js').Rectangle | null} rect Page units.
* @param {[number, number] | null} [corner]
*/
showGrip(rect, corner = null) {
this.gripShown = rect ? { rect, corner } : null;
this.layoutGrip();
}
layoutGrip() {
const shown = this.gripShown;
this.grip.hidden = !shown;
if (!shown) return;
const page = this.page;
const style = this.grip.style;
style.left = `${(shown.rect.left - page.left) / page.width * 100}%`;
style.top = `${(shown.rect.top - page.top) / page.height * 100}%`;
style.width = `${shown.rect.width / page.width * 100}%`;
style.height = `${shown.rect.height / page.height * 100}%`;
this.grip.classList.toggle('corner', shown.corner !== null);
if (shown.corner) {
style.setProperty('--corner-x', shown.corner[0] ? '100%' : '0%');
style.setProperty('--corner-y', shown.corner[1] ? '100%' : '0%');
}
}
/** @returns {number} CORNER_REACH_PX in page units at the present zoom. */
cornerReach() {
return CORNER_REACH_PX / this.cssPixelsPerUnit;
}
makeCanvas() { makeCanvas() {
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.className = 'layer'; canvas.className = 'layer';
@@ -341,18 +549,13 @@ export class Renderer {
* redraw. Called when the window changes, when the page grows and when * redraw. Called when the window changes, when the page grows and when
* the zoom changes. * the zoom changes.
* *
* At "Fit", the whole page is shown within the window, and refitted * A zoom of 'fit' (a page shown for the first time, or "Fit" pressed)
* when the page grows, except while the user is drawing (see holdFit). * becomes the scale at which the whole page fits the window, and stays
* a number from then on: growth extends the page beyond the window.
*/ */
resize() { resize() {
const desk = this.desk(); const desk = this.desk();
const deskStyle = getComputedStyle(desk); const { width: availableWidth, height: availableHeight } = this.available();
// offsetWidth, not clientWidth: a fitted page needs no scrollbars, so
// the room they take while zoomed in must not count.
const availableWidth = desk.offsetWidth -
parseFloat(deskStyle.paddingLeft) - parseFloat(deskStyle.paddingRight);
const availableHeight = desk.offsetHeight -
parseFloat(deskStyle.paddingTop) - parseFloat(deskStyle.paddingBottom);
if (!(availableWidth > 0) || !(availableHeight > 0)) return; if (!(availableWidth > 0) || !(availableHeight > 0)) return;
const first = this.zoom === 'fit' && this.sheetFirst; const first = this.zoom === 'fit' && this.sheetFirst;
this.sheetFirst = false; this.sheetFirst = false;
@@ -360,14 +563,16 @@ export class Renderer {
this.zoom = Math.max(LEAST_CSS_PIXELS_PER_UNIT, Math.min(MOST_CSS_PIXELS_PER_UNIT, this.zoom = Math.max(LEAST_CSS_PIXELS_PER_UNIT, Math.min(MOST_CSS_PIXELS_PER_UNIT,
1 / Math.max(BLANK_PAGE_WIDTH / availableWidth, BLANK_PAGE_HEIGHT / availableHeight))); 1 / Math.max(BLANK_PAGE_WIDTH / availableWidth, BLANK_PAGE_HEIGHT / availableHeight)));
} }
let unitsPerCssPixel; const fitted = this.zoom === 'fit';
if (this.zoom !== 'fit') unitsPerCssPixel = 1 / this.zoom; const unitsPerCssPixel = this.zoom === 'fit'
else if (this.holdFit && this.shownLeft !== null) unitsPerCssPixel = 1 / this.cssPixelsPerUnit; ? Math.max(this.page.width / availableWidth, this.page.height / availableHeight)
else unitsPerCssPixel = Math.max(this.page.width / availableWidth, this.page.height / availableHeight); : 1 / this.zoom;
this.cssPixelsPerUnit = 1 / unitsPerCssPixel; this.cssPixelsPerUnit = 1 / unitsPerCssPixel;
if (fitted) this.zoom = this.cssPixelsPerUnit;
this.pageElement.style.width = `${this.page.width / unitsPerCssPixel}px`; this.pageElement.style.width = `${this.page.width / unitsPerCssPixel}px`;
this.pageElement.style.height = `${this.page.height / unitsPerCssPixel}px`; this.pageElement.style.height = `${this.page.height / unitsPerCssPixel}px`;
this.layoutImages(); this.layoutImages();
this.layoutGrip();
// When the page grew to the left or upward, keep what is in view in view. // When the page grew to the left or upward, keep what is in view in view.
if (this.shownLeft !== null && this.page.left < this.shownLeft) { if (this.shownLeft !== null && this.page.left < this.shownLeft) {
desk.scrollLeft += (this.shownLeft - this.page.left) / unitsPerCssPixel; desk.scrollLeft += (this.shownLeft - this.page.left) / unitsPerCssPixel;
@@ -380,10 +585,14 @@ export class Renderer {
this.scale = (window.devicePixelRatio || 1) / unitsPerCssPixel; this.scale = (window.devicePixelRatio || 1) / unitsPerCssPixel;
this.sizeCanvas(this.tailCanvas); this.sizeCanvas(this.tailCanvas);
this.sizeCanvas(this.groundCanvas);
this.sizeCanvas(this.hideCanvas);
this.tailRegion = null; this.tailRegion = null;
for (const id of this.canvases.keys()) this.redrawLayer(id, true); for (const id of this.canvases.keys()) this.redrawLayer(id, true);
this.drawGrounds();
this.drawHides();
this.drawTails(); this.drawTails();
if (first) this.onZoomChanged?.(); if (first || fitted) this.onZoomChanged?.();
} }
/** /**
@@ -412,6 +621,7 @@ export class Renderer {
else this.hidden.add(id); else this.hidden.add(id);
const canvas = this.canvases.get(id); const canvas = this.canvases.get(id);
if (canvas) canvas.hidden = !visible; if (canvas) canvas.hidden = !visible;
this.drawGrounds();
this.drawTails(); this.drawTails();
} }
@@ -438,9 +648,40 @@ export class Renderer {
for (const block of layer.texts.values()) { for (const block of layer.texts.values()) {
if (block.id !== this.editingText) drawText(context, block); if (block.id !== this.editingText) drawText(context, block);
} }
this.drawGrounds();
this.drawTails(); this.drawTails();
} }
/**
* Draw the white grounds of every text rectangle of every layer shown
* here, beneath the layers: they hide the image, never anyone's writing.
*/
drawGrounds() {
const context = this.context(this.groundCanvas);
context.clearRect(this.page.left, this.page.top, this.page.width, this.page.height);
context.fillStyle = '#ffffff';
for (const [id, layer] of this.page.layers) {
if (this.hidden.has(id)) continue;
for (const block of layer.texts.values()) {
if (block.height === null || block.id === this.editingText) continue;
const b = textBounds(block);
context.fillRect(b.left, b.top, b.width, b.height);
}
}
}
/** Draw the Hide rectangles, white with blue corners, above everything. */
drawHides() {
const context = this.context(this.hideCanvas);
context.clearRect(this.page.left, this.page.top, this.page.width, this.page.height);
for (const hide of this.page.hides) {
context.fillStyle = '#ffffff';
context.fillRect(hide.x, hide.y, hide.width, hide.height);
drawCornerMarks(context, { left: hide.x, top: hide.y, width: hide.width, height: hide.height },
HIDE_CORNER_COLOR, HIDE_CORNER_WIDTH);
}
}
/** /**
* A stroke gained points or was finished: draw what is new. * A stroke gained points or was finished: draw what is new.
* @param {string} layerId * @param {string} layerId
+79
View File
@@ -0,0 +1,79 @@
// @ts-check
/**
* No selection of text that the stylesheet declares unselectable.
*
* Safari on iOS and iPadOS sometimes selected the save status at the
* toolbar's right end when a stroke began with an Apple Pencil on Andy's
* iPad (2026-10-10), no hand on the glass: WebKit may take a Pencil drag
* for a selection gesture, and the selection snaps to the nearest text.
* The prefixed -webkit-user-select: none made it rarer, not gone, so
* WebKit's gesture path does not fully respect the property. Two
* guards, both inert where the browser already does the right thing:
* a selection that is about to start in unselectable text is refused
* (selectstart), and one that WebKit makes there anyway is cleared the
* moment it appears (selectionchange). Each is logged, in the console
* once per place and in the record every time, as evidence of which
* path the browser took. Selections inside fields
* (the invitation, a text block being typed) are left alone: a field's
* own selection reaches document.getSelection() in some browsers as a
* range on the field's parent, which may be unselectable.
*/
import { log, trace } from './log.js';
/**
* @param {Node | null | undefined} node
* @returns {boolean} Whether the stylesheet forbids selecting it.
*/
function unselectable(node) {
const element = node instanceof Element ? node : node?.parentElement;
if (!element) return false;
const style = getComputedStyle(element);
const value = style.getPropertyValue('-webkit-user-select') ||
style.getPropertyValue('user-select');
return value === 'none';
}
/**
* @param {Node | null | undefined} node
* @returns {string} The element, for the log.
*/
function describe(node) {
const element = node instanceof Element ? node : node?.parentElement;
if (!element) return 'nothing';
const name = element.tagName.toLowerCase();
if (element.id) return `${name}#${element.id}`;
return element.className ? `${name}.${element.className}` : name;
}
/** Whether the selection belongs to a focused text field. */
function inField() {
const active = document.activeElement;
return active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement ||
(active instanceof HTMLElement && active.isContentEditable);
}
export function guardSelection() {
/** @type {Set<string>} */
const logged = new Set();
/** @param {string} what */
function once(what) {
if (logged.has(what)) return trace(what);
logged.add(what);
log(what);
}
document.addEventListener('selectstart', (event) => {
if (inField() || !(event.target instanceof Node) || !unselectable(event.target)) return;
event.preventDefault();
once(`Refused a selection starting in unselectable ${describe(event.target)}`);
});
document.addEventListener('selectionchange', () => {
const selection = document.getSelection();
if (!selection || selection.isCollapsed || inField()) return;
if (!unselectable(selection.anchorNode) && !unselectable(selection.focusNode)) return;
const where = describe(selection.anchorNode);
selection.removeAllRanges();
once(`Cleared a selection the browser made in unselectable ${where}`);
});
}
+330 -57
View File
@@ -13,7 +13,8 @@ import {
} from './crypto.js'; } from './crypto.js';
import { import {
MAX_STROKE_POINTS, POINT_SIZE, Page, appendPoints, cleanName, cleanPageDescriptor, MAX_STROKE_POINTS, POINT_SIZE, Page, appendPoints, cleanName, cleanPageDescriptor,
cleanStroke, cleanText, placeImage, pointCount, validId, validPoints, cleanStroke, cleanText, placeImage, pointCount, sheetHolding, validId, validPoints,
MAX_PAGE_HIDES,
} from './page_model.js'; } from './page_model.js';
import { RelayConnection } from './relay_connection.js'; import { RelayConnection } from './relay_connection.js';
@@ -49,6 +50,9 @@ import { RelayConnection } from './relay_connection.js';
* image was added or taken back, not if writing reached an edge. * image was added or taken back, not if writing reached an edge.
* @property {() => void} onPermissionsChanged * @property {() => void} onPermissionsChanged
* Who may start pages changed. * Who may start pages changed.
* @property {(command: 'fit' | 'sheet') => void} onViewCommand
* The host pressed "Fit" or "A4": everyone's view follows (Andy,
* 2026-10-08), each in their own window.
* @property {() => void} onParticipantsChanged * @property {() => void} onParticipantsChanged
* @property {(status: 'connecting' | 'connected' | 'failed', detail?: string) => void} onStatus * @property {(status: 'connecting' | 'connected' | 'failed', detail?: string) => void} onStatus
*/ */
@@ -71,6 +75,19 @@ const SNAPSHOT_CHUNK_CHARACTERS = 100000;
/** An image is sent in pieces of this many bytes. */ /** An image is sent in pieces of this many bytes. */
const IMAGE_CHUNK_BYTES = 96 * 1024; const IMAGE_CHUNK_BYTES = 96 * 1024;
/** While an image is dragged by hand, its new place is sent this often. */
const PAGE_SEND_INTERVAL_MS = 40;
/**
* The points of one's own stroke are sent at most this often: the first
* at once, the rest gathered (Andy, 2026-10-10, after the load test:
* one message per pointer event was 60 to 120 messages a second from
* each writer, and the relay's limit is the message rate). The delay
* others see is at most this, under what Andy felt as no lag. The
* writer's own screen is not delayed.
*/
export const POINT_SEND_INTERVAL_MS = 25;
export class Session { export class Session {
/** /**
* @param {SessionOptions} options * @param {SessionOptions} options
@@ -82,37 +99,14 @@ export class Session {
this.identity = options.identity; this.identity = options.identity;
this.hostId = options.hostId; this.hostId = options.hostId;
this.name = cleanName(options.name) || 'Unnamed'; this.name = cleanName(options.name) || 'Unnamed';
/** /** @type {ReturnType<typeof setTimeout> | null} A page descriptor waiting to be sent. */
* The image we have just added, which we may take back until anything this.pageSendTimer = null;
* else changes on the page. /** @type {{id: string, at: number, points: number[]} | null} Points of one's own stroke not yet sent. */
* @type {PageImage | null} this.pendingPoints = null;
*/ /** @type {ReturnType<typeof setTimeout> | null} */
this.removableImage = null; this.pointSendTimer = null;
// Every event that reports a change to the page ends that. this.lastPointSend = 0;
/** @type {SessionEvents} */ this.events = events;
this.events = {
...events,
onLayerReplaced: (layerId) => {
this.removableImage = null;
events.onLayerReplaced(layerId);
},
onStrokeProgress: (layerId, stroke) => {
this.removableImage = null;
events.onStrokeProgress(layerId, stroke);
},
onTextChanged: (layerId, block) => {
this.removableImage = null;
events.onTextChanged(layerId, block);
},
onPageReplaced: (oldPage, newPage) => {
this.removableImage = null;
events.onPageReplaced(oldPage, newPage);
},
onImagesChanged: () => {
this.removableImage = null;
events.onImagesChanged();
},
};
this.isHost = this.identity.id === this.hostId; this.isHost = this.identity.id === this.hostId;
/** @type {Set<string>} Guests the host allows to start pages. */ /** @type {Set<string>} Guests the host allows to start pages. */
@@ -216,6 +210,15 @@ export class Session {
return this.isHost || this.permitted.has(this.identity.id); return this.isHost || this.permitted.has(this.identity.id);
} }
/**
* The host's "Fit" or "A4" goes to everyone. Only the host's does.
* @param {'fit' | 'sheet'} command
*/
sendViewCommand(command) {
if (!this.isHost) return;
this.send(0, { type: 'view', command });
}
/** /**
* Allow a guest to start pages, or stop allowing it. Only the host may. * Allow a guest to start pages, or stop allowing it. Only the host may.
* @param {string} id * @param {string} id
@@ -258,48 +261,233 @@ export class Session {
* @param {string} prepared.hash * @param {string} prepared.hash
* @param {number} prepared.naturalWidth In page units. * @param {number} prepared.naturalWidth In page units.
* @param {number} prepared.naturalHeight * @param {number} prepared.naturalHeight
* @param {boolean} [shrink] Whether an image wider than the sheet is
* scaled down to it; otherwise it keeps its size and widens the sheet.
* @returns {boolean} False if the page has no room for it. * @returns {boolean} False if the page has no room for it.
*/ */
addImage(prepared, shrink = true) { addImage(prepared) {
const page = this.page; const page = this.page;
if (!this.mayMakePages() || page.number === 0) return false; if (!this.mayMakePages() || page.number === 0) return false;
const place = placeImage(prepared.naturalWidth, prepared.naturalHeight, page.imagesBottom(), shrink); const place = placeImage(page, prepared.naturalWidth, prepared.naturalHeight);
if (!page.hasRoomForImage(prepared.data.length, place.y + place.height)) return false; if (!page.hasRoomForImage(prepared.data.length, place.y + place.height)) return false;
/** @type {PageImage} */ /** @type {PageImage} */
const image = { const image = {
type: prepared.type, size: prepared.data.length, hash: prepared.hash, data: prepared.data, type: prepared.type, size: prepared.data.length, hash: prepared.hash, data: prepared.data,
x: place.x, y: place.y, width: place.width, height: place.height, x: place.x, y: place.y, width: place.width, height: place.height, by: this.identity.id,
}; };
page.images.push(image); page.images.push(image);
page.imagesRevision += 1; page.imagesRevision += 1;
page.baseWidth = Math.max(page.baseWidth, place.pageWidth); page.baseLeft = place.sheet.left;
page.baseHeight = Math.max(page.baseHeight, place.pageHeight); page.baseTop = place.sheet.top;
page.baseWidth = place.sheet.width;
page.baseHeight = place.sheet.height;
this.send(0, { type: 'page', page: page.descriptor() }); this.send(0, { type: 'page', page: page.descriptor() });
this.sendImage(0, image); this.sendImage(0, image);
this.events.onImagesChanged(); this.events.onImagesChanged();
this.removableImage = image; this.extentChanged(true);
return true;
}
// Hide rectangles: page objects like images, for those who may make pages.
/**
* Hide part of the page for everyone.
* @param {import('./page_model.js').Rectangle} rect
* @returns {boolean} Whether a Hide rectangle was made.
*/
addHide(rect) {
const page = this.page;
if (!this.mayMakePages() || page.number === 0 || page.hides.length >= MAX_PAGE_HIDES) return false;
page.hides.push({
id: randomId(), x: Math.round(rect.left), y: Math.round(rect.top),
width: Math.round(rect.width), height: Math.round(rect.height),
});
this.sheetAround(rect);
page.imagesRevision += 1;
this.sendPageNow();
this.events.onImagesChanged();
this.extentChanged(true); this.extentChanged(true);
return true; return true;
} }
/** /**
* Take back the image we have just added: the remedy for having chosen * Give a Hide rectangle a new rectangle: moved or resized by hand.
* the wrong file. It works only until anything else changes on the * @param {number} index
* page (Andy, 2026-10-05), so nothing can have been written on the image, * @param {import('./page_model.js').Rectangle} rect
* and since it is the lowest image, no other image is affected.
* @returns {boolean} Whether an image was taken back.
*/ */
removeAddedImage() { placeHideByHand(index, rect) {
const page = this.page; const page = this.page;
const image = this.removableImage; const hide = page.hides[index];
if (!image || page.images.at(-1) !== image) return false; if (!this.mayMakePages() || !hide) return;
page.images.pop(); hide.x = Math.round(rect.left);
hide.y = Math.round(rect.top);
hide.width = Math.round(rect.width);
hide.height = Math.round(rect.height);
this.sheetAround(rect);
this.imagesChangedByHand();
}
/**
* Remove a Hide rectangle: the eraser tapped on it.
* @param {number} index
* @returns {boolean}
*/
removeHide(index) {
const page = this.page;
if (!this.mayMakePages() || !page.hides[index]) return false;
page.hides.splice(index, 1);
page.imagesRevision += 1;
this.sendPageNow();
this.events.onImagesChanged();
this.extentChanged(true);
return true;
}
/**
* Let the sheet hold a rectangle with the image margin around it.
* @param {import('./page_model.js').Rectangle} rect
*/
sheetAround(rect) {
const page = this.page;
const sheet = sheetHolding(page, rect);
page.baseLeft = sheet.left;
page.baseTop = sheet.top;
page.baseWidth = sheet.width;
page.baseHeight = sheet.height;
}
/**
* Bring an image to the front, among the images.
* @param {number} index
* @returns {number} Its index now.
*/
raiseImage(index) {
const page = this.page;
if (!this.mayMakePages() || index < 0 || index >= page.images.length) return index;
if (index !== page.images.length - 1) {
const [image] = page.images.splice(index, 1);
page.images.push(image);
this.imagesChangedByHand();
}
return page.images.length - 1;
}
/**
* Give an image a new rectangle: moved or scaled by hand (Andy's
* teacher, 2026-10-08). Writing stays where it is. The image comes
* to the front, the sheet grows to hold it, and everyone sees it move.
* @param {number} index
* @param {import('./page_model.js').Rectangle} rect
* @returns {number} The image's index now.
*/
placeImageByHand(index, rect) {
const page = this.page;
if (!this.mayMakePages() || index < 0 || index >= page.images.length) return index;
index = this.raiseImage(index);
const image = page.images[index];
image.x = Math.round(rect.left);
image.y = Math.round(rect.top);
image.width = Math.round(rect.width);
image.height = Math.round(rect.height);
this.sheetAround({ left: image.x, top: image.y, width: image.width, height: image.height });
this.imagesChangedByHand();
return index;
}
/** An image was raised, moved or scaled: tell everyone, soon. */
imagesChangedByHand() {
const page = this.page;
page.imagesRevision += 1;
this.sendPageSoon();
this.events.onImagesChanged();
this.extentChanged(true);
}
/**
* Send the page descriptor, at most every few tens of milliseconds
* while an image is being dragged; sendPageNow() at the end.
*/
sendPageSoon() {
if (this.pageSendTimer !== null) return;
this.pageSendTimer = setTimeout(() => {
this.pageSendTimer = null;
this.send(0, { type: 'page', page: this.page.descriptor() });
}, PAGE_SEND_INTERVAL_MS);
}
sendPageNow() {
if (this.pageSendTimer !== null) {
clearTimeout(this.pageSendTimer);
this.pageSendTimer = null;
}
this.send(0, { type: 'page', page: this.page.descriptor() });
}
/**
* Make the sheet wider, for more room to write (Andy, 2026-10-08:
* "Widen" enlarges the page rather than zooming). Everyone's page
* widens; it is a change to the page, so it needs the right to make
* pages, and it ends the chance to take back an image.
* @param {number} width The page's new width, if wider than now.
* @returns {boolean} Whether the page widened.
*/
widenPage(width) {
const page = this.page;
const added = Math.round(width) - page.width;
if (!this.mayMakePages() || page.number === 0 || added <= 0) return false;
// Equally on both sides of the PAGE as it is, writing included (the
// sheet may lie inside a page that writing has grown; measured from
// the sheet, the page came out short, Andy, 2026-10-08): what is on
// the page stays where it is seen. The same whole number of units on
// each side, since the page is centred on the screen: an odd growth
// split 1199 and 1200 moved the page's centre half a unit, a fraction
// of a pixel that showed as a one-pixel move on Olion (2026-10-09).
const half = Math.floor(added / 2);
if (half === 0) return false;
page.baseLeft = page.left - half;
page.baseWidth = page.width + 2 * half;
page.imagesRevision += 1; page.imagesRevision += 1;
page.baseWidth = page.sheetWidth();
page.baseHeight = page.sheetHeight();
this.send(0, { type: 'page', page: page.descriptor() }); this.send(0, { type: 'page', page: page.descriptor() });
this.extentChanged(true);
return true;
}
/**
* Make the sheet the smallest that holds everything on the page with
* the margin around it ("Shrink", Andy, 2026-10-08). A change to the
* page like "Widen".
* @returns {boolean} Whether the sheet changed.
*/
shrinkPage() {
const page = this.page;
if (!this.mayMakePages() || page.number === 0) return false;
const sheet = page.contentSheet();
if (!sheet || (sheet.left === page.baseLeft && sheet.top === page.baseTop &&
sheet.width === page.baseWidth && sheet.height === page.baseHeight)) return false;
page.baseLeft = sheet.left;
page.baseTop = sheet.top;
page.baseWidth = sheet.width;
page.baseHeight = sheet.height;
page.imagesRevision += 1;
this.send(0, { type: 'page', page: page.descriptor() });
this.extentChanged(true);
return true;
}
/**
* Remove an image: the eraser tapped on it (Andy, 2026-10-08: "the user
* has decided that the image should be deleted"). Allowed to whoever
* may make pages, and to whoever added the image. Writing on it stays
* where it is; the sheet keeps its size ("Shrink" trims it).
* @param {number} index
* @returns {boolean} Whether it was removed.
*/
removeImage(index) {
const page = this.page;
const image = page.images[index];
if (!image || !(this.mayMakePages() || image.by === this.identity.id)) return false;
page.images.splice(index, 1);
page.imagesRevision += 1;
this.imageTransfers.delete(image.hash);
this.sendPageNow();
this.events.onImagesChanged(); this.events.onImagesChanged();
this.extentChanged(true); this.extentChanged(true);
return true; return true;
@@ -314,7 +502,10 @@ export class Session {
loadPage(kept) { loadPage(kept) {
if (!this.mayMakePages()) return; if (!this.mayMakePages()) return;
const page = new Page(kept.baseWidth, kept.baseHeight, randomId(), this.page.number + 1); const page = new Page(kept.baseWidth, kept.baseHeight, randomId(), this.page.number + 1);
page.baseLeft = kept.baseLeft;
page.baseTop = kept.baseTop;
page.images = kept.images.map((image) => ({ ...image })); page.images = kept.images.map((image) => ({ ...image }));
page.hides = kept.hides.map((hide) => ({ ...hide }));
page.imagesRevision = kept.imagesRevision; page.imagesRevision = kept.imagesRevision;
for (const [id, layer] of kept.layers) { for (const [id, layer] of kept.layers) {
const copy = page.layer(id, layer.name); const copy = page.layer(id, layer.name);
@@ -460,6 +651,38 @@ export class Session {
this.pageSourcePeer = peer; this.pageSourcePeer = peer;
} }
/**
* Whether a page descriptor from a participant without the right to
* make pages is one they may still send: our page, the sheet as it is,
* and the images ours with only images they added left out (Andy,
* 2026-10-08: whoever added an image may remove it, right or no right).
* @param {any} received
* @param {string} id The sender.
* @returns {boolean}
*/
removesOnlyOwnImages(received, id) {
const descriptor = cleanPageDescriptor(received);
const page = this.page;
if (!descriptor || descriptor.id !== page.id || descriptor.left !== page.baseLeft ||
descriptor.top !== page.baseTop || descriptor.width !== page.baseWidth ||
descriptor.height !== page.baseHeight ||
JSON.stringify(descriptor.hides) !== JSON.stringify(page.hides)) return false;
/** @param {Omit<PageImage, 'data'>} image */
const key = (image) => `${image.hash} ${image.x} ${image.y} ${image.width} ${image.height} ${image.by}`;
let at = 0;
let removed = 0;
for (const ours of page.images) {
if (at < descriptor.images.length && key(descriptor.images[at]) === key(ours)) {
at += 1;
} else if (ours.by === id) {
removed += 1;
} else {
return false;
}
}
return at === descriptor.images.length && removed > 0;
}
/** /**
* Another participant's list of the images on the page we are on. The * Another participant's list of the images on the page we are on. The
* list with the higher revision is the later one. Of two different * list with the higher revision is the later one. Of two different
@@ -470,11 +693,13 @@ export class Session {
*/ */
considerImages(descriptor) { considerImages(descriptor) {
const page = this.page; const page = this.page;
/** @param {Omit<PageImage, 'data'>[]} images */ /** @param {import('./page_model.js').PageDescriptor} d */
const listed = (images) => images.map((image) => const listed = (d) => d.images.map((image) =>
`${image.hash} ${image.x} ${image.y} ${image.width} ${image.height}`).join(','); `${image.hash} ${image.x} ${image.y} ${image.width} ${image.height}`).join(',') +
const ours = listed(page.images); '|' + d.hides.map((hide) => `${hide.id} ${hide.x} ${hide.y} ${hide.width} ${hide.height}`).join(',') +
const theirs = listed(descriptor.images); `|${d.left} ${d.top} ${d.width} ${d.height}`;
const ours = listed(page.descriptor());
const theirs = listed(descriptor);
if (descriptor.revision < page.imagesRevision || if (descriptor.revision < page.imagesRevision ||
(descriptor.revision === page.imagesRevision && theirs <= ours)) return; (descriptor.revision === page.imagesRevision && theirs <= ours)) return;
const held = new Map(); const held = new Map();
@@ -482,7 +707,10 @@ export class Session {
if (image.data) held.set(image.hash, image.data); if (image.data) held.set(image.hash, image.data);
} }
page.images = descriptor.images.map((image) => ({ ...image, data: held.get(image.hash) ?? null })); page.images = descriptor.images.map((image) => ({ ...image, data: held.get(image.hash) ?? null }));
page.hides = descriptor.hides.map((hide) => ({ ...hide }));
page.imagesRevision = descriptor.revision; page.imagesRevision = descriptor.revision;
page.baseLeft = descriptor.left;
page.baseTop = descriptor.top;
page.baseWidth = descriptor.width; page.baseWidth = descriptor.width;
page.baseHeight = descriptor.height; page.baseHeight = descriptor.height;
// Bytes on their way for an image that is no longer wanted are dropped, // Bytes on their way for an image that is no longer wanted are dropped,
@@ -541,16 +769,54 @@ export class Session {
const at = pointCount(stroke); const at = pointCount(stroke);
appendPoints(stroke, at, points); appendPoints(stroke, at, points);
this.page.includeStroke(stroke, at); this.page.includeStroke(stroke, at);
this.send(0, { type: 'stroke_points', id: stroke.id, at, points }); this.queuePoints(stroke.id, at, points);
this.events.onStrokeProgress(this.identity.id, stroke); this.events.onStrokeProgress(this.identity.id, stroke);
this.extentChanged(); this.extentChanged();
} }
/**
* Points to send: at once if none went in the last interval, else with
* the next sending, which the timer makes when the interval is up.
* @param {string} id
* @param {number} at
* @param {number[]} points
*/
queuePoints(id, at, points) {
if (this.pendingPoints && this.pendingPoints.id === id) {
this.pendingPoints.points.push(...points);
} else {
this.sendPendingPoints();
this.pendingPoints = { id, at, points: points.slice() };
}
const since = performance.now() - this.lastPointSend;
if (since >= POINT_SEND_INTERVAL_MS) {
this.sendPendingPoints();
} else if (this.pointSendTimer === null) {
this.pointSendTimer = setTimeout(() => {
this.pointSendTimer = null;
this.sendPendingPoints();
}, POINT_SEND_INTERVAL_MS - since);
}
}
sendPendingPoints() {
if (this.pointSendTimer !== null) {
clearTimeout(this.pointSendTimer);
this.pointSendTimer = null;
}
const pending = this.pendingPoints;
if (!pending) return;
this.pendingPoints = null;
this.lastPointSend = performance.now();
this.send(0, { type: 'stroke_points', id: pending.id, at: pending.at, points: pending.points });
}
endStroke() { endStroke() {
const stroke = this.currentStroke; const stroke = this.currentStroke;
if (!stroke) return; if (!stroke) return;
this.currentStroke = null; this.currentStroke = null;
stroke.done = true; stroke.done = true;
this.sendPendingPoints();
this.send(0, { type: 'stroke_end', id: stroke.id }); this.send(0, { type: 'stroke_end', id: stroke.id });
this.events.onStrokeProgress(this.identity.id, stroke); this.events.onStrokeProgress(this.identity.id, stroke);
} }
@@ -756,12 +1022,19 @@ export class Session {
if (!id) return; if (!id) return;
if (message.type === 'page') { if (message.type === 'page') {
if (id === this.hostId || this.permitted.has(id)) this.considerPage(message.page, true, peer); if (id === this.hostId || this.permitted.has(id)) this.considerPage(message.page, true, peer);
else if (this.removesOnlyOwnImages(message.page, id)) this.considerPage(message.page, false, peer);
return; return;
} }
if (message.type === 'permissions') { if (message.type === 'permissions') {
if (id === this.hostId) this.receivedPermissions(message.ids); if (id === this.hostId) this.receivedPermissions(message.ids);
return; return;
} }
if (message.type === 'view') {
if (id === this.hostId && (message.command === 'fit' || message.command === 'sheet')) {
this.events.onViewCommand(message.command);
}
return;
}
if (message.type === 'image') { if (message.type === 'image') {
this.receivedImagePiece(message); this.receivedImagePiece(message);
return; return;
+164 -83
View File
@@ -1,15 +1,19 @@
// @ts-check // @ts-check
/** /**
* Editing a text block: a real text field laid over the page, so that * Editing a text block: a real text field laid over the page, so that
* umlauts, dead keys and selection work as they do everywhere, with * umlauts, dead keys and selection work as they do everywhere. Moving a
* handles for moving the block and changing its width. * block and resizing a text rectangle are done by a hold of the pen, as
* for images (main.js), not by handles here (2026-10-08).
* *
* While a block is being edited the renderer leaves it out, and the field * While a block is being edited the renderer leaves it out, and the field
* shows it; everyone else sees it drawn, updated at every keystroke. * shows it; everyone else sees it drawn, updated at every keystroke.
*/ */
import { randomId } from './crypto.js'; import { randomId } from './crypto.js';
import { LEFT_GAP, LINE_HEIGHT, TOP_GAP, layoutText } from './text_layout.js'; import { textBounds } from './page_model.js';
import { TEXT_FRAME_WIDTH, TEXT_MARK } from './page_file.js';
import { HOLD_MS, HOLD_SLACK_PX } from './pen_input.js';
import { LEFT_GAP, LINE_HEIGHT, TOP_GAP, baselineOf, fontMetrics, layoutText } from './text_layout.js';
/** @typedef {import('./page_model.js').TextBlock} TextBlock */ /** @typedef {import('./page_model.js').TextBlock} TextBlock */
/** @typedef {import('./page_model.js').TextFont} TextFont */ /** @typedef {import('./page_model.js').TextFont} TextFont */
@@ -18,6 +22,7 @@ import { LEFT_GAP, LINE_HEIGHT, TOP_GAP, layoutText } from './text_layout.js';
/** A block without a width is shown at least this wide, in page units. */ /** A block without a width is shown at least this wide, in page units. */
const LEAST_FIELD_WIDTH = 300; const LEAST_FIELD_WIDTH = 300;
export class TextEditor { export class TextEditor {
/** /**
* @param {HTMLElement} pageElement * @param {HTMLElement} pageElement
@@ -25,6 +30,13 @@ export class TextEditor {
* @param {object} target * @param {object} target
* @param {(block: TextBlock) => void} target.setText The block changed. * @param {(block: TextBlock) => void} target.setText The block changed.
* @param {(id: string) => void} target.removeText The block was left empty. * @param {(id: string) => void} target.removeText The block was left empty.
* @param {(clientX: number, clientY: number) => boolean} [target.beginHold]
* The pen rested on the field for HOLD_MS: a hold, as on the page
* (Andy, 2026-10-09: the box being typed in can be moved without
* stopping). Then the pen's movement goes to holdMove, and holdEnd
* when it lifts.
* @param {(clientX: number, clientY: number) => void} [target.holdMove]
* @param {() => void} [target.holdEnd]
*/ */
constructor(pageElement, renderer, target) { constructor(pageElement, renderer, target) {
this.pageElement = pageElement; this.pageElement = pageElement;
@@ -38,6 +50,7 @@ export class TextEditor {
this.field.spellcheck = false; this.field.spellcheck = false;
this.field.hidden = true; this.field.hidden = true;
this.field.addEventListener('input', () => this.changed()); this.field.addEventListener('input', () => this.changed());
this.watchForHold();
this.field.addEventListener('keydown', (event) => { this.field.addEventListener('keydown', (event) => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
event.preventDefault(); event.preventDefault();
@@ -45,95 +58,111 @@ export class TextEditor {
} }
}); });
this.corner = this.makeHandle('text_handle corner', 'Move the text'); /** Free text's corner mark while it is being edited (the renderer leaves the block out then). */
this.edge = this.makeHandle('text_edge', 'Move the text'); this.mark = document.createElement('div');
this.end = this.makeHandle('text_handle end', 'Change the width'); this.mark.className = 'text_mark';
/** Shows the top edge while it is being dragged out. */ this.mark.hidden = true;
/** Shows a rectangle while it is being dragged open. */
this.preview = document.createElement('div'); this.preview = document.createElement('div');
this.preview.className = 'text_preview'; this.preview.className = 'rect_preview';
this.preview.hidden = true; this.preview.hidden = true;
this.pageElement.append(this.field, this.edge, this.corner, this.end, this.preview); this.pageElement.append(this.field, this.mark, this.preview);
} }
/** /**
* Show where the top edge of a new block would run, from (x0, y0) to * A press that rests on the field becomes a hold that moves the block,
* (x1, y0), while the pointer is dragging. * as a hold on the page would; a press that moves at once selects text
* as in any field.
*/
watchForHold() {
const field = this.field;
/** @type {ReturnType<typeof setTimeout> | null} */
let timer = null;
let holding = false;
let pointer = -1;
let pressX = 0;
let pressY = 0;
const cancel = () => {
if (timer !== null) clearTimeout(timer);
timer = null;
};
field.addEventListener('pointerdown', (event) => {
if (!this.target.beginHold || (event.pointerType === 'mouse' && event.button !== 0)) return;
pointer = event.pointerId;
pressX = event.clientX;
pressY = event.clientY;
cancel();
timer = setTimeout(() => {
timer = null;
if (!this.target.beginHold?.(pressX, pressY)) return;
holding = true;
field.setPointerCapture(pointer);
}, HOLD_MS);
});
field.addEventListener('pointermove', (event) => {
if (event.pointerId !== pointer) return;
if (holding) {
event.preventDefault();
this.target.holdMove?.(event.clientX, event.clientY);
} else if (timer !== null && Math.hypot(event.clientX - pressX, event.clientY - pressY) > HOLD_SLACK_PX) {
cancel();
}
});
const finish = (/** @type {PointerEvent} */ event) => {
if (event.pointerId !== pointer) return;
cancel();
pointer = -1;
if (holding) {
holding = false;
this.target.holdEnd?.();
field.focus();
}
};
field.addEventListener('pointerup', finish);
field.addEventListener('pointercancel', finish);
}
/**
* Show the rectangle being dragged open, from (x0, y0) to (x1, y1).
* @param {number} x0 * @param {number} x0
* @param {number} y0 * @param {number} y0
* @param {number} x1 * @param {number} x1
* @param {number} y1
*/ */
showPreview(x0, y0, x1) { showPreview(x0, y0, x1, y1) {
const scale = this.renderer.cssPixelsPerUnit; const scale = this.renderer.cssPixelsPerUnit;
const page = this.renderer.page; const page = this.renderer.page;
this.preview.hidden = false; this.preview.hidden = false;
this.preview.style.left = `${(Math.min(x0, x1) - page.left) * scale}px`; this.preview.style.left = `${(Math.min(x0, x1) - page.left) * scale}px`;
this.preview.style.top = `${(y0 - page.top) * scale}px`; this.preview.style.top = `${(Math.min(y0, y1) - page.top) * scale}px`;
this.preview.style.width = `${Math.abs(x1 - x0) * scale}px`; this.preview.style.width = `${Math.abs(x1 - x0) * scale}px`;
this.preview.style.height = `${Math.abs(y1 - y0) * scale}px`;
} }
hidePreview() { hidePreview() {
this.preview.hidden = true; this.preview.hidden = true;
} }
/**
* @param {string} className
* @param {string} help
* @returns {HTMLElement}
*/
makeHandle(className, help) {
const handle = document.createElement('div');
handle.className = className;
handle.hidden = true;
handle.setAttribute('data-help', help);
const widening = className.includes('end');
handle.addEventListener('pointerdown', (event) => {
if (!this.block) return;
event.preventDefault();
event.stopPropagation();
handle.setPointerCapture(event.pointerId);
const [startX, startY] = this.renderer.pageFromClient(event.clientX, event.clientY);
const { x, y, width } = this.block;
const move = (/** @type {PointerEvent} */ e) => {
if (!this.block) return;
const [nowX, nowY] = this.renderer.pageFromClient(e.clientX, e.clientY);
if (widening) {
this.block.width = Math.max(100, Math.round((width ?? 0) + nowX - startX));
} else {
this.block.x = Math.round(x + nowX - startX);
this.block.y = Math.round(y + nowY - startY);
}
this.changed();
};
const finish = () => {
handle.removeEventListener('pointermove', move);
handle.removeEventListener('pointerup', finish);
handle.removeEventListener('pointercancel', finish);
this.field.focus();
};
handle.addEventListener('pointermove', move);
handle.addEventListener('pointerup', finish);
handle.addEventListener('pointercancel', finish);
});
return handle;
}
/** @returns {boolean} */ /** @returns {boolean} */
get editing() { get editing() {
return this.block !== null; return this.block !== null;
} }
/** /**
* Start a new block. * Start a new block: free text, or a text rectangle with a width and a
* height.
* @param {number} x * @param {number} x
* @param {number} y * @param {number} y
* @param {number | null} width * @param {number | null} width
* @param {number | null} height
* @param {{color: string, size: number, font: TextFont}} style * @param {{color: string, size: number, font: TextFont}} style
*/ */
begin(x, y, width, style) { begin(x, y, width, height, style) {
this.stop(); this.stop();
/** @type {TextBlock} */ /** @type {TextBlock} */
const block = { const block = {
id: randomId(), x: Math.round(x), y: Math.round(y), width, text: '', lines: [''], id: randomId(), x: Math.round(x), y: Math.round(y), width,
height: width === null ? null : height, text: '', lines: [''],
size: style.size, font: style.font, color: style.color, boxWidth: 0, boxHeight: 0, size: style.size, font: style.font, color: style.color, boxWidth: 0, boxHeight: 0,
}; };
Object.assign(block, layoutText('', width, style.size, style.font)); Object.assign(block, layoutText('', width, style.size, style.font));
@@ -163,6 +192,8 @@ export class TextEditor {
this.layout(); this.layout();
this.target.setText(block); // Redrawn without this block. this.target.setText(block); // Redrawn without this block.
this.field.focus(); this.field.focus();
// The cursor at the end of the text (Andy, 2026-10-09).
this.field.setSelectionRange(block.text.length, block.text.length);
} }
/** Finish editing. An empty block is removed. */ /** Finish editing. An empty block is removed. */
@@ -172,58 +203,108 @@ export class TextEditor {
this.block = null; this.block = null;
this.renderer.editingText = null; this.renderer.editingText = null;
this.field.hidden = true; this.field.hidden = true;
this.mark.hidden = true;
this.field.value = ''; this.field.value = '';
for (const handle of [this.corner, this.edge, this.end]) handle.hidden = true;
if (block.text.trim() === '') this.target.removeText(block.id); if (block.text.trim() === '') this.target.removeText(block.id);
else this.target.setText(block); else this.target.setText(block);
} }
/**
* The page was replaced under the editor: leave off without sending
* anything, since the block already reached the page it belonged to at
* every keystroke, and that page is being kept. Finishing normally
* would put the block on the new page.
* @returns {TextBlock | null} The block that was being edited.
*/
discard() {
const block = this.block;
if (!block) return null;
this.block = null;
this.renderer.editingText = null;
this.field.hidden = true;
this.mark.hidden = true;
this.field.value = '';
return block;
}
/** The text, position or width changed: lay it out and tell the page. */ /** The text, position or width changed: lay it out and tell the page. */
changed() { changed() {
const block = this.block; const block = this.block;
if (!block) return; if (!block) return;
block.text = this.field.value; block.text = this.field.value;
Object.assign(block, layoutText(block.text, block.width, block.size, block.font)); Object.assign(block, layoutText(block.text, block.width, block.size, block.font));
// A text rectangle grows downward by lines when the text needs more.
if (block.height !== null && block.boxHeight > block.height) block.height = Math.ceil(block.boxHeight);
this.layout(); this.layout();
this.target.setText(block); this.target.setText(block);
} }
/** Place the field and the handles over the block, at the present zoom. */ /** Place the field over the block, at the present zoom. */
layout() { layout() {
const block = this.block; const block = this.block;
if (!block) return; if (!block) return;
const scale = this.renderer.cssPixelsPerUnit; const scale = this.renderer.cssPixelsPerUnit;
const page = this.renderer.page; const page = this.renderer.page;
const left = (block.x - page.left) * scale; // Everything on whole device pixels, placed as the canvas places the
const top = (block.y - page.top) * scale; // drawn block (renderer.js drawText): the same pixels in both, so that
// nothing moves when editing ends (Andy, 2026-10-09).
const dpr = window.devicePixelRatio || 1;
/** @param {number} px CSS pixels. @returns {number} The nearest whole device pixel, in CSS pixels. */
const whole = (px) => Math.round(px * dpr) / dpr;
const left = whole((block.x - page.left) * scale);
const top = whole((block.y - page.top) * scale);
const isRect = block.height !== null;
const fieldWidth = (block.width ?? Math.max(LEAST_FIELD_WIDTH, block.boxWidth + block.size)) * scale; const fieldWidth = (block.width ?? Math.max(LEAST_FIELD_WIDTH, block.boxWidth + block.size)) * scale;
const style = this.field.style; const style = this.field.style;
this.field.classList.toggle('rect', isRect);
style.left = `${left}px`; style.left = `${left}px`;
style.top = `${top}px`; style.top = `${top}px`;
// A little wider than the block, so that the field never wraps a line // The text's left edge and its first baseline, where the canvas draws
// earlier than the layout did. // them; the lines a whole number of pixels apart.
style.width = `${fieldWidth - block.size * LEFT_GAP * scale + 2}px`; const textLeft = whole((block.x + block.size * LEFT_GAP - page.left) * scale);
style.paddingTop = `${block.size * TOP_GAP * scale}px`; const firstBaseline = whole((block.y + baselineOf(block.size, block.font, 0) - page.top) * scale);
style.paddingLeft = `${block.size * LEFT_GAP * scale}px`; const lineHeight = whole(block.size * LINE_HEIGHT * scale);
const { ascent, descent } = fontMetrics(block.size, block.font);
// In a line box the baseline lies half the leading and the ascent down.
const baselineInLine = (lineHeight - (ascent + descent) * scale) / 2 + ascent * scale;
// A box's field is exactly the box, on whole pixels, and draws the
// frame as an outline inside its edge, which takes no room, at the
// width the canvas draws it.
const frame = Math.max(1, Math.round(TEXT_FRAME_WIDTH * scale * dpr)) / dpr;
style.outlineWidth = isRect ? `${frame}px` : '0';
style.outlineOffset = `${-frame}px`;
const padTop = firstBaseline - top - baselineInLine;
style.paddingTop = `${padTop}px`;
style.paddingLeft = `${textLeft - left}px`;
// Room below the last line, or the field scrolls by a few pixels to
// keep the cursor in view and the text jumps.
const slack = (LINE_HEIGHT - 1) / 2 * block.size * scale + 1;
style.paddingBottom = `${slack}px`;
style.font = `${block.size * scale}px ${block.font}`; style.font = `${block.size * scale}px ${block.font}`;
style.lineHeight = String(LINE_HEIGHT); style.lineHeight = `${lineHeight}px`;
style.color = block.color; style.color = block.color;
style.whiteSpace = block.width === null ? 'pre' : 'pre-wrap'; style.whiteSpace = block.width === null ? 'pre' : 'pre-wrap';
// Exactly as tall as the lines: no spare line below the text. if (isRect) {
style.height = `${block.lines.length * block.size * LINE_HEIGHT * scale}px`; const bounds = textBounds(block);
// The canvas strokes the far lines on the pixel at the edge itself,
this.corner.hidden = false; // so the field reaches one pixel past it.
this.corner.style.left = `${left}px`; style.boxSizing = 'border-box';
this.corner.style.top = `${top}px`; style.width = `${whole((bounds.left + bounds.width - page.left) * scale) - left + 1 / dpr}px`;
const hasWidth = block.width !== null; style.height = `${Math.max(block.lines.length * lineHeight + padTop + slack,
this.edge.hidden = !hasWidth; whole((bounds.top + bounds.height - page.top) * scale) - top + 1 / dpr)}px`;
this.end.hidden = !hasWidth; } else {
if (hasWidth) { style.boxSizing = 'content-box';
this.edge.style.left = `${left}px`; // A little wider than the block, so that the field never wraps a
this.edge.style.top = `${top}px`; // line earlier than the layout did.
this.edge.style.width = `${fieldWidth}px`; style.width = `${fieldWidth - (textLeft - left) + 2}px`;
this.end.style.left = `${left + fieldWidth}px`; style.height = `${block.lines.length * lineHeight}px`;
this.end.style.top = `${top}px`;
} }
this.field.scrollTop = 0;
this.mark.hidden = isRect;
this.mark.style.left = `${left}px`;
this.mark.style.top = `${top}px`;
this.mark.style.width = `${whole(TEXT_MARK * scale)}px`;
this.mark.style.height = `${whole(TEXT_MARK * scale)}px`;
this.mark.style.borderWidth = `${Math.max(1, Math.round(TEXT_FRAME_WIDTH * scale * dpr)) / dpr}px`;
} }
} }
+39 -2
View File
@@ -14,9 +14,10 @@ export const LINE_HEIGHT = 1.25;
/** /**
* Room between a block's top edge and its first line, as a multiple of * Room between a block's top edge and its first line, as a multiple of
* the font size (Andy, 2026-10-03: the text should not sit on the edge). * the font size (Andy, 2026-10-03: the text should not sit on the edge;
* halved from 0.3 on 2026-10-09).
*/ */
export const TOP_GAP = 0.3; export const TOP_GAP = 0.15;
/** /**
* Room between a block's left edge (its corner square) and the text, as a * Room between a block's left edge (its corner square) and the text, as a
@@ -51,6 +52,42 @@ export function textWidth(text, size, family) {
return measurer.measureText(text).width; return measurer.measureText(text).width;
} }
/**
* Where the baseline of line `i` of a block lies below the block's top, in
* page units, placed as a browser places text in a line box: the gap, then
* half the leading (the line height less the font's own ascent and
* descent), then the ascent. The text field and the canvas then agree
* to the pixel (Andy, 2026-10-09: the text moved when editing ended).
* @param {number} size
* @param {string} family
* @param {number} i
* @returns {number}
*/
export function baselineOf(size, family, i) {
const { ascent, descent } = fontMetrics(size, family);
const halfLeading = (size * LINE_HEIGHT - ascent - descent) / 2;
return size * (TOP_GAP + i * LINE_HEIGHT) + halfLeading + ascent;
}
/**
* The font's ascent and descent, in page units, as the browser lays
* lines out with them.
* @param {number} size
* @param {string} family
* @returns {{ascent: number, descent: number}}
*/
export function fontMetrics(size, family) {
if (!measurer) {
measurer = /** @type {CanvasRenderingContext2D} */ (document.createElement('canvas').getContext('2d'));
}
measurer.font = fontOf(size, family);
const metrics = measurer.measureText('Mg');
return {
ascent: metrics.fontBoundingBoxAscent ?? size * 0.8,
descent: metrics.fontBoundingBoxDescent ?? size * 0.2,
};
}
/** /**
* Break one paragraph into lines no wider than `width`. Breaks come at * Break one paragraph into lines no wider than `width`. Breaks come at
* spaces; a word wider than the width is broken where it must be. * spaces; a word wider than the width is broken where it must be.
+10 -2
View File
@@ -9,7 +9,7 @@
* are covered too, since the listeners are on the document. * are covered too, since the listeners are on the document.
*/ */
const DELAY_MS = 1000; const DELAY_MS = 2000;
const GAP_PX = 6; const GAP_PX = 6;
/** @type {HTMLElement | null} */ /** @type {HTMLElement | null} */
@@ -66,8 +66,16 @@ export function enableTooltips() {
timer = setTimeout(() => { timer = null; show(element); }, DELAY_MS); timer = setTimeout(() => { timer = null; show(element); }, DELAY_MS);
}); });
document.addEventListener('pointerleave', hide); document.addEventListener('pointerleave', hide);
document.addEventListener('pointerdown', hide); // Focus that comes from a click brings no tooltip: the user is already
// acting on the element, and a list it opens would be covered (Andy,
// 2026-10-07, the notebook list). Focus from the keyboard does.
let pressedAt = -Infinity;
document.addEventListener('pointerdown', () => {
pressedAt = performance.now();
hide();
});
document.addEventListener('focusin', (event) => { document.addEventListener('focusin', (event) => {
if (performance.now() - pressedAt < 500) return;
const element = helpElement(event.target); const element = helpElement(event.target);
if (element) show(element); if (element) show(element);
}); });
+135 -70
View File
@@ -5,8 +5,7 @@
--panel: #f6f6f7; --panel: #f6f6f7;
--desk: #e4e4e7; --desk: #e4e4e7;
--accent: #1f4fd8; --accent: #1f4fd8;
--text_edge: #ababab; /* The top edge of a text block being edited: 67% grey. */ --text_frame: #e53935; /* A text rectangle's frame: the palette's red. */
--text_handle: #ababab; /* Its squares: the same. */
font-family: system-ui, sans-serif; font-family: system-ui, sans-serif;
font-size: 15px; font-size: 15px;
color: var(--ink); color: var(--ink);
@@ -20,6 +19,14 @@ html, body { height: 100%; margin: 0; }
button, input { font: inherit; color: inherit; } button, input { font: inherit; color: inherit; }
/* A drag across the controls selects nothing: their text is not for
copying (Andy, 2026-10-07). Fields stay selectable. Safari on iOS
and iPadOS knows only the prefixed property (MDN's compatibility
data, 2026-10-10); without it the Pencil's first movement sometimes
selected the save status on Andy's iPad. */
button, select, label, .toolbar, .panel h2, #participants, .chooser header
{ -webkit-user-select: none; user-select: none; }
button { button {
padding: 0.35rem 0.8rem; padding: 0.35rem 0.8rem;
border: 1px solid var(--line); border: 1px solid var(--line);
@@ -35,12 +42,14 @@ button[aria-pressed="true"] {
box-shadow: inset 0 0 0 1px var(--accent); box-shadow: inset 0 0 0 1px var(--accent);
} }
input[type="text"] { input[type="text"], select.notebook_pick {
width: 100%; width: 100%;
padding: 0.4rem 0.5rem; padding: 0.4rem 0.5rem;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 6px; border-radius: 6px;
background: white; background: white;
font: inherit;
color: inherit;
} }
h2 { h2 {
@@ -80,15 +89,29 @@ h2 {
height: 100%; height: 100%;
} }
/* One row, holding everything (Andy's teacher, 2026-10-08: the whole
width of the window belongs to the page); it wraps only when the window
is too narrow for it. */
.toolbar { .toolbar {
display: flex; display: flex;
flex-wrap: wrap;
align-items: center; align-items: center;
gap: 1.25rem; gap: 0.5rem 0.55rem;
padding: 0.5rem 0.75rem; padding: 0.4rem 0.5rem;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
background: var(--panel); background: var(--panel);
} }
/* When the window is too narrow for one row, the tools are centred
instead of standing at the two ends (Andy, 2026-10-09; main.js sets
the class by measuring). */
.toolbar.wrapped { justify-content: center; }
.toolbar.wrapped .toolbar_right { margin-left: 0; justify-content: center; }
.toolbar .notebook_pick { width: 9rem; }
.toolbar .toggle { display: flex; align-items: center; gap: 0.3rem; cursor: pointer; }
.toolbar .toggle input { margin: 0; }
.group { display: flex; align-items: center; gap: 0.35rem; } .group { display: flex; align-items: center; gap: 0.35rem; }
.swatch { .swatch {
@@ -100,13 +123,16 @@ h2 {
background-clip: content-box; background-clip: content-box;
} }
/* The same height as the buttons, so the toolbar does not change height. */ /* The same height as the buttons, so the toolbar does not change height;
narrow enough that a host's toolbar fits a 1920-pixel screen in one
row (measured 2026-10-09: about 1750 pixels with these settings). */
.toolbar button:not(.swatch), #text_size, #text_font { .toolbar button:not(.swatch), #text_size, #text_font {
box-sizing: border-box; box-sizing: border-box;
height: 2rem; height: 2rem;
padding-top: 0; padding-top: 0;
padding-bottom: 0; padding-bottom: 0;
} }
.toolbar button:not(.swatch) { padding-left: 0.6rem; padding-right: 0.6rem; }
#text_size, #text_font { #text_size, #text_font {
margin: 0; margin: 0;
@@ -137,6 +163,8 @@ h2 {
} }
.palette .swatch { width: 1.5rem; height: 1.5rem; } .palette .swatch { width: 1.5rem; height: 1.5rem; }
/* A colour already in the toolbar leaves its place in the grid empty. */
.palette .swatch.missing { visibility: hidden; }
.swatch.more { .swatch.more {
--swatch: white; --swatch: white;
@@ -161,24 +189,36 @@ h2 {
background: var(--ink); background: var(--ink);
} }
#zoom_in, #zoom_out { /* The page, the notebook and the session: flush right, the page number
width: 2.2rem; and what is going on at the very end (Andy, 2026-10-08). */
padding: 0.1rem 0;
font-size: 1.35rem;
line-height: 1;
}
/* What concerns the page and the view, not the tool: it never moves. */
.toolbar_right { .toolbar_right {
display: flex; display: flex;
flex-wrap: wrap;
align-items: center; align-items: center;
gap: 1.25rem; gap: 0.5rem 0.55rem;
margin-left: auto; margin-left: auto;
min-width: 0;
} }
.page_number { color: var(--muted); } /* The notice line between the toolbar and the page: nothing, and no
.status { min-width: 6.5rem; text-align: right; color: var(--muted); } room, while all is well; a problem in red while it lasts; a
.status.problem { color: #b42318; } confirmation for a few seconds (Andy, 2026-10-10: the toolbar's
"Page 3 Connected Saved at …" was noise, and the room is needed for
one row). */
.notice {
padding: 0.3rem 1rem;
border-bottom: 1px solid var(--line);
background: var(--panel);
text-align: center;
font-size: 0.9rem;
color: var(--muted);
}
.notice.problem { color: #b42318; }
/* The quiet facts in the Session window: the page, the connection, the
last save. */
.session_state span { margin-right: 1rem; white-space: nowrap; }
.session_state .problem { color: #b42318; }
.workspace { .workspace {
display: flex; display: flex;
@@ -190,17 +230,40 @@ h2 {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
overflow: auto; overflow: auto;
padding: 1.25rem; padding: 0.5rem;
background: var(--desk); background: var(--desk);
} }
/* The cue around an image being moved or scaled by hand: green, with a
square at the corner being dragged. */
.image_grip {
position: absolute;
z-index: 3;
box-sizing: border-box;
border: 2px solid #2e7d32;
pointer-events: none;
}
.image_grip.corner::after {
content: "";
position: absolute;
left: var(--corner-x);
top: var(--corner-y);
width: 12px;
height: 12px;
transform: translate(-50%, -50%);
background: #2e7d32;
}
.page { .page {
position: relative; position: relative;
margin: 0 auto; margin: 0 auto;
background: white; background: white;
box-shadow: 0 1px 4px rgb(0 0 0 / 0.25); box-shadow: 0 1px 4px rgb(0 0 0 / 0.25);
touch-action: none; touch-action: none;
-webkit-user-select: none; /* Safari on iOS: see the controls rule. */
user-select: none; user-select: none;
-webkit-touch-callout: none; /* No callout from a long press of the Pencil. */
cursor: crosshair; cursor: crosshair;
} }
@@ -228,75 +291,78 @@ h2 {
outline: none; outline: none;
} }
.text_handle { /* A rectangle being dragged open, for text or for Hide. */
.rect_preview {
position: absolute; position: absolute;
z-index: 3; z-index: 3;
width: 10px; box-sizing: border-box;
height: 10px; border: 2px dotted #ababab;
margin: -5px 0 0 -5px; pointer-events: none;
border: 1px solid white;
border-radius: 2px;
background: var(--text_handle);
cursor: move;
} }
.text_handle.end { cursor: ew-resize; } /* The field of a text rectangle shows its frame and white ground itself,
since the renderer leaves the block out while it is edited. */
.text_field.rect {
background: white;
outline-style: solid;
outline-color: var(--text_frame);
}
.text_edge { /* Free text's corner mark while it is edited: an L at its top-left corner. */
.text_mark {
position: absolute; position: absolute;
z-index: 3; z-index: 3;
height: 0; box-sizing: border-box;
margin-top: -1px; border-style: solid none none solid;
border-top: 2px solid var(--text_edge); border-color: var(--text_frame);
cursor: move;
}
.text_edge::before, .text_edge::after {
content: "";
position: absolute;
top: 0;
width: 0;
height: 8px;
border-left: 2px solid var(--text_edge);
}
.text_edge::before { left: 0; }
.text_edge::after { right: 0; }
/* The top edge of a new block, while it is being dragged out. */
.text_preview {
position: absolute;
z-index: 3;
height: 0;
margin-top: -1px;
border-top: 2px solid var(--text_edge);
pointer-events: none; pointer-events: none;
} }
.panel { /* The Session dialog: who is here, and the invitation. */
width: 16rem;
padding: 0 1rem 1rem;
overflow: auto;
border-left: 1px solid var(--line);
background: var(--panel);
}
.panel ul { margin: 0; padding: 0; list-style: none; } /* fit-content, not auto: a modal dialog has its top and bottom pinned by
the browser's own style, so an auto height filled the window up to the
maximum (seen 2026-10-10: 1100 px of dialog around 350 px of content). */
.chooser.session { width: min(32rem, 90vw); height: fit-content; max-height: 85vh; }
.session_body h3 { margin: 1rem 0 0.4rem; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); }
.session_body ul { margin: 0; padding: 0; list-style: none; }
.panel li { .session_body li {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
padding: 0.2rem 0; padding: 0.2rem 0;
} }
.panel li.absent { color: var(--muted); } .session_body li.absent { color: var(--muted); }
.panel li label { flex: 1; } .session_body li label { flex: 1; }
.panel li .role { font-size: 0.8rem; color: var(--muted); } .session_body li .role { font-size: 0.8rem; color: var(--muted); }
.panel li .allow { order: 1; padding: 0.1rem 0.4rem; font-size: 0.75rem; } .session_body li .allow_line { order: 1; display: flex; align-items: center; gap: 0.3rem; font-size: 0.8rem; color: var(--muted); cursor: pointer; }
.session_body li .allow_line input { margin: 0; }
.panel button { margin-top: 0.4rem; } .invitation_line { display: flex; gap: 0.5rem; align-items: center; }
.invitation_line input { flex: 1; min-width: 0; }
/* The invitation as a QR code, for the camera of a phone or a tablet. */
.invitation_code { width: min(13rem, 60vw); margin: 0.75rem auto 0; }
.invitation_code svg { display: block; width: 100%; height: auto; }
/* The log, the latest entry at the bottom and in view, selectable. */
.log_line { display: flex; gap: 0.5rem; align-items: flex-start; }
.event_log {
flex: 1;
min-width: 0;
margin: 0;
max-height: 10rem;
overflow: auto;
padding: 0.3rem 0.4rem;
border: 1px solid var(--line);
border-radius: 6px;
font-size: 0.7rem;
line-height: 1.35;
color: var(--muted);
white-space: pre;
-webkit-user-select: text;
user-select: text;
}
/* The notebook chooser */ /* The notebook chooser */
@@ -396,7 +462,6 @@ h2 {
overflow: auto; overflow: auto;
} }
.files_shrink { display: block; margin-top: 0.75rem; font-size: 0.9rem; }
.files_existing { .files_existing {
display: flex; display: flex;
+4 -1
View File
@@ -31,7 +31,10 @@ copies the relay, the client and the files in this directory to the
server and runs `setup_vps.sh` there, which makes or updates everything server and runs `setup_vps.sh` there, which makes or updates everything
in the table and restarts the relay. It can be run again at any time. in the table and restarts the relay. It can be run again at any time.
`KOKKOJA_SERVER` names another ssh destination than `KOKKOJA_SERVER` names another ssh destination than
`root@vps313847.iceservers.net`. `root@vps313847.iceservers.net`. The VPS accepts root logins from the
keys of Jatke, Kukka and, since 2026-10-09, Olion (Andy's laptop, for
travelling); from any other machine the script fails with "Permission
denied (publickey)".
Steps a person does, once: Steps a person does, once:
+144 -83
View File
@@ -14,22 +14,28 @@ chat message, a text message. It is a link of this shape:
The part after `join=` is the invitation itself, which the host's Kokkoja The part after `join=` is the invitation itself, which the host's Kokkoja
may also give you on its own, without the link around it. Both forms may also give you on its own, without the link around it. Both forms
work wherever an invitation is asked for. The invitation carries the key work wherever an invitation is asked for. A host in the same room may
instead show you the link as a QR code on their screen: point your
phone's or tablet's camera at it and open the link it offers. The invitation carries the key
that protects the session, so pass it on only to people who belong in the that protects the session, so pass it on only to people who belong in the
session, and treat it as you would a meeting link. An invitation is good session, and treat it as you would a meeting link. An invitation is good
for one session: the host's next session has a new one. for one session: the host's next session has a new one.
## Joining ## Joining
Open the link in a web browser. Firefox, Brave and Chrome are known to Open the link in a web browser. Firefox, Brave and Chrome on a
work; other browsers built on the same engines very probably do. You see computer, and Safari on an iPad or an iPhone, are known to work; other
Kokkoja's start screen with the invitation already filled in. Enter the browsers built on the same engines very probably do. You see
name the others should see and press **Join**. That is all: nothing is Kokkoja's start screen, headed "Join a session", with the invitation
installed, and there is no account to make. already filled in. Enter the name the others should see and press
**Join**. That is all: nothing is installed, and there is no account to
make. (Opened without an invitation, the same screen also offers to
start a session of your own; an invitation is a request to join one
session, so with one it offers joining alone.)
Your name is remembered by the browser for next time. So is a second Your name is remembered by the browser for next time. So is a second
field, **Notebook for the pages of this session**, which you can leave as choice, **Notebook for the pages of this session**, which you can leave
it is for now; what it does is explained under "Your notebook" below. as it is for now; what it does is explained under "Your notebook" below.
If you have the Kokkoja program installed on your computer (see If you have the Kokkoja program installed on your computer (see
[A host's Kokkoja](host.md) for what that is and why you might want it), [A host's Kokkoja](host.md) for what that is and why you might want it),
@@ -42,15 +48,17 @@ place in it waits for the host to return.
## The screen ## The screen
Across the top is the toolbar. On the left are the tools and their Across the top is the toolbar, one row holding everything: the tools
settings; on the right, the zoom and, for the host, the buttons that and their settings, the view, the host's page buttons, your notebook,
start pages. Below it, the page fills most of the window, with a panel on and **Session**, which opens a window with the participants and the
the right. Every button explains itself: rest the pointer on it for a invitation. Below it the page has the whole width of the window. Every button
second and its explanation appears beneath it. explains itself: rest the pointer on it for two seconds and its
explanation appears beneath it.
### The page ### The page
The page is an A4 sheet, shown so that it fits the window. Write The page is an A4 sheet lying on its side, shown so that it fits the
window. Write
anywhere on it. When your writing comes near an edge, the sheet grows in anywhere on it. When your writing comes near an edge, the sheet grows in
that direction, in small steps, so there is always room, and the view that direction, in small steps, so there is always room, and the view
keeps its scale and extends beyond the window; scroll to reach the rest. keeps its scale and extends beyond the window; scroll to reach the rest.
@@ -65,11 +73,12 @@ is scrolled to concern nobody else.
**Pen** is chosen when you arrive. Draw with a pen on a tablet, a mouse, **Pen** is chosen when you arrive. Draw with a pen on a tablet, a mouse,
or a finger on a touch screen. With a pen, pressing harder makes the or a finger on a touch screen. With a pen, pressing harder makes the
line wider. The colours are to the right of the tools: black, blue, red line wider. The colours are to the right of the tools: black, red,
and green, and **+** opens a palette of more. A colour chosen from the green, blue and white, and **+** opens a palette of more, in which the
palette joins the row of colours and stays there in this browser. The colours already in the row leave their places empty. A colour chosen
three lines to the right of the colours are the line widths: 0.4, 0.7 from the palette joins the row and stays there in this browser. White
and 1.2 mm at ordinary pressure. is for painting over. The four lines to the right of the colours are
the line widths: 0.4, 0.7, 1.2 and 2.4 mm at ordinary pressure.
If a pen is in use, a hand resting on a touch screen is ignored. If a pen is in use, a hand resting on a touch screen is ignored.
@@ -78,17 +87,25 @@ If a pen is in use, a hand resting on a touch screen is ignored.
**Text** types on the page. Tap where the text should begin and type; a **Text** types on the page. Tap where the text should begin and type; a
text field appears there and the text is laid out on the page as you text field appears there and the text is laid out on the page as you
type. Enter begins a new line. Press Escape, or tap somewhere else, to type. Enter begins a new line. Press Escape, or tap somewhere else, to
finish; a block with nothing in it disappears. finish; a block with nothing in it disappears. You need not finish to
move a block: rest the pen on it while typing, and it follows the pen,
field and all; lift, and go on typing.
A block made by a tap does not wrap: its lines are as long as you type A block made by a tap does not wrap: its lines are as long as you type
them, and the page grows to hold them. To make the text wrap at a chosen them, and the page grows to hold them; a red corner mark of 5 mm at its
width, drag along where its top edge should run instead of tapping: the top left shows where it begins. To make a *text rectangle*
block begins at the left end of the drag and wraps at the right end. instead, drag it open where you want it: a red box, white inside, in
which the text wraps at the box's width, and which grows downward by
lines when you type more than it holds. The box is part of the page,
seen by everyone, and where it lies over an image it covers the image
and nothing else; a teacher uses one to say "write your answer here".
Tap your own text to change it. While a block of yours is being edited Tap your own text to change it, anywhere in a box, and the cursor is at
it is drawn grey, and three handles appear: the one at its top-left the end of the text. To move a block of yours, rest the pen
corner and the line along its top edge move it, the one at the right end on it, without moving, for a little over half a second: a green outline
of that line changes its width. You cannot change anyone else's text. appears, and it follows the pen. To resize a text rectangle, rest the
pen near one of its corners and drag the corner; the text wraps anew at
the new width. You cannot change anyone else's text.
With the Text tool, the row of colours stays and the line widths give way With the Text tool, the row of colours stays and the line widths give way
to the font, one of **Sans**, **Serif** and **Mono**, and the size in to the font, one of **Sans**, **Serif** and **Mono**, and the size in
@@ -102,48 +119,79 @@ whole stroke goes; across a text block and the block goes. It removes
nothing of anyone else's, and nobody else's eraser can touch yours. An nothing of anyone else's, and nobody else's eraser can touch yours. An
erased stroke is gone from the page for everyone. erased stroke is gone from the page for everyone.
### Zoom ### The view
**Fit** shows the whole page in the window, and keeps doing so as the A page is shown whole when you first see it. Afterwards the view keeps
page grows by writing, except while you are in the middle of a stroke of its scale: when the page grows, or the host adds an image, the page
your own. **+** and **−** zoom in and out by a tenth. Holding Ctrl and extends beyond the window and you scroll to the rest. Three buttons and
turning the scroll wheel zooms about the pointer. When the page is two gestures change the view, and none of them changes the page:
larger than the window, scroll it as you would any page. When the host
adds an image, the view keeps its scale rather than shrinking to fit; a * **Fit** shows the whole page in the window.
new image at the bottom of a long page is reached by scrolling. * **A4** shows the page at the scale a new page has, one A4 sheet
filling the window, so that a page shrunk to a small exercise is seen
at its size on the sheet rather than enlarged to fill the window.
* When the host presses **Fit** or **A4**, every participant's view
follows, each in their own window. Your own Fit and A4 change only
your screen.
* **Widen**, which only the host and co-hosts have, makes the page
itself wider, for everyone, until it fills the
window at the present zoom. The sheet grows equally on both sides,
so nothing on the page moves and nothing is rescaled; there is simply
more room at either side.
* **Shrink**, also for the host and co-hosts, makes the page, for everyone, just large enough for what is on it,
with the usual margin around. Nothing on it moves either.
* **Extend** is a box, ticked to begin with. Ticked, the sheet grows
when your writing comes near an edge, so there is always room.
Unticked, your writing stops short of the edge; a stroke that leaves
the page resumes when the pen comes back. Images always get the room
they need.
* Hold **Ctrl** (Command on a Mac) and turn the scroll wheel to zoom in
and out about the pointer.
* Hold **Ctrl** (Command on a Mac) and draw a stroke with the pen: up
zooms in, down zooms out, by twice for every 100 pixels of movement,
about the point where the pen touched down. Nothing is drawn while
the key is held.
If you reload the browser tab, it comes back to the page as you last saw If you reload the browser tab, it comes back to the page as you last saw
it: same zoom, same place. it: same zoom, same place.
### The panel ### Session
**Participants** lists everyone in the session, with *you* and *host* **Session** in the toolbar opens a window with the people and the
marked, and *away* beside anyone who has left or lost their connection; invitation. **Participants** lists everyone in the session, with *you*
an absent participant's writing stays on the page. The box in front of and *host* marked, and *away* beside anyone who has left or lost their
each name shows or hides that participant's writing on your screen alone. connection; an absent participant's writing stays on the page. The box
Hiding your teacher's corrections to try an exercise again, or hiding in front of each name shows or hides that participant's writing on your
your own answers to see the exercise as it was, changes nothing for screen alone. Hiding your teacher's corrections to try an exercise
anyone else. again, or hiding your own answers to see the exercise as it was,
changes nothing for anyone else.
**Invitation** shows the invitation of this session, with a button to **Invitation** shows the invitation of this session, with a button to
copy it, in case someone else should be invited. copy it and, below, as a QR code for a camera, in case someone else
should be invited. **Log** is a record of what happened in this
session, pages and connection and every press and release of the pen,
with a button to copy it: if something goes wrong, copy it and send it
to whoever looks after Kokkoja. Above it the window says which page
you are on, counted from the start of the session, how you stand with
the relay, and when the page was last saved; below it, where your
notebooks are kept.
**Current notebook**, **Keep page** and **Export** concern your notebook, ### Notices
below. Under them, a line reports what was last saved, and whether
anything went wrong.
The last line of the panel reports what your pen or mouse is delivering: While all is well Kokkoja says nothing. When there is something to
the kind of device, the pressure, and how many positions per second it know, a line appears between the toolbar and the page: in red while
sends. It is there for curiosity and for finding faults. a problem lasts, in grey for a few seconds after something was done.
*Not connected. Reconnecting…* means the relay cannot be reached;
Kokkoja reconnects by itself, and what you wrote meanwhile is sent when
it returns. *The page could not be saved* means the notebook is out of
reach; the page is still in memory and the saving is tried again.
*Waiting for the host's page* means the host has not started a page
yet, or your connection is down. The grey notices say that a page was
kept or exported, and where.
### Status For finding faults, the browser's console (F12) keeps a short log of the
session's events: pages, images, the connection, and what your pen or
At the right end of the toolbar, **Page 3** is the number of the page you mouse delivers.
are on, counted from the start of the session, and beside it *Connected*,
*Connecting…* or *Disconnected* says how you stand with the relay. If
the connection drops, Kokkoja reconnects by itself; what you wrote while
disconnected is sent when it returns. Until the host has started a
page, the toolbar says *Waiting for the host's page*.
## Your notebook ## Your notebook
@@ -162,24 +210,28 @@ last changed and the number counting the pages kept that day.
Two buttons bring pages out of the browser: Two buttons bring pages out of the browser:
* **Keep page** keeps a dated copy of the page as it is now, and * **Keep** keeps a dated copy of the page as it is now, and
downloads it, so you find it among your browser's downloads. Pressing downloads it, so you find it among your browser's downloads. Pressing
it again before anything has changed makes no second copy. it again before anything has changed makes no second copy and no
second download. Any mark counts as a change, even a dot from a
stray touch of the pen.
* **Export** downloads a picture of the page as you see it, as a PNG * **Export** downloads a picture of the page as you see it, as a PNG
file; writing you have hidden is left out. Its name is the date and file; writing you have hidden is left out. Its name is the date and
the page number. the page number.
A page file is an SVG file. Any web browser displays it, and vector A page file is an SVG file. Any web browser displays it, and vector
drawing programs such as Inkscape open it. Kokkoja itself can load one drawing programs such as Inkscape open it. Kokkoja itself can load one
again, writing and all, when someone with the right to start pages again, writing and all, when the host or a co-host chooses it (see "When the host gives you more" below, and the host's
chooses it (see "When the host gives you more" below, and the host's
part). part).
The notebook field on the start screen names the notebook within the The notebook list on the start screen names the notebook within the
browser's storage. Giving each course or each colleague a notebook of browser's storage. Giving each course or each colleague a notebook of
their own keeps their pages apart; the name proposed is simply their own keeps their pages apart; until you make one, everything goes
*Notebook*. **Current notebook** in the panel shows the notebook in use into the notebook *General*. The last entry of the list, **New
and lets you change it during the session. notebook…**, asks for a name and makes a notebook of that name; it
exists from the moment a page is written into it. **Current notebook**
in the toolbar is the same list, and lets you change notebook, or make
one, during the session.
### On your computer ### On your computer
@@ -193,7 +245,7 @@ to open the invitation in the installed program.
With the program, a notebook is a directory in `Kokkoja` in your With the program, a notebook is a directory in `Kokkoja` in your
Documents directory. The page you are on is `Current.svg` there, and Documents directory. The page you are on is `Current.svg` there, and
kept pages lie beside it under their dated names. **Keep page** makes kept pages lie beside it under their dated names. **Keep** makes
the dated copy; **Export** opens Kokkoja's own file chooser, in which you the dated copy; **Export** opens Kokkoja's own file chooser, in which you
pick a directory and a name for the PNG file. pick a directory and a name for the PNG file.
@@ -210,35 +262,44 @@ still on the page, from the others' copies and from your notebook, and
it is still yours to erase or change. A tab that was in a session and is it is still yours to erase or change. A tab that was in a session and is
reloaded goes straight back in. reloaded goes straight back in.
## When the host gives you more ## When the host makes you a co-host
Normally only the host puts images on the page, starts new pages, or Normally only the host puts images on the page, starts new pages, or
loads pages from a notebook. The host can give you the same right, for loads pages from a notebook. The host can make you a *co-host*, which
instance in a discussion among colleagues where everyone brings gives you the same powers, for instance in a discussion among
material. When that happens, three more buttons appear at the right of colleagues where everyone brings material. The Session window then
your toolbar: says *co-host* beside your name, and three more buttons appear at the
right of your toolbar:
* **New** starts a new blank page for everyone. The page being left is * **New** starts a new blank page for everyone. The page being left is
kept in each person's notebook. kept in each person's notebook.
* **Image** puts an image file on the page for everyone, below any * **Image** puts an image file on the page for everyone, below any
images already there. Your browser's file chooser asks for the file images already there. Your browser's file chooser asks for the file
(with the Kokkoja program, Kokkoja's own chooser does, beginning where (with the Kokkoja program, Kokkoja's own chooser does, beginning where
the notebooks are, with a choice whether an image wider than the page the notebooks are). A PNG or JPEG file keeps its exact pixels unless
is shrunk to fit it). A PNG or JPEG file keeps its exact pixels unless
it is very large; other formats and very large images are converted it is very large; other formats and very large images are converted
and reduced before they are shared. An image is placed at its natural and reduced before they are shared. An image is placed at its natural
size, centred, 8 mm from the top or from the image above; one wider size, centred, 5 mm below the image above or at the top of the page;
than the sheet is scaled down to fit between the margins. To put an one wider than the page widens it. To put an image on a page of its
image on a page of its own, press **New** first. own, press **New** first.
* **Notebook** starts a new page for everyone from a page kept in one of * **Notebook** starts a new page for everyone from a page kept in one of
your notebooks, with everything that was written on it. A chooser your notebooks, with everything that was written on it. A chooser
shows your notebooks and small pictures of their kept pages. The kept shows your notebooks and small pictures of their kept pages. The kept
page itself is not changed: the session works on a copy. page itself is not changed: the session works on a copy.
* **Hide**, among the tools, hides part of the page for everyone: drag
open a rectangle and whatever lies under it, writing included, is
covered in white, marked by blue corners so that nobody writes there
unknowingly. Rest the pen on it to move it, near a corner to resize
it; a tap of the eraser removes it and uncovers what was there. A Hide
stays even for someone who hides your writing on their own screen.
An image cannot be moved or removed once anyone has written on it or As a co-host you can also move and scale images, as the host can: rest the pen on an image for a little over half a second
added anything after it, since the marks on it would make no sense and it follows the pen; rest it near a corner and the corner follows,
without it. The one exception is a mistake caught at once: the person the shape kept. Writing stays where it is, so images are arranged
who added an image can take it back with a tap of the eraser on it, as first and written on afterwards. A new image always arrives below the
long as nothing else has happened on the page since. others, centred, at its own size, to be arranged by hand. A tap of the
eraser on an image removes it; writing on it stays. An image you added
yourself you may always remove, even after you have stopped being a
co-host.
The right stays with you until the host withdraws it or the session ends. You stay a co-host until the host decides otherwise or the session ends.
+142 -47
View File
@@ -22,21 +22,44 @@ You need:
`python3 --version`; if Python is not installed, macOS offers to `python3 --version`; if Python is not installed, macOS offers to
install the command line developer tools, which include it. Any Python install the command line developer tools, which include it. Any Python
from 3.9 on will do. from 3.9 on will do.
* **A web browser.** Firefox, Brave or Chrome; other browsers built on * **A web browser.** Firefox, Brave or Chrome, or Safari on a Mac;
the same engines very probably work. other browsers built on the same engines very probably work.
* **A pen tablet**, if you want to write by hand. A mouse works for * **A pen tablet**, if you want to write by hand. A mouse works for
pointing and for rough marks. pointing and for rough marks.
Get Kokkoja from its repository, either by downloading it as a zip file Get Kokkoja from its repository: download
and unpacking it, or with git:
https://git.andykopra.com/ack/kokkoja-rel/archive/main.zip
and unpack it, or, if you have git,
git clone https://git.andykopra.com/ack/kokkoja-rel.git kokkoja git clone https://git.andykopra.com/ack/kokkoja-rel.git kokkoja
Nothing else is installed and nothing is changed on your system. To Nothing else is installed and nothing is changed on your system. To
update later, download again or run `git pull` in that directory. To update later, download again or run `git pull` in that directory.
Kokkoja is free software under the GNU General Public License; the
`LICENSE.md` file in the directory is the license. To
remove Kokkoja, delete the directory; your notebooks are elsewhere and remove Kokkoja, delete the directory; your notebooks are elsewhere and
stay. stay.
### On a Mac
The same steps, with four things to know:
* Terminal is in Applications → Utilities. The first `python3` or
`git` command may make macOS offer to install the command line
developer tools; accept, and run the command again when it has
finished.
* The first time the program writes a notebook, macOS asks whether
Terminal may reach your Documents folder. Allow it, once; the
notebooks are there. The answer can be changed later under System
Settings → Privacy & Security → Files and Folders.
* The program opens Kokkoja in your default browser, Safari included.
To use another, start the program with `--no-browser` and open the
address it prints in the browser you prefer.
* Wherever this documentation says Ctrl for zooming, use Command on a
Mac. Both work, so a hand used to Ctrl is not punished.
## Starting Kokkoja ## Starting Kokkoja
Open a terminal in the Kokkoja directory and run Open a terminal in the Kokkoja directory and run
@@ -57,8 +80,13 @@ The relay address names the *Kokkoja server* you use. A server does two
things: it passes the participants' messages, encrypted so that it things: it passes the participants' messages, encrypted so that it
cannot read them, and it serves the web site that your guests join from. cannot read them, and it serves the web site that your guests join from.
The address above is Andy's server; whoever runs a server for you gives The address above is Andy's server; whoever runs a server for you gives
you its address. If you give the program no relay, it expects one on you its address. The program remembers it, so from the second time on
your own computer, which is useful only for trying Kokkoja alone.
python3 kokkoja.py
is enough, and the program says which relay it is using. Give the
option again only to change servers. Started for the first time with no
relay, it stops and asks for one.
Other options, all optional: Other options, all optional:
@@ -73,28 +101,34 @@ Other options, all optional:
## Starting a session ## Starting a session
On the start screen, enter your name and the notebook for this session, On the start screen, enter your name and the notebook for this session,
and press **Start a session**. You are on page 1, a blank sheet, and the and press **Start a session**. You are on page 1, a blank sheet, and
panel on the right shows the invitation. **Session** in the toolbar shows the invitation.
### The notebook ### The notebook
The notebook you name is a directory under `Kokkoja` in your Documents A notebook is a directory under `Kokkoja` in your Documents directory.
directory, made when it is first needed, and the start screen offers the The start screen lists the notebooks you have; until you make one, there
names of the notebooks you already have. For teaching, a notebook per is *General*, the notebook for whatever has no notebook of its own. The
student works well: everything a student is given, with everything they last entry of the list, **New notebook…**, asks for a name and makes a
and you wrote on it, gathers in that student's notebook, and the next notebook of that name, which exists from the moment a page is written
lesson with that student starts by naming it again. Your library of into it. For teaching, a notebook per student works well: everything a
exercises can live in notebooks of its own, such as *Exercises A1*, filled student is given, with everything they and you wrote on it, gathers in
by keeping pages in a session of your own with nobody else present. that student's notebook, and the next lesson with that student starts
by choosing it again. Your library of exercises can live in notebooks
of its own, such as *Exercises A1*, filled by keeping pages in a session
of your own with nobody else present.
The notebook can be changed during a session, in **Current notebook** in The notebook can be changed during a session, in the notebook list in
the panel. The page you are on then continues in the new notebook. the toolbar. The page you are on then continues in the notebook chosen.
### The invitation ### The invitation
**Invitation** in the panel shows a link. **Copy** copies it; send it to **Session** in the toolbar opens a window; **Invitation** there shows a
link. **Copy** copies it; send it to
your guests by email, in a chat message, or however you reach them. A your guests by email, in a chat message, or however you reach them. A
guest opens it, enters a name and is in. A guest with the Kokkoja guest opens it, enters a name and is in. Below the link the same
invitation stands as a QR code: a guest with a phone or a tablet points
its camera at your screen, and the link opens there. A guest with the Kokkoja
program installed may paste it into their own program instead. The link program installed may paste it into their own program instead. The link
carries the secret that protects the session, so send it only to those carries the secret that protects the session, so send it only to those
who should be there; anyone with the link can join and read the page. who should be there; anyone with the link can join and read the page.
@@ -115,24 +149,72 @@ notebook, by itself, under a dated name; nobody has to remember to save.
file chooser opens, beginning where your notebooks are and remembering file chooser opens, beginning where your notebooks are and remembering
where you last chose from; a directory path can also be typed into it. where you last chose from; a directory path can also be typed into it.
It shows small pictures of the image files it finds. Choose one and it It shows small pictures of the image files it finds. Choose one and it
is placed on the page: the first at the top of the sheet, each further is placed on the page in one way always: 5 mm below the lowest image
one 8 mm below the lowest image there, centred, at its natural size. An there, or at the top of the page if there is none, centred across the
image wider than the sheet is scaled down to fit between the margins, so page's width, at its natural size, in front of the images already
a full-page A4 scan appears at about 92%; the chooser has a setting to there. Natural size is the size the file records: a scan arrives at
turn that off, in which case the sheet widens to the image instead. PNG the size of the paper it came from. A photo from a phone or camera
and JPEG files are shared as they are unless very large; other formats, records no useful size and arrives at a tenth of a millimetre per
and images over 2400 pixels on a side or 1.5 MB, are converted to JPEG pixel. An image wider than the page widens the page on both sides to
and reduced first. A page holds up to 12 images. hold it. From there you move and scale it by hand, as described next;
there is no other setting to choose. PNG and JPEG files are shared as
they are unless very large; other formats, and images over 2400 pixels
on a side or 1.5 MB, are converted to JPEG and reduced first, which
does not change the size they arrive at. A page holds up to 12 images.
To move an image, rest the pen on it, without moving, for a little over
half a second: a green outline appears, and the image follows the pen
until you lift it. It comes to the front of the other images, which a
hold without moving also does. To scale an image, rest the pen near one
of its corners: a green square marks the corner, and dragging it scales
the image along its diagonal, the opposite corner staying put and the
shape kept. This works with every tool: a dot the pen may have drawn
while resting is removed, and the eraser erases nothing while it rests.
Writing is not attached to images: an image moved from under writing
leaves the writing where it was, so compose the images first and write
afterwards. Only you and co-hosts can move images.
For a page that begins with an image, press **New** and then **Image**. For a page that begins with an image, press **New** and then **Image**.
For a series of exercises, keep pressing **Image** on the same page: the For a series of exercises, keep pressing **Image** on the same page: the
sheet grows downward and everyone scrolls through them. A new image does sheet grows downward and everyone scrolls through them. A new image does
not change anyone's zoom; it appears below, to be scrolled to. not change anyone's zoom; it appears below, to be scrolled to.
If you chose the wrong file, a tap of the eraser on the image takes it A tap of the eraser on an image removes it, whoever added it and
back, as long as nothing else has happened on the page since: no stroke, whenever. Writing on it stays where it was, so remove an image before
no text, no erasure, no further image, by anyone. After that an image anyone writes on it unless the writing is meant to stay. The page keeps
stays, because the marks made on it would make no sense without it. the size the image gave it; **Shrink** trims it. A guest who added an
image may remove it, even after they have stopped being a co-host.
**Fit** and **A4**, pressed by you, act on every participant's screen,
each in their own window: the way to bring everyone back to the whole
page. A guest's Fit and A4 change only that guest's screen.
**Hide**, the tool beside the Eraser, covers part of the page for
everyone: drag open a rectangle, and whatever lies under it, writing of
anyone included, is covered in white, marked by blue corners that tell
the students something is there. Use it to cover a solution until the
student has tried, or a part of the exercise for later. Rest the pen on
it to move it, near a corner to resize it; a tap of the eraser removes
it and shows what was covered. Nothing under a Hide is lost, and a
student cannot uncover it by hiding your writing on their screen: a Hide
belongs to the page, not to your writing. A Hide is white in an
exported picture and in the page file as other programs see it.
With the Text tool, a rectangle dragged open is a *text rectangle*: a
red box with a white ground, which covers the image beneath it and no
writing, in which text wraps and which grows downward as it fills. It
is the "write your answer here" box of a worksheet: draw it on the scan
where the answer belongs, and the student types into their own box or
beside yours. Only its owner can type in a box, as with any text.
**Widen**, among the view buttons, makes the page wider for everyone,
until it fills your window at your present zoom: the quickest way to
more room for writing beside an exercise. The sheet grows equally on
both sides, so nothing on the page moves. The page keeps the new width
in everyone's notebook. **Shrink** is its opposite: the page becomes
just large enough for everything on it, images and writing, with the
margin of 5 mm around, which trims a page down to its exercise before you keep
or export it. A blank page cannot shrink.
**Notebook** starts a new page for everyone from a page kept in one of **Notebook** starts a new page for everyone from a page kept in one of
your notebooks, with everything that was written on it. A chooser shows your notebooks, with everything that was written on it. A chooser shows
@@ -147,14 +229,17 @@ was kept when you left it, and **Notebook** brings it back as a new page.
A lesson might go: **New**, **Image** for the exercise, work, **New** A lesson might go: **New**, **Image** for the exercise, work, **New**
for a point of grammar, work, **Notebook** to return to the exercise. for a point of grammar, work, **Notebook** to return to the exercise.
## Guests' rights ## Co-hosts
In the **Participants** list, each guest's name has a button **pages** in In the **Participants** list of the Session window, each guest's name
front of it. Pressing it lets that guest start new pages, put images on has a box **Co-host** beside it. Ticking it makes that guest a
the page and load pages from their notebook, exactly as you can; the co-host: they may then start new pages, put images on the page and
three buttons appear on their toolbar. Pressing it again withdraws the arrange them, load pages from their notebook, widen, shrink and hide,
right, which otherwise lasts until the session ends. Guests can never erase or exactly as you can, and the buttons for it appear on their toolbar.
change anyone's writing but their own; that needs no setting. Unticking it takes it back; otherwise it lasts until the session
ends. What stays yours alone: making co-hosts, and bringing everyone's
view to the page with Fit or A4. Guests, co-hosts or not, can never
erase or change anyone's writing but their own; that needs no setting.
## Hiding writing ## Hiding writing
@@ -169,9 +254,10 @@ picture of the exercise with the corrections alone.
The page you are on is written continuously to `Current.svg` in the The page you are on is written continuously to `Current.svg` in the
current notebook, and copied to a dated file whenever the session moves current notebook, and copied to a dated file whenever the session moves
on. **Keep page** makes such a copy now, named for the day and a serial on. **Keep** makes such a copy now, named for the day and a serial
number, such as `2026_10_06_02.svg`; pressing it again before anything number, such as `2026_10_06_02.svg`; pressing it again before anything
has changed makes no second copy. Use it to mark a state worth keeping has changed makes no second copy. Any mark counts as a change, even a
dot from a stray touch of the pen. Use it to mark a state worth keeping
on its own, or to add a finished page to a notebook of exercises. on its own, or to add a finished page to a notebook of exercises.
**Export** saves a picture of the page as you see it, as a PNG file at **Export** saves a picture of the page as you see it, as a PNG file at
@@ -190,7 +276,7 @@ Close the browser tab, or stop the program with Ctrl-C in its terminal.
There is nothing to save: the page is already in your notebook, and in There is nothing to save: the page is already in your notebook, and in
each guest's. Guests who are still connected can go on writing on the each guest's. Guests who are still connected can go on writing on the
page among themselves, but no new page can be started without you or a page among themselves, but no new page can be started without you or a
guest you have given the right, and a guest who arrives after everyone co-host, and a guest who arrives after everyone
has left finds an empty session. The next lesson is a new session with a has left finds an empty session. The next lesson is a new session with a
new invitation; load the pages you need from the student's notebook. new invitation; load the pages you need from the student's notebook.
@@ -199,13 +285,22 @@ new invitation; load the pages you need from the student's notebook.
* **The browser says the notebooks are locked.** Open Kokkoja from the * **The browser says the notebooks are locked.** Open Kokkoja from the
full address the program printed when it started, the one with the full address the program printed when it started, the one with the
token. token.
* **"Disconnected" in the toolbar.** Kokkoja reconnects by itself as * **"Not connected" above the page.** Kokkoja reconnects by itself as
soon as the relay can be reached, and sends what was written meanwhile. soon as the relay can be reached, and sends what was written meanwhile.
If it does not come back, check your connection; the page is safe in If it does not come back, check your connection; the page is safe in
your notebook either way. your notebook either way.
* **A guest sees "Waiting for the host's page".** You have not started * **A guest sees "Waiting for the host's page".** You have not started
a page yet, or your connection is down. The first page is started when a page yet, or your connection is down. The first page is started when
you start the session, so this usually means the latter. you start the session, so this usually means the latter.
* **Pressure does not work.** The last line of the panel reports what * **Pressure does not work.** The browser's console (F12) logs what
the browser receives from your pen. If it says *mouse*, the browser is the browser receives from your pen at your first stroke. If it says
not seeing the pen as a pen; try the other browser. *mouse*, the browser is not seeing the pen as a pen; try the other
browser.
* **"Address already in use" when starting the program.** A copy of
the program is already running, usually in another terminal window.
Only one can run at a time; use it, or stop it there with Ctrl-C and
start again.
* **Guests cannot connect.** Look at the line `Relay: ...` the program
printed when it started: it names the relay the session goes through.
If it is not your server's address, stop the program and start it
again with `--relay` and the right address.
+68 -20
View File
@@ -37,13 +37,16 @@ page, an image once. There is no central copy.
Two computers behind home routers generally cannot connect to each other Two computers behind home routers generally cannot connect to each other
directly, so something in between has to pass the messages. The relay is directly, so something in between has to pass the messages. The relay is
the smallest such thing: a WebSocket server with *rooms*. A client the smallest such thing: a WebSocket server that passes messages among
connects to `wss://<server>/relay/room/<room id>`; the relay gives the the members of a session. What a user calls a session, the relay calls a
connection a number, tells the room that a peer has arrived, and from *room*: the word appears in the relay's code and log and in the address a
then on sends every message it receives from one member to all the client connects to, `wss://<server>/relay/room/<room id>`, and nowhere
others, stamped with the sender's number. When the last member leaves, else. The relay gives each connection a number, tells the others that a
the room is gone. It holds at most 32 members per room and 500 rooms, peer has arrived, and from then on sends every message it receives from
and refuses messages over 1 MB; clients split anything larger. one member to all the others, stamped with the sender's number. When
the last member leaves, nothing of the session remains at the relay. It
holds at most 32 members per session and 500 sessions, and refuses
messages over 1 MB; clients split anything larger.
## Sessions and encryption ## Sessions and encryption
@@ -53,7 +56,7 @@ The invitation is
The *secret* is 16 random bytes, made by the host's browser. From it The *secret* is 16 random bytes, made by the host's browser. From it
every client derives two values with HKDF-SHA-256: the *room id*, which every client derives two values with HKDF-SHA-256: the *room id*, which
names the room at the relay, and the *message key*, a 256-bit AES-GCM names the session at the relay, and the *message key*, a 256-bit AES-GCM
key with which every message is encrypted before it is sent and key with which every message is encrypted before it is sent and
decrypted on arrival. The relay sees the room id, the members' network decrypted on arrival. The relay sees the room id, the members' network
addresses, and the size and timing of encrypted messages. It cannot addresses, and the size and timing of encrypted messages. It cannot
@@ -66,7 +69,21 @@ A link invitation wraps the same text:
https://<site>/#join=<invitation> https://<site>/#join=<invitation>
Browsers never send the part of an address after `#` to the site, so the Browsers never send the part of an address after `#` to the site, so the
secret stays between the host and the guests. secret stays between the host and the guests. The Session window also
shows the link as a QR code, so that a phone or a tablet in the room can
join by camera; the encoder is Kokkoja's own (`qr_code.js`: byte mode,
error correction level M, any version), checked by the tests against
Nayuki's reference encoder.
The client writes its singular events (pages, images, the connection,
the co-host right, the first input report) to the browser's console
and keeps them, with a trace of every press, release and cancellation
of the pointer (an eraser's release says how many strokes and text
blocks it removed), every change of tool and the touch events, in
memory (`log.js`, the last 400 entries). The Session window shows the record with a Copy button,
so that a guest on a tablet, where there is no console, can send it
when the pen misbehaves. The record stays in the browser tab; nothing
of it is sent anywhere by Kokkoja.
## Identity and layers ## Identity and layers
@@ -74,7 +91,7 @@ Each participant has a signing key pair (ECDSA on P-256), made in the
browser the first time and then kept, by the local program in its browser the first time and then kept, by the local program in its
configuration directory or by the browser in its own storage. The configuration directory or by the browser in its own storage. The
*participant id* is the first 16 bytes of the SHA-256 hash of the public *participant id* is the first 16 bytes of the SHA-256 hash of the public
key. When a client joins a room it announces itself with a signed key. When a client connects to a session it announces itself with a signed
`hello` carrying its name and public key; the signature covers the room `hello` carrying its name and public key; the signature covers the room
id and the peer number the relay stamped on the message, so nobody can id and the peer number the relay stamped on the message, so nobody can
replay it elsewhere or claim another peer's connection. Until a peer has replay it elsewhere or claim another peer's connection. Until a peer has
@@ -89,23 +106,37 @@ kept, a person is the same participant in every session from the same
computer, and their writing on a page loaded weeks later is still theirs. computer, and their writing on a page loaded weeks later is still theirs.
The host is whoever holds the key whose id is in the invitation. Pages, The host is whoever holds the key whose id is in the invitation. Pages,
images and the list of guests allowed to start pages are accepted only images and the list of co-hosts are accepted only
from the host (or from an allowed guest, for pages and images). from the host (or from an allowed guest, for pages and images).
## Pages ## Pages
Page coordinates are in units of 0.1 mm. A blank page is an A4 sheet, Page coordinates are in units of 0.1 mm. A blank page is an A4 sheet
2100 by 2970 units. Writing within 8 mm of an edge grows the sheet by in landscape, 2970 by 2100 units. "Widen" makes the sheet wider, and
8 mm on that side. An image is placed at its natural size, taken from "Shrink" makes it the bounding box of everything on the page plus the
greater of the two margins, exactly, on every side; both are changes to the page like adding
an image, carried in the page descriptor (its `left` and `top` edges and
its size) with the images' revision raised. Writing within 5 mm of an edge grows the page by
5 mm on that side, counted from the sheet's edge. An image is placed at its natural size, taken from
the resolution recorded in the file, or 0.1 mm per pixel when there is the resolution recorded in the file, or 0.1 mm per pixel when there is
none, centred across the sheet, 8 mm from the top or from the image none (the nominal 72 and 96 dpi that cameras and screenshots write count
above, scaled down if wider than the sheet inside its margins (or, if the as none), centred across the page, 5 mm from the top or from the image
person adding it chooses, placed at natural size with the sheet widened above, the sheet widened equally on both sides if it is wider than the
to hold it). A page holds at most 12 images and 24 MB of image data. page; only one wider than a page can be, 2 m, is scaled down. A page holds at most 12 images and 24 MB of image data.
An image file over 2400 pixels on a side or 1.5 MB, or not PNG or JPEG, An image file over 2400 pixels on a side or 1.5 MB, or not PNG or JPEG,
is redrawn at most 2400 pixels and stored as JPEG before it is shared; is redrawn at most 2400 pixels and stored as JPEG before it is shared;
smaller PNG and JPEG files are shared byte for byte. smaller PNG and JPEG files are shared byte for byte.
A text block is free text, or a *text rectangle* with a width and a
height, drawn with a red frame on a white ground; the ground is drawn
beneath every layer, so that it hides an image and never anyone's
writing, and the frame within the owner's layer. A *Hide* rectangle is
a page object like an image, in the page descriptor and the page file,
drawn white above every layer with blue corners on screen; made, moved
and removed by the host and co-hosts. Both are moved by a hold of
the pen and resized from their corners, like images but with their
shape free; an image keeps its shape.
A stroke is a list of points with pressure, and the line width is a A stroke is a list of points with pressure, and the line width is a
function of pressure: nominal at a pressure of 0.4, about 2.6 times that function of pressure: nominal at a pressure of 0.4, about 2.6 times that
at 0.75. Points are sent while the stroke is drawn, so the others see it at 0.75. Points are sent while the stroke is drawn, so the others see it
@@ -127,7 +158,7 @@ it, rewritten a moment after every change (at most every 400 ms while
writing continues, at least every 2 s). A *kept* page is a copy of writing continues, at least every 2 s). A *kept* page is a copy of
`Current.svg` named `YYYY_MM_DD_NN.svg`, the day being when the page was `Current.svg` named `YYYY_MM_DD_NN.svg`, the day being when the page was
last changed and NN a serial number within that day. A copy is made when last changed and NN a serial number within that day. A copy is made when
the user asks (**Keep page**), and by the program whenever `Current.svg` the user asks (**Keep**), and by the program whenever `Current.svg`
is about to be given to another page: when the session moves to a new is about to be given to another page: when the session moves to a new
page, when the notebook is changed, and when a session begins and finds page, when the notebook is changed, and when a session begins and finds
a page of another session in the notebook. Nothing is copied if the a page of another session in the notebook. Nothing is copied if the
@@ -213,7 +244,7 @@ computer behind a home router is not without arrangement.
The client uses the browser's pointer events, which deliver every sample The client uses the browser's pointer events, which deliver every sample
a pen produces, with pressure, in Firefox and in Chromium-based browsers a pen produces, with pressure, in Firefox and in Chromium-based browsers
on Linux and macOS. The panel's last line shows what arrives. When a on Linux and macOS. The browser's console shows what arrives. When a
pen has been used, touches are ignored, so a hand resting on a touch pen has been used, touches are ignored, so a hand resting on a touch
screen does not draw; this rule has not yet been tested with a device screen does not draw; this rule has not yet been tested with a device
that has both a pen and a touch sensor. Kokkoja opens none of the that has both a pen and a touch sensor. Kokkoja opens none of the
@@ -221,6 +252,23 @@ browser's system dialogs (the colour palette and, with the local
program, the file chooser are its own), because one such dialog crashed program, the file chooser are its own), because one such dialog crashed
Firefox on Linux. Firefox on Linux.
## Adjustable choices
Some choices of the interface are named constants at the top of the
file that uses them, so that they can be changed in one place, or offered
as preferences later: the line widths and the five colours (`main.js`),
the zoom step of the wheel and the zoom per 100 pixels of a pen drag
(`renderer.js`), the dead zone of that drag (`pen_input.js`), the margin
by which the page grows (`page_model.js`), the tooltip delay
(`tooltip.js`), the hold that moves an image (600 ms, with 6 pixels of
slack, `pen_input.js`), the reach of an image's corner (24 pixels,
`renderer.js`), the smallest an image can be scaled to (1 cm,
`page_model.js`), how often a dragged image's place is sent (40 ms,
`session.js`) and the points of a stroke (25 ms, `session.js`), the colour and width of a text rectangle's frame
(`page_file.js`) and whether the frame is shown whole or as corners on
screen (`TEXT_FRAME_STYLE` in `renderer.js`; the file and the export
always hold the whole frame).
## Development ## Development
The code is Python and JavaScript only. The tests run with The code is Python and JavaScript only. The tests run with
+50 -5
View File
@@ -33,7 +33,7 @@ layer of its own. You can erase your own writing and nothing else; nobody
else can erase yours. Any layer can be hidden on your own screen without else can erase yours. Any layer can be hidden on your own screen without
affecting anyone else's view. affecting anyone else's view.
**A page that grows.** A page is an A4 sheet. When writing approaches an **A page that grows.** A page is an A4 sheet, lying on its side. When writing approaches an
edge, the sheet grows in that direction, so you are never short of room. edge, the sheet grows in that direction, so you are never short of room.
An image is placed at its natural size; one wider than the sheet is An image is placed at its natural size; one wider than the sheet is
scaled down to fit, and the sheet grows downward to hold further images. scaled down to fit, and the sheet grows downward to hold further images.
@@ -62,8 +62,9 @@ host runs Kokkoja.
**Free software.** Kokkoja is free and open-source. Anyone can use it **Free software.** Kokkoja is free and open-source. Anyone can use it
without paying, and nobody depends on a company's service. It runs in without paying, and nobody depends on a company's service. It runs in
Firefox and in browsers built on the Chromium engine, such as Brave and Firefox, in browsers built on the Chromium engine, such as Brave or
Chrome, on Linux and macOS. Chrome, and in Safari, on Linux and macOS; a guest can also join from
Safari on an iPad or an iPhone, writing with a pencil or a finger.
## Who uses it ## Who uses it
@@ -93,8 +94,52 @@ A session has one *host*, the person who starts it, and any number of
guests. The host sends the guests an invitation, by email, in a chat guests. The host sends the guests an invitation, by email, in a chat
message, or by any other means. The host *drives*: only the host puts message, or by any other means. The host *drives*: only the host puts
images on the page, starts new pages or loads pages from a notebook, and images on the page, starts new pages or loads pages from a notebook, and
everyone follows. The host can give any guest the same abilities, for a everyone follows. The host can make any guest a *co-host*, with the
discussion among colleagues. same powers, for a discussion among colleagues.
## What runs where
Three kinds of machine take part, and it helps to have the picture in
mind before reading on.
```
Host's computer Server Guests
--------------- ------ ------
Kokkoja program kokkoja.andykopra.com Guest A: a browser
serves the client -+ | opens the link
to the browser at | +----+-----+ the server serves
localhost:8760 | | relay | <-------------> the client to it;
keeps the notebook | | (passes | notebook in the
as files | | messages,| browser, downloads
| | | reads |
browser <-----------+ | nothing) | <-------------> Guest B: has the
| +----------+ Kokkoja program too;
+---- encrypted ----------^ | joins from its own
messages | start screen; note-
web site: book as files
the client's files
```
* **The host** runs the small Kokkoja program on their own computer.
It shows Kokkoja in the host's browser, at the address
`http://localhost:8760/`, and it keeps the host's notebooks as
directories of files. It must be running for as long as the host is
in a session; the browser alone cannot reach the notebooks.
* **The server** does two things for everyone. Its *relay* passes the
participants' messages, which are encrypted before they leave each
computer, so the relay reads nothing. Its *web site* serves the
Kokkoja client to guests who join by link. The host's program is
told the server's address once, when it is started.
* **A guest** needs only a browser: opening the invitation link fetches
the client from the server's web site, and the guest's notebook lives
in that browser, with kept pages and exports offered as downloads. A
guest who wants a notebook of real files runs the Kokkoja program as
the host does, and joins from the program's own start screen.
Every participant's computer holds the whole page. Only changes travel,
through the relay, to everyone else. Nothing is stored on the server.
The rest of this documentation describes Kokkoja from the guest's point of The rest of this documentation describes Kokkoja from the guest's point of
view first, since the host sees the same screen and has a few more view first, since the host sees the same screen and has a few more
+38 -5
View File
@@ -106,6 +106,31 @@ def load_token(directory):
return token return token
def remembered_relay(directory, given=""):
"""The relay to use: the one given, which is then remembered in the
configuration directory, or else the one remembered from last time.
Returns (address, remembered): the address, or None if none was given
and none is remembered, and whether it came from memory. A relay
address is typed once; afterwards the program is started bare (Andy,
2026-10-08, after a start without --relay sent an invitation to a
relay on his own computer that was not running).
"""
path = directory / "relay"
if given:
given = given.strip()
directory.mkdir(parents=True, exist_ok=True)
if path.exists() and path.read_text().strip() == given:
return given, False
write_atomically(path, (given + "\n").encode())
return given, False
try:
kept = path.read_text().strip()
except OSError:
kept = ""
return (kept, True) if kept else (None, False)
def write_atomically(path, data): def write_atomically(path, data):
"""Replace a file so that it is never seen half written.""" """Replace a file so that it is never seen half written."""
temporary = path.with_name("." + path.name + ".writing") temporary = path.with_name("." + path.name + ".writing")
@@ -132,7 +157,8 @@ def keep_page(notebook):
changed = current.stat().st_mtime changed = current.stat().st_mtime
except FileNotFoundError: except FileNotFoundError:
return None, False return None, False
if b"data-stroke=" not in data and b"<image" not in data: # Blank: no stroke, no text block, no image.
if not any(mark in data for mark in (b"data-stroke=", b"data-text=", b"<image")):
return None, False return None, False
kept = [entry for entry in notebook.iterdir() kept = [entry for entry in notebook.iterdir()
@@ -459,9 +485,10 @@ def make_server(port, relay, notebooks, token, config, site=""):
def main(): def main():
parser = argparse.ArgumentParser(description="Kokkoja local program") parser = argparse.ArgumentParser(description="Kokkoja local program")
parser.add_argument("--port", type=int, default=8760) parser.add_argument("--port", type=int, default=8760)
parser.add_argument("--relay", default="ws://127.0.0.1:8770", parser.add_argument("--relay", default="",
help="relay used for sessions started here " help="relay used for sessions started here, such as "
"(default ws://127.0.0.1:8770)") "wss://kokkoja.example/relay; remembered, so it "
"need be given only once")
parser.add_argument("--site", default="", parser.add_argument("--site", default="",
help="Kokkoja web site that invitations link to, so that " help="Kokkoja web site that invitations link to, so that "
"guests can join with nothing installed (default: " "guests can join with nothing installed (default: "
@@ -478,10 +505,16 @@ def main():
notebooks = arguments.notebooks or default_notebooks_directory() notebooks = arguments.notebooks or default_notebooks_directory()
config = arguments.config or config_directory() config = arguments.config or config_directory()
relay, remembered = remembered_relay(config, arguments.relay)
if relay is None:
parser.exit(2, "Kokkoja needs the address of a relay the first time it is started:\n"
" python3 kokkoja.py --relay wss://kokkoja.example/relay\n"
"Afterwards the address is remembered and python3 kokkoja.py is enough.\n")
token = load_token(config) token = load_token(config)
server = make_server(arguments.port, arguments.relay, notebooks, token, config, arguments.site) server = make_server(arguments.port, relay, notebooks, token, config, arguments.site)
address = f"http://localhost:{arguments.port}/#token={token}" address = f"http://localhost:{arguments.port}/#token={token}"
print(f"Kokkoja is at {address}", flush=True) print(f"Kokkoja is at {address}", flush=True)
print(f"Relay: {relay}" + (" (remembered from last time)" if remembered else ""), flush=True)
print(f"Notebooks are in {notebooks}", flush=True) print(f"Notebooks are in {notebooks}", flush=True)
print("Ctrl-C to stop.", flush=True) print("Ctrl-C to stop.", flush=True)
if not arguments.no_browser: if not arguments.no_browser:
+1
View File
@@ -0,0 +1 @@
qrcodegen>=1.8
+825 -161
View File
File diff suppressed because it is too large. Load diff
+17
View File
@@ -22,6 +22,9 @@ import kokkoja # noqa: E402
TOKEN = "test-token" TOKEN = "test-token"
PAGE = b'<svg xmlns="http://www.w3.org/2000/svg" data-kokkoja="1"/>\n' PAGE = b'<svg xmlns="http://www.w3.org/2000/svg" data-kokkoja="1"/>\n'
PNG = b"\x89PNG\r\n\x1a\n" + b"the rest of a picture" PNG = b"\x89PNG\r\n\x1a\n" + b"the rest of a picture"
TEXT_ONLY_LAYER = (b'<g data-layer="abc" data-name="Andy"><g data-text="t1" data-x="10" data-y="10">'
b'<desc>hello</desc><text><tspan>hello</tspan></text></g></g>')
WRITTEN_PAGE = b'<svg data-kokkoja="1"><g data-layer="a"><g data-stroke="s"/></g></svg>\n' WRITTEN_PAGE = b'<svg data-kokkoja="1"><g data-layer="a"><g data-stroke="s"/></g></svg>\n'
@@ -64,6 +67,16 @@ class LocalProgramTest(unittest.TestCase):
self.assertEqual(self.json("GET", "/config.json", token=None), self.assertEqual(self.json("GET", "/config.json", token=None),
(200, {"relay": "ws://relay.example:8770", "storage": "files", "site": ""})) (200, {"relay": "ws://relay.example:8770", "storage": "files", "site": ""}))
def test_the_relay_is_remembered(self):
config = pathlib.Path(self.directory.name) / "relay_config"
self.assertEqual(kokkoja.remembered_relay(config), (None, False))
self.assertEqual(kokkoja.remembered_relay(config, "wss://kokkoja.example/relay"),
("wss://kokkoja.example/relay", False))
self.assertEqual(kokkoja.remembered_relay(config), ("wss://kokkoja.example/relay", True))
self.assertEqual(kokkoja.remembered_relay(config, " ws://127.0.0.1:8770 "),
("ws://127.0.0.1:8770", False))
self.assertEqual((config / "relay").read_text(), "ws://127.0.0.1:8770\n")
def test_site_comes_from_a_kokkoja_server_relay_or_is_given(self): def test_site_comes_from_a_kokkoja_server_relay_or_is_given(self):
self.assertEqual(kokkoja.site_of("wss://kokkoja.example/relay", ""), "https://kokkoja.example/") self.assertEqual(kokkoja.site_of("wss://kokkoja.example/relay", ""), "https://kokkoja.example/")
self.assertEqual(kokkoja.site_of("wss://kokkoja.example/relay/", ""), "https://kokkoja.example/") self.assertEqual(kokkoja.site_of("wss://kokkoja.example/relay/", ""), "https://kokkoja.example/")
@@ -149,6 +162,10 @@ class LocalProgramTest(unittest.TestCase):
self.assertEqual(self.json("GET", "/api/notebooks/Andy")[1]["pages"], self.assertEqual(self.json("GET", "/api/notebooks/Andy")[1]["pages"],
[f"{today}_01.svg", f"{today}_02.svg", "Current.svg"]) [f"{today}_01.svg", f"{today}_02.svg", "Current.svg"])
# A page with typed text and nothing else is not blank either.
self.request("PUT", current, body=b'<svg data-kokkoja="1">' + TEXT_ONLY_LAYER + b"</svg>\n")
self.assertEqual(self.json("POST", keep), (200, {"kept": f"{today}_03.svg", "new": True}))
def test_a_kept_page_is_dated_when_it_was_last_changed(self): def test_a_kept_page_is_dated_when_it_was_last_changed(self):
notebook = self.notebooks / "Andy" notebook = self.notebooks / "Andy"
notebook.mkdir(parents=True) notebook.mkdir(parents=True)
+85
View File
@@ -0,0 +1,85 @@
"""The client's QR encoder against Nayuki's reference encoder (qrcodegen).
Every version from 1 to 40 at every level, and the invitation link's
shape, must give the same modules, mask choice included. Needs node
(the type checker's) and qrcodegen (tests/requirements.txt). Two other
encoders were tried as the reference: python-qrcode agrees with ours at
a given mask but chooses masks by its own reading of the penalty rules,
and segno's modules differ even at the same mask.
"""
import json
import os
import subprocess
import unittest
from qrcodegen import QrCode, QrSegment
CLIENT = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "client", "js")
LEVELS = {"L": QrCode.Ecc.LOW, "M": QrCode.Ecc.MEDIUM, "Q": QrCode.Ecc.QUARTILE, "H": QrCode.Ecc.HIGH}
HARNESS = """
import { qrModules } from %s;
const texts = JSON.parse(await new Promise((resolve) => {
let data = '';
process.stdin.on('data', (chunk) => { data += chunk; });
process.stdin.on('end', () => resolve(data));
}));
console.log(JSON.stringify(texts.map(([text, level]) =>
qrModules(text, level).map((row) => row.map((dark) => dark ? 1 : 0)))));
"""
def encode_in_client(texts):
"""[(text, level)] -> list of module matrices (lists of 0/1 rows)."""
harness = HARNESS % json.dumps(os.path.join(CLIENT, "qr_code.js"))
result = subprocess.run(["node", "--input-type=module", "-e", harness],
input=json.dumps(texts), capture_output=True, text=True, check=True)
return json.loads(result.stdout)
def reference(data, level):
"""Nayuki's code for those bytes in byte mode: (version, mask, matrix)."""
code = QrCode.encode_segments([QrSegment.make_bytes(data)], LEVELS[level], boostecl=False)
size = code.get_size()
matrix = [[1 if code.get_module(x, y) else 0 for x in range(size)] for y in range(size)]
return code.get_version(), code.get_mask(), matrix
def longest_text(version, level):
"""The longest text of that version at that level in byte mode."""
bits = QrCode._get_num_data_codewords(version, LEVELS[level]) * 8
length = (bits - 4 - (8 if version < 10 else 16)) // 8
assert reference(b"x" * length, level)[0] == version
return "".join(chr(33 + (i * 7) % 90) for i in range(length))
class QrCodeTest(unittest.TestCase):
def check(self, texts):
matrices = encode_in_client(texts)
for (text, level), matrix in zip(texts, matrices):
version, mask, expected = reference(text.encode("utf-8"), level)
with self.subTest(level=level, length=len(text.encode("utf-8")), version=version, mask=mask):
self.assertEqual(matrix, expected)
def test_invitation_link(self):
link = ("https://kokkoja.andykopra.com/#join=wss://kokkoja.andykopra.com/relay%23"
"AAAAAAAAAAAAAAAAAAAAAA.BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB")
self.check([(link, "M"), ("", "M"), ("Kokkoja", "M"), ("Mahendra & Artha", "L"),
("Übung 1: äöü", "M")])
def test_every_version(self):
texts = []
for level in "LMQH":
for version in range(1, 41):
# The longest text of that version, so that the block
# structure is filled, and a short one with much padding.
full = longest_text(version, level)
texts.append((full, level))
texts.append((full[: len(full) // 2 + 1], level))
self.check(texts)
if __name__ == "__main__":
unittest.main()