Kokkoja as of 2026-10-10: the teacher's requests, the Session window, notices, the QR code, the license
This commit is contained in:
1 parent
95c80d04c9
commit
1d087af633
32 files changed
+4893
-866
No files matched your search
+703
@@ -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,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
|
||||
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
|
||||
|
||||
@@ -43,5 +45,7 @@ Documents directory.
|
||||
* `relay/`: the relay server, Python with the `websockets` library.
|
||||
* `deploy/`: how a Kokkoja server is set up (relay plus web site).
|
||||
* `docs/`: the documentation above.
|
||||
* `tests/`: the tests (`python3 -m unittest discover tests`; the
|
||||
end-to-end test needs Firefox and geckodriver).
|
||||
* `tests/`: the tests (`python3 -m unittest discover tests`); the
|
||||
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.
|
||||
+62
-34
@@ -15,17 +15,19 @@
|
||||
<input id="name" type="text" maxlength="40" autocomplete="off">
|
||||
|
||||
<label for="notebook">Notebook for the pages of this session</label>
|
||||
<input id="notebook" type="text" maxlength="100" autocomplete="off" list="notebook_choices">
|
||||
<datalist id="notebook_choices"></datalist>
|
||||
<select id="notebook" class="notebook_pick"
|
||||
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>
|
||||
|
||||
<section>
|
||||
<section id="start_choice">
|
||||
<h2>Start a session</h2>
|
||||
<p>You will be given an invitation to send to the others.</p>
|
||||
<button id="start_session" type="button">Start a session</button>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<section id="join_choice">
|
||||
<h2>Join a session</h2>
|
||||
<label for="invitation_entry">Invitation</label>
|
||||
<input id="invitation_entry" type="text" autocomplete="off" spellcheck="false">
|
||||
@@ -42,9 +44,11 @@
|
||||
<button type="button" data-tool="pen" aria-pressed="true"
|
||||
data-help="Write on the page. Only your own writing can be erased.">Pen</button>
|
||||
<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"
|
||||
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 class="group" id="colors" role="radiogroup" aria-label="Color">
|
||||
<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"
|
||||
aria-label="Text size in points" data-help="Text size in points.">
|
||||
</div>
|
||||
<div class="toolbar_right">
|
||||
<div class="group" role="radiogroup" aria-label="Zoom">
|
||||
<button id="zoom_fit" type="button" aria-pressed="true"
|
||||
data-help="Show the whole page in the window. Ctrl and the scroll wheel zoom in and out.">Fit</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>
|
||||
<button id="zoom_out" type="button" data-help="Zoom out by a step.">−</button>
|
||||
<div class="group" aria-label="View">
|
||||
<button id="zoom_fit" type="button"
|
||||
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>
|
||||
<button id="zoom_sheet" type="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_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 class="toolbar_right">
|
||||
<div class="group" id="host_tools" hidden>
|
||||
<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>
|
||||
<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"
|
||||
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>
|
||||
<span id="page_number" class="page_number"></span>
|
||||
<span id="status" class="status"></span>
|
||||
<div class="group" aria-label="Notebook">
|
||||
<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>
|
||||
</header>
|
||||
<div id="notice" class="notice" hidden></div>
|
||||
|
||||
<div class="workspace">
|
||||
<div id="desk" class="desk">
|
||||
<div id="page" class="page"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="panel">
|
||||
<h2>Participants</h2>
|
||||
<dialog id="session_dialog" class="chooser session">
|
||||
<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>
|
||||
|
||||
<h2>Invitation</h2>
|
||||
<h3>Invitation</h3>
|
||||
<div class="invitation_line">
|
||||
<input id="invitation" type="text" readonly spellcheck="false">
|
||||
<button id="copy_invitation" type="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>
|
||||
</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>
|
||||
|
||||
<p id="pen_report" class="report"></p>
|
||||
</aside>
|
||||
<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">
|
||||
<header>
|
||||
@@ -136,9 +167,6 @@
|
||||
<nav id="files_directories" class="files_directories"></nav>
|
||||
<div id="files_entries" class="files_entries"></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>
|
||||
<input id="files_name" type="text" spellcheck="false" autocomplete="off"
|
||||
aria-label="File name">
|
||||
|
||||
@@ -137,9 +137,8 @@ export const backend = {
|
||||
async keepPage(notebook) {
|
||||
return transaction('readwrite', async (pages) => {
|
||||
const current = /** @type {StoredPage | undefined} */ (await result(pages.get([notebook, CURRENT_PAGE])));
|
||||
if (!current || !(current.text.includes('data-stroke=') || current.text.includes('<image'))) {
|
||||
return { kept: null, new: false };
|
||||
}
|
||||
const blank = !['data-stroke=', 'data-text=', '<image'].some((mark) => current?.text.includes(mark));
|
||||
if (!current || blank) return { kept: null, new: false };
|
||||
const kept = (await pagesOf(pages, notebook)).filter((page) => KEPT_PAGE.test(page.name));
|
||||
const same = kept.find((page) => page.text === current.text);
|
||||
if (same) return { kept: same.name, new: false };
|
||||
|
||||
@@ -53,6 +53,8 @@ export class NotebookChooser {
|
||||
try {
|
||||
const { notebooks } = await listNotebooks();
|
||||
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
|
||||
: notebooks.length === 1 ? notebooks[0] : '';
|
||||
await this.showPages(chosen);
|
||||
@@ -65,7 +67,6 @@ export class NotebookChooser {
|
||||
showNotebooks(notebooks) {
|
||||
const list = element('chooser_notebooks');
|
||||
list.replaceChildren();
|
||||
list.hidden = notebooks.length <= 1;
|
||||
for (const notebook of notebooks) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
|
||||
@@ -13,9 +13,6 @@ import { listFiles, readFile } from './files.js';
|
||||
/** Where the chooser was last used, for each of its two purposes. */
|
||||
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. */
|
||||
const SMALL_PICTURE_SIDE = 240;
|
||||
/** 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.directoryField = /** @type {HTMLInputElement} */ (element('files_directory'));
|
||||
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'} */
|
||||
this.purpose = 'image';
|
||||
/** @type {import('./files.js').DirectoryListing | null} */
|
||||
@@ -84,14 +76,6 @@ export class FileChooser {
|
||||
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.
|
||||
* @param {string} title
|
||||
@@ -119,7 +103,6 @@ export class FileChooser {
|
||||
this.purpose = purpose;
|
||||
element('files_title').textContent = title;
|
||||
element('files_save').hidden = purpose !== 'export';
|
||||
element('files_shrink_line').hidden = purpose !== 'image';
|
||||
element('files_directories').replaceChildren();
|
||||
element('files_entries').replaceChildren();
|
||||
this.dialog.showModal();
|
||||
|
||||
@@ -11,13 +11,21 @@
|
||||
/** Resolutions outside this range are mistakes, not information. */
|
||||
const LEAST_DPI = 30;
|
||||
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
|
||||
* @returns {number | null}
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -13,6 +13,7 @@ import {
|
||||
readCurrentPage, readIdentity, readPage, rememberToken, useBrowserStorage,
|
||||
} from './notebook.js';
|
||||
import { download, pickImageFile } from './browser_files.js';
|
||||
import { DEFAULT_NOTEBOOK, NotebookPicker } from './notebook_picker.js';
|
||||
import { pageFromSvg, pageToSvg } from './page_file.js';
|
||||
import { hashOf } from './crypto.js';
|
||||
import { imageResolution } from './image_info.js';
|
||||
@@ -21,7 +22,10 @@ import {
|
||||
UNITS_PER_POINT, strokesTouched, textAt, textsTouched,
|
||||
} from './page_model.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 { NotebookChooser } from './chooser.js';
|
||||
import { FileChooser } from './file_chooser.js';
|
||||
@@ -29,13 +33,23 @@ import { writePicture } from './files.js';
|
||||
import { pageToPng } from './page_picture.js';
|
||||
import { TextEditor } from './text_editor.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). */
|
||||
const DEFAULT_COLOR = COLORS[0];
|
||||
|
||||
/** Nominal line widths in page units (0.1 mm). */
|
||||
const WIDTHS = [4, 7, 12];
|
||||
/** Nominal line widths in page units (0.1 mm); the last is twice the one before (2026-10-08). */
|
||||
const WIDTHS = [4, 7, 12, 24];
|
||||
|
||||
/** Whether the page grows when one's writing reaches an edge: 'no' if not. */
|
||||
const EXTEND_KEY = 'kokkoja_extend';
|
||||
|
||||
/** Radius of the eraser on the screen, in CSS pixels. */
|
||||
const ERASER_RADIUS = 10;
|
||||
@@ -66,7 +80,6 @@ const PALETTE = [
|
||||
'#ffffff', '#bdbdbd', '#757575', '#424242', '#8d6e63', '#5d4037', '#3e2723', '#000000',
|
||||
];
|
||||
const NOTEBOOK_KEY = 'kokkoja_notebook';
|
||||
const DEFAULT_NOTEBOOK = 'Notebook';
|
||||
|
||||
/**
|
||||
* Where the installed Kokkoja program serves the client, for a guest who
|
||||
@@ -104,6 +117,20 @@ function input(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
|
||||
* 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_message').textContent = message;
|
||||
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();
|
||||
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
|
||||
// prefer it, for a notebook of real files.
|
||||
const installed = /** @type {HTMLAnchorElement} */ (element('open_installed'));
|
||||
installed.hidden = !(pending && notebooksInBrowser());
|
||||
installed.href = `${INSTALLED_ADDRESS}#join=${encodeURIComponent(pending)}`;
|
||||
showNotebooks().catch((error) => {
|
||||
showNotebooks(startPicker).catch((error) => {
|
||||
element('notebook_note').textContent = String(error.message ?? error);
|
||||
element('notebook_note').classList.add('problem');
|
||||
});
|
||||
}
|
||||
|
||||
/** Offer the existing notebooks and say where notebooks are kept. */
|
||||
async function showNotebooks() {
|
||||
/**
|
||||
* 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 choices = element('notebook_choices');
|
||||
choices.replaceChildren();
|
||||
for (const notebook of notebooks) {
|
||||
const option = document.createElement('option');
|
||||
option.value = notebook;
|
||||
choices.append(option);
|
||||
}
|
||||
const current = picker.value;
|
||||
picker.fill(notebooks, current);
|
||||
picker.current = current;
|
||||
for (const note of document.querySelectorAll('.notebook_directory')) {
|
||||
note.textContent = directory ? `Notebooks are kept in ${directory}`
|
||||
: 'Notebooks are kept in this browser, on this computer. “Keep page” also downloads the page.';
|
||||
@@ -169,6 +203,96 @@ function addressValues() {
|
||||
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 ''. */
|
||||
function invitationInAddress() {
|
||||
return addressValues().get('join') ?? '';
|
||||
@@ -195,9 +319,9 @@ function enteredName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
/** @returns {string} The notebook entered. */
|
||||
/** @returns {string} The notebook chosen. */
|
||||
function enteredNotebook() {
|
||||
const notebook = input('notebook').value.trim() || DEFAULT_NOTEBOOK;
|
||||
const notebook = startPicker.value || DEFAULT_NOTEBOOK;
|
||||
localStorage.setItem(NOTEBOOK_KEY, notebook);
|
||||
return notebook;
|
||||
}
|
||||
@@ -327,9 +451,10 @@ async function enterSession(invitationText, name, notebook) {
|
||||
// installed. The link carries the invitation itself.
|
||||
const plainInvitation = formatInvitation(invitation);
|
||||
input('invitation').value = config.site ? formatInvitationLink(config.site, plainInvitation) : plainInvitation;
|
||||
showInvitationCode(config.site ? input('invitation').value : '');
|
||||
|
||||
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 width = WIDTHS[1];
|
||||
let textSize = TEXT_SIZE;
|
||||
@@ -353,9 +478,12 @@ async function enterSession(invitationText, name, notebook) {
|
||||
const fileOf = (page) => pageToSvg(page, { session: roomId, hostId: invitation.hostId });
|
||||
|
||||
function showPageNumber() {
|
||||
element('page_number').textContent = session.page.number > 0
|
||||
? `Page ${session.page.number}` : 'Waiting for the host’s page';
|
||||
const waiting = session.page.number === 0;
|
||||
element('page_number').textContent = waiting ? 'No page yet' : `Page ${session.page.number}`;
|
||||
standingNotice('page', waiting ? 'Waiting for the host’s page' : '', false);
|
||||
}
|
||||
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
||||
let connectionNoticeTimer;
|
||||
|
||||
const session = new Session(
|
||||
{
|
||||
@@ -379,7 +507,15 @@ async function enterSession(invitationText, name, notebook) {
|
||||
writer.changed();
|
||||
},
|
||||
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.
|
||||
writer.pageReplaced(oldPage.number > 0 ? fileOf(oldPage) : null);
|
||||
renderer.setPage(newPage);
|
||||
@@ -389,17 +525,25 @@ async function enterSession(invitationText, name, notebook) {
|
||||
showPageNumber();
|
||||
},
|
||||
onImagesChanged: () => {
|
||||
log(`Page ${session.page.number}: ${session.page.images.length} images, ${session.page.hides.length} hides`);
|
||||
renderer.showImages();
|
||||
renderer.drawHides();
|
||||
writer.writeNow();
|
||||
},
|
||||
onPageResized: (byImages) => {
|
||||
// An image added or taken back does not rescale what is in view.
|
||||
if (byImages) renderer.holdScale();
|
||||
onPageResized: () => {
|
||||
// The view keeps its scale; the page extends beyond the window.
|
||||
renderer.resize();
|
||||
textEditor?.layout();
|
||||
writer.changed();
|
||||
},
|
||||
onViewCommand: (command) => {
|
||||
log(`The host's view: ${command === 'fit' ? 'Fit' : 'A4'}`);
|
||||
if (command === 'fit') renderer.fit();
|
||||
else renderer.sheetScale();
|
||||
},
|
||||
onPermissionsChanged: () => {
|
||||
log(`May start pages: ${session.mayMakePages() ? 'yes' : 'no'}` +
|
||||
(session.isHost ? `; guests allowed: ${session.permitted.size}` : ''));
|
||||
showHostTools();
|
||||
showParticipants(session, renderer);
|
||||
},
|
||||
@@ -409,19 +553,43 @@ async function enterSession(invitationText, name, notebook) {
|
||||
},
|
||||
onStatus: (status, detail) => {
|
||||
const text = { connecting: 'Connecting…', connected: 'Connected', failed: detail ?? 'Disconnected' };
|
||||
log(`Relay: ${text[status]}`);
|
||||
element('status').textContent = text[status];
|
||||
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(
|
||||
notebook,
|
||||
() => (session.page.number > 0 ? fileOf(session.page) : null),
|
||||
(message, problem) => {
|
||||
element('save_status').textContent = message;
|
||||
element('save_status').classList.toggle('problem', problem);
|
||||
{
|
||||
onSaved: (time) => {
|
||||
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');
|
||||
renderer = new Renderer(pageElement, session.page);
|
||||
@@ -436,75 +604,305 @@ async function enterSession(invitationText, name, notebook) {
|
||||
textEditor?.layout();
|
||||
}).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, {
|
||||
setText: (block) => session.setText(block),
|
||||
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),
|
||||
tool: () => tool,
|
||||
canWrite: () => session.page.number > 0,
|
||||
beginStroke: () => {
|
||||
textEditor?.stop();
|
||||
renderer.holdFit = true;
|
||||
session.beginStroke(color, width);
|
||||
strokeInProgress = session.beginStroke(color, width);
|
||||
},
|
||||
addPoints: (points) => {
|
||||
const inside = withinPage(points);
|
||||
if (inside.length) session.addPoints(inside);
|
||||
},
|
||||
addPoints: (points) => session.addPoints(points),
|
||||
endStroke: () => {
|
||||
strokeInProgress = null;
|
||||
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) => {
|
||||
textEditor?.stop();
|
||||
const radius = renderer.pageLength(ERASER_RADIUS);
|
||||
session.erase(strokesTouched(session.ownLayer, x0, y0, x1, y1, radius));
|
||||
session.removeTexts(textsTouched(session.ownLayer, x0, y0, x1, y1, radius));
|
||||
const strokes = strokesTouched(session.ownLayer, x0, y0, x1, y1, radius);
|
||||
const texts = textsTouched(session.ownLayer, x0, y0, x1, y1, radius);
|
||||
session.erase(strokes);
|
||||
session.removeTexts(texts);
|
||||
return { strokes: strokes.length, texts: texts.length };
|
||||
},
|
||||
eraseEnded: (x, y, reach) => {
|
||||
// A tap of the eraser on the image we have just added takes it back.
|
||||
const image = session.removableImage;
|
||||
if (image && reach < TAP_DISTANCE && x >= image.x && x <= image.x + image.width &&
|
||||
y >= image.y && y <= image.y + image.height) {
|
||||
session.removeAddedImage();
|
||||
// A tap of the eraser on a Hide rectangle or an image removes it
|
||||
// (the right to make pages, or having added the image).
|
||||
if (reach >= TAP_DISTANCE) return;
|
||||
const hide = session.page.hideAt(x, y, 0);
|
||||
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) => {
|
||||
if (Math.abs(x1 - x0) >= TAP_DISTANCE) textEditor?.showPreview(x0, y0, x1);
|
||||
else textEditor?.hidePreview();
|
||||
},
|
||||
placeText: (x0, y0, x1, y1) => {
|
||||
placeRect: (x0, y0, x1, y1) => {
|
||||
if (!textEditor) return;
|
||||
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;
|
||||
textEditor.stop();
|
||||
const own = textAt(session.ownLayer, x0, y0);
|
||||
if (Math.hypot(x1 - x0, y1 - y0) < TAP_DISTANCE) {
|
||||
// A tap: into our own text to change it, or a new block here.
|
||||
if (!dragged) {
|
||||
// A tap: into our own text to change it, or free text here. A
|
||||
// tap elsewhere while typing only finishes the block (2026-10-09).
|
||||
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 {
|
||||
// A drag along the top edge: a new block that wraps at its width.
|
||||
textEditor.begin(Math.min(x0, x1), y0, Math.max(100, Math.round(Math.abs(x1 - x0))),
|
||||
// A rectangle dragged open: at least one line tall.
|
||||
const line = Math.ceil(textSize * (TOP_GAP + LINE_HEIGHT));
|
||||
textEditor.begin(rect.left, rect.top, rect.width, Math.max(rect.height, line),
|
||||
{ color, size: textSize, font: textFont });
|
||||
}
|
||||
},
|
||||
onReport: (report) => {
|
||||
element('pen_report').textContent =
|
||||
`Input: ${report.pointerType}, pressure ${report.pressure.toFixed(2)}, ` +
|
||||
`${report.pointsPerSecond} points per second`;
|
||||
// To the console once, and again when the kind of device changes.
|
||||
if (report.pointerType === reportedDevice || report.pointsPerSecond === 0) return;
|
||||
reportedDevice = report.pointerType;
|
||||
log(`Input: ${report.pointerType}, pressure ${report.pressure.toFixed(2)}, ` +
|
||||
`${report.pointsPerSecond} points per second`);
|
||||
},
|
||||
});
|
||||
};
|
||||
attachPenInput(pageElement, penTarget);
|
||||
|
||||
buildToolbar({
|
||||
color, width, textSize, textFont,
|
||||
setTool: (value) => {
|
||||
if (value !== 'text') textEditor?.stop();
|
||||
if (value !== tool) trace(`Tool: ${value}`);
|
||||
tool = value;
|
||||
// Only the chosen tool's settings show: the pen's colours and line
|
||||
// widths, the text's colours, size and font, nothing for the eraser.
|
||||
element('colors').hidden = value === 'eraser';
|
||||
element('colors').hidden = value === 'eraser' || value === 'hide';
|
||||
element('widths').hidden = value !== 'pen';
|
||||
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.
|
||||
const showZoom = () => {
|
||||
element('zoom_fit').setAttribute('aria-pressed', String(renderer.zoom === 'fit'));
|
||||
};
|
||||
// A reloaded tab shows the page as it was last seen in it (Andy,
|
||||
// 2026-10-05: the least surprise): the zoom and the place scrolled to
|
||||
// are remembered in the tab, for the page they belong to.
|
||||
@@ -541,14 +936,31 @@ async function enterSession(invitationText, name, notebook) {
|
||||
viewTimer = setTimeout(saveView, 300);
|
||||
};
|
||||
renderer.onZoomChanged = () => {
|
||||
showZoom();
|
||||
textEditor?.layout();
|
||||
viewChanged();
|
||||
};
|
||||
element('zoom_fit').addEventListener('click', () => renderer.setZoom('fit'));
|
||||
element('zoom_in').addEventListener('click', () => renderer.zoomBy(ZOOM_STEP));
|
||||
element('zoom_out').addEventListener('click', () => renderer.zoomBy(1 / ZOOM_STEP));
|
||||
showZoom();
|
||||
// The host's Fit and A4 reach everyone (Andy, 2026-10-08); a guest's
|
||||
// are the guest's own.
|
||||
element('zoom_fit').addEventListener('click', () => {
|
||||
renderer.fit();
|
||||
session.sendViewCommand('fit');
|
||||
});
|
||||
element('zoom_sheet').addEventListener('click', () => {
|
||||
renderer.sheetScale();
|
||||
session.sendViewCommand('sheet');
|
||||
});
|
||||
// "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 {
|
||||
const seen = JSON.parse(sessionStorage.getItem(viewKey) ?? 'null');
|
||||
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.
|
||||
function showHostTools() {
|
||||
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();
|
||||
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 {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;
|
||||
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.');
|
||||
}
|
||||
} catch (error) {
|
||||
element('save_status').textContent =
|
||||
error instanceof Error && error.message ? error.message : 'That file is not an image.';
|
||||
element('save_status').classList.add('problem');
|
||||
passingNotice(error instanceof Error && error.message ? error.message : 'That file is not an image.', true);
|
||||
}
|
||||
}
|
||||
const chooser = new NotebookChooser((chosenNotebook, pageName, text) => {
|
||||
const kept = pageFromSvg(text);
|
||||
if (!kept) {
|
||||
element('save_status').textContent = `${pageName} is not a Kokkoja page.`;
|
||||
element('save_status').classList.add('problem');
|
||||
passingNotice(`${pageName} is not a Kokkoja page.`, true);
|
||||
return;
|
||||
}
|
||||
log(`Notebook page loaded: ${chosenNotebook} / ${pageName}`);
|
||||
session.loadPage(kept.page);
|
||||
});
|
||||
element('load_notebook_page').addEventListener('click', () => { chooser.open(writer.notebook); });
|
||||
@@ -608,7 +1027,7 @@ async function enterSession(invitationText, name, notebook) {
|
||||
return;
|
||||
}
|
||||
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()) {
|
||||
download(`${day}_page_${page.number}.png`,
|
||||
await pageToPng(page, { session: roomId, hostId: session.hostId, hidden: renderer.hidden }));
|
||||
element('save_status').textContent = 'Exported to your downloads';
|
||||
element('save_status').classList.remove('problem');
|
||||
log(`Exported page ${page.number} to the downloads`);
|
||||
passingNotice('Exported to your downloads');
|
||||
return;
|
||||
}
|
||||
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 }),
|
||||
replace));
|
||||
if (saved === null) return;
|
||||
element('save_status').textContent = `Exported to ${saved}`;
|
||||
element('save_status').classList.remove('problem');
|
||||
log(`Exported page ${page.number} to ${saved}`);
|
||||
passingNotice(`Exported to ${saved}`);
|
||||
}
|
||||
element('export_page').addEventListener('click', () => { exportPage(); });
|
||||
|
||||
// The notebook: its name, and keeping the page.
|
||||
const notebookField = input('session_notebook');
|
||||
notebookField.value = notebook;
|
||||
showNotebooks().catch(() => {});
|
||||
notebookField.addEventListener('change', () => {
|
||||
const chosen = notebookField.value.trim();
|
||||
if (!chosen || chosen === writer.notebook) {
|
||||
notebookField.value = writer.notebook;
|
||||
return;
|
||||
}
|
||||
// The notebook: choosing one, making one, and keeping the page.
|
||||
const sessionPicker = new NotebookPicker(
|
||||
select('session_notebook'), input('session_notebook_name'),
|
||||
(message) => { if (message) passingNotice(message, true); },
|
||||
(chosen) => {
|
||||
if (chosen === writer.notebook) return;
|
||||
sessionStorage.setItem(notebookKey, chosen);
|
||||
localStorage.setItem(NOTEBOOK_KEY, chosen);
|
||||
writer.changeNotebook(chosen, (text) => {
|
||||
const saved = pageFromSvg(text);
|
||||
return saved !== null && saved.session === roomId && saved.page.id === session.page.id;
|
||||
}).then(() => showNotebooks()).catch(() => {});
|
||||
}).then(() => showNotebooks(sessionPicker)).catch(() => {});
|
||||
});
|
||||
sessionPicker.fill([], notebook);
|
||||
sessionPicker.current = notebook;
|
||||
showNotebooks(sessionPicker).catch(() => {});
|
||||
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
|
||||
// programs: a kept page is also downloaded, which is how a page gets out.
|
||||
if (kept && notebooksInBrowser()) {
|
||||
// programs: a page kept just now is also downloaded, which is how a
|
||||
// page gets out. One already kept was downloaded then, and is not
|
||||
// again (Andy, 2026-10-06: a second press should do nothing new).
|
||||
if (kept && made && notebooksInBrowser()) {
|
||||
const text = await readPage(writer.notebook, kept);
|
||||
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 () => {
|
||||
const field = input('invitation');
|
||||
field.select();
|
||||
@@ -716,7 +1161,7 @@ function press(group, chosen) {
|
||||
* @param {number} options.width Initial width.
|
||||
* @param {number} options.textSize
|
||||
* @param {import('./page_model.js').TextFont} options.textFont
|
||||
* @param {(tool: 'pen' | 'eraser' | 'text') => void} options.setTool
|
||||
* @param {(tool: 'pen' | 'eraser' | 'text' | 'hide') => void} options.setTool
|
||||
* @param {(color: string) => void} options.setColor
|
||||
* @param {(width: number) => void} options.setWidth
|
||||
* @param {(size: number) => void} options.setTextSize
|
||||
@@ -727,7 +1172,7 @@ function buildToolbar(options) {
|
||||
button.addEventListener('click', () => {
|
||||
press(/** @type {Element} */ (button.parentElement), button);
|
||||
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);
|
||||
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) {
|
||||
if (taken.has(color)) {
|
||||
const gap = document.createElement('span');
|
||||
gap.className = 'swatch missing';
|
||||
palette.append(gap);
|
||||
continue;
|
||||
}
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'swatch';
|
||||
@@ -787,11 +1245,13 @@ function buildToolbar(options) {
|
||||
button.addEventListener('click', () => choose(color));
|
||||
palette.append(button);
|
||||
}
|
||||
};
|
||||
more.addEventListener('click', () => {
|
||||
if (!palette.hidden) {
|
||||
palette.hidden = true;
|
||||
return;
|
||||
}
|
||||
showPalette();
|
||||
const box = more.getBoundingClientRect();
|
||||
palette.style.left = `${box.left}px`;
|
||||
palette.style.top = `${box.bottom + 6}px`;
|
||||
@@ -861,15 +1321,17 @@ function showParticipants(session, renderer) {
|
||||
item.classList.toggle('absent', !present);
|
||||
|
||||
if (session.isHost && !participant.self) {
|
||||
const allow = document.createElement('button');
|
||||
allow.type = 'button';
|
||||
// A checkbox with its label to the left, like Extend (Andy, 2026-10-09).
|
||||
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.textContent = 'pages';
|
||||
allow.setAttribute('data-help', 'Let this participant start new pages and load images.');
|
||||
const allowed = session.permitted.has(participant.id);
|
||||
allow.setAttribute('aria-pressed', String(allowed));
|
||||
allow.addEventListener('click', () => session.setPermitted(participant.id, !allowed));
|
||||
item.append(allow);
|
||||
allow.checked = session.permitted.has(participant.id);
|
||||
allow.addEventListener('change', () => session.setPermitted(participant.id, allow.checked));
|
||||
allowLine.append('Co-host ', allow);
|
||||
item.append(allowLine);
|
||||
}
|
||||
|
||||
const label = document.createElement('label');
|
||||
@@ -884,6 +1346,7 @@ function showParticipants(session, renderer) {
|
||||
const roles = [];
|
||||
if (participant.self) roles.push('you');
|
||||
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 (roles.length) {
|
||||
const role = document.createElement('span');
|
||||
@@ -932,6 +1395,8 @@ async function resume() {
|
||||
}
|
||||
|
||||
enableTooltips();
|
||||
guardSelection();
|
||||
log(`Browser: ${navigator.userAgent}; window ${innerWidth}×${innerHeight}, pixel ratio ${devicePixelRatio}`);
|
||||
|
||||
const token = addressValues().get('token');
|
||||
if (token) {
|
||||
|
||||
+16
-15
@@ -205,12 +205,15 @@ export class NotebookWriter {
|
||||
* @param {string} notebook
|
||||
* @param {() => string | null} content Produces the file of the present
|
||||
* 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.content = content;
|
||||
this.onStatus = onStatus;
|
||||
this.events = events;
|
||||
/** @type {number | null} */
|
||||
this.timer = null;
|
||||
/** Time of the oldest change not yet written, or 0. */
|
||||
@@ -232,7 +235,7 @@ export class NotebookWriter {
|
||||
*/
|
||||
enqueue(work) {
|
||||
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.
|
||||
if (this.timer === null) this.timer = setTimeout(() => this.writeNow(), RETRY_MS);
|
||||
});
|
||||
@@ -272,27 +275,25 @@ export class NotebookWriter {
|
||||
const text = this.content();
|
||||
if (text === null) return;
|
||||
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.
|
||||
* @returns {Promise<string | null>} The name of the kept page, or null
|
||||
* if there was nothing to keep.
|
||||
* @returns {Promise<{kept: string | null, new: boolean}>} The name of
|
||||
* the kept page, or null if there was nothing to keep, and whether
|
||||
* the copy was made just now.
|
||||
*/
|
||||
keepPage() {
|
||||
this.writeNow();
|
||||
/** @type {string | null} */
|
||||
let kept = null;
|
||||
let result = { kept: /** @type {string | null} */ (null), new: false };
|
||||
return this.enqueue(async () => {
|
||||
const result = await keepCurrentPage(this.notebook);
|
||||
kept = result.kept;
|
||||
this.onStatus(
|
||||
result = await keepCurrentPage(this.notebook);
|
||||
this.events.onKept(
|
||||
result.kept === null ? 'The page is blank: nothing to keep'
|
||||
: result.new ? `Kept as ${result.kept}` : `Already kept as ${result.kept}`,
|
||||
false);
|
||||
}).then(() => kept);
|
||||
: result.new ? `Kept as ${result.kept}` : `Already kept as ${result.kept}`);
|
||||
}).then(() => result);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -28,15 +28,22 @@
|
||||
|
||||
import { fromBase64, toBase64 } from './crypto.js';
|
||||
import {
|
||||
Page, POINT_SIZE, cleanImage, cleanName, cleanPageDescriptor, cleanStroke, cleanText,
|
||||
pointCount, validId,
|
||||
POINT_SIZE, Page, cleanHide, cleanImage, cleanName, cleanPageDescriptor, cleanStroke, cleanText, pointCount, textBounds, validId,
|
||||
} from './page_model.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 */
|
||||
|
||||
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';
|
||||
|
||||
/**
|
||||
@@ -58,7 +65,7 @@ function imageElement(image) {
|
||||
text = toBase64(image.data);
|
||||
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}"` +
|
||||
` preserveAspectRatio="none" href="data:${image.type};base64,${text}"/>`;
|
||||
}
|
||||
@@ -86,15 +93,26 @@ function escapedContent(text) {
|
||||
* @returns {string}
|
||||
*/
|
||||
function textElements(block) {
|
||||
// Each line at its baseline, placed as on the screen.
|
||||
const lines = block.lines.map((line, i) =>
|
||||
`<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}"` +
|
||||
` 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}">` +
|
||||
`<desc>${escapedContent(block.text)}</desc>` +
|
||||
`<desc>${escapedContent(block.text)}</desc>${frame}` +
|
||||
`<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}"` +
|
||||
` data-session="${escaped(session)}" data-host="${escaped(hostId)}"` +
|
||||
` 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}"` +
|
||||
` width="${page.width / 100}cm" height="${page.height / 100}cm">`,
|
||||
`<title>Kokkoja page ${page.number}</title>`,
|
||||
@@ -164,6 +184,20 @@ export function pageToSvg(page, { session, hostId, hidden }) {
|
||||
const shown = imageElement(image);
|
||||
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)) {
|
||||
const layer = page.layers.get(id);
|
||||
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));
|
||||
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>', '');
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -195,14 +238,19 @@ export function pageFromSvg(text) {
|
||||
if (box.length !== 4 || !(box[2] > 0) || !(box[3] > 0)) return null;
|
||||
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({
|
||||
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),
|
||||
});
|
||||
if (!descriptor) return null;
|
||||
|
||||
const page = new Page(descriptor.width, descriptor.height, descriptor.id, descriptor.number);
|
||||
page.baseLeft = descriptor.left;
|
||||
page.baseTop = descriptor.top;
|
||||
page.imagesRevision = descriptor.revision;
|
||||
/** @type {Map<string, Uint8Array<ArrayBuffer>>} An image shown twice is held once. */
|
||||
const held = new Map();
|
||||
@@ -215,11 +263,20 @@ export function pageFromSvg(text) {
|
||||
type: address[1], size: data.length, hash,
|
||||
x: Number(shown.getAttribute('x')), y: Number(shown.getAttribute('y')),
|
||||
width: Number(shown.getAttribute('width')), height: Number(shown.getAttribute('height')),
|
||||
by: shown.getAttribute('data-by') ?? '',
|
||||
});
|
||||
if (!image) continue;
|
||||
held.set(hash, 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]')) {
|
||||
const layerId = group.getAttribute('data-layer');
|
||||
if (!validId(layerId)) continue;
|
||||
@@ -237,11 +294,13 @@ export function pageFromSvg(text) {
|
||||
}
|
||||
for (const element of group.querySelectorAll('g[data-text]')) {
|
||||
const widthText = element.getAttribute('data-width') ?? '';
|
||||
const heightText = element.getAttribute('data-height') ?? '';
|
||||
const box = (element.getAttribute('data-box') ?? '').split(' ').map(Number);
|
||||
const block = cleanText({
|
||||
id: element.getAttribute('data-text'),
|
||||
x: Number(element.getAttribute('data-x')), y: Number(element.getAttribute('data-y')),
|
||||
width: widthText === '' ? null : Number(widthText),
|
||||
height: heightText === '' ? null : Number(heightText),
|
||||
size: Number(element.getAttribute('data-size')),
|
||||
font: element.getAttribute('data-font') ?? 'sans-serif',
|
||||
color: element.querySelector('text')?.getAttribute('fill'),
|
||||
|
||||
+252
-62
@@ -8,21 +8,24 @@
|
||||
*/
|
||||
|
||||
/** A blank page is A4. */
|
||||
export const BLANK_PAGE_WIDTH = 2100;
|
||||
export const BLANK_PAGE_HEIGHT = 2970;
|
||||
// An A4 sheet in landscape (Andy, 2026-10-08; portrait until then).
|
||||
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,
|
||||
* 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
|
||||
* choice; it is deliberately a separate setting from WRITING_MARGIN.
|
||||
* An image is placed this far inside the sheet's edge, and below the
|
||||
* 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
|
||||
@@ -45,6 +48,11 @@ export const POINT_SIZE = 3;
|
||||
* @property {number} x
|
||||
* @property {number} y
|
||||
* @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[]} lines As shown, laid out by the writer's client.
|
||||
* @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.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.height != null && !(received.height >= 10 && received.height <= 10 * MAX_PAGE_SIZE)) ||
|
||||
!(received.size >= 10 && received.size <= 400) ||
|
||||
!TEXT_FONTS.includes(received.font) ||
|
||||
!(received.boxWidth >= 0 && received.boxWidth <= MAX_PAGE_SIZE) ||
|
||||
!(received.boxHeight >= 0 && received.boxHeight <= 10 * MAX_PAGE_SIZE)) return null;
|
||||
return {
|
||||
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,
|
||||
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 {
|
||||
/**
|
||||
* @param {string} id The participant's id.
|
||||
@@ -205,6 +258,9 @@ export class Layer {
|
||||
* @property {number} y
|
||||
* @property {number} width
|
||||
* @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
|
||||
* have arrived from another participant.
|
||||
*/
|
||||
@@ -225,11 +281,17 @@ function withoutData(image) {
|
||||
* @typedef {object} PageDescriptor
|
||||
* @property {string} id
|
||||
* @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} height
|
||||
* @property {Omit<PageImage, 'data'>[]} images From the top of the page down.
|
||||
* @property {number} revision Counts the changes made to `images`, so
|
||||
* that of two lists the later one can be told.
|
||||
* @property {Omit<PageImage, 'data'>[]} images In stacking order, the last in front.
|
||||
* @property {HideRect[]} hides The Hide rectangles, in stacking order.
|
||||
* @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;
|
||||
return {
|
||||
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
|
||||
* 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).
|
||||
* Where an image goes on the page, and the sheet that then holds it.
|
||||
*
|
||||
* An image wider than the sheet's width inside the margin is scaled down
|
||||
* to it, or, if `shrink` is false, keeps its natural size: it then begins
|
||||
* at the left margin and the sheet is widened to hold it (Andy,
|
||||
* 2026-10-05, as a choice to try beside the scaling).
|
||||
* Below the lowest image on the page, or at the page's top if there is
|
||||
* none, the margin apart, centred across the page's width, at its
|
||||
* natural size: one way for every image (Andy, 2026-10-08, dropping the
|
||||
* 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} naturalHeight
|
||||
* @param {number} [below] The bottom edge of the lowest image already on
|
||||
* the page; 0 if there is none.
|
||||
* @param {boolean} [shrink]
|
||||
* @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.
|
||||
* @returns {{x: number, y: number, width: number, height: number, sheet: Rectangle}}
|
||||
* The image's rectangle, and the sheet that holds it and everything
|
||||
* the sheet held before.
|
||||
*/
|
||||
export function placeImage(naturalWidth, naturalHeight, below = 0, shrink = true) {
|
||||
const room = BLANK_PAGE_WIDTH - 2 * IMAGE_MARGIN;
|
||||
// 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);
|
||||
export function placeImage(page, naturalWidth, naturalHeight) {
|
||||
const scale = Math.min(1, (MAX_PAGE_SIZE - 2 * IMAGE_MARGIN) / naturalWidth);
|
||||
const width = Math.round(naturalWidth * 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;
|
||||
return {
|
||||
x, y, width, height,
|
||||
pageWidth: Math.max(BLANK_PAGE_WIDTH, x + width + IMAGE_MARGIN),
|
||||
pageHeight: Math.max(BLANK_PAGE_HEIGHT, y + height + IMAGE_MARGIN),
|
||||
};
|
||||
return { x, y, width, height, sheet: sheetHolding(page, { left: x, top: y, width, height }) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @param {any} received
|
||||
@@ -320,9 +419,20 @@ export function cleanPageDescriptor(received) {
|
||||
if (!image) return null;
|
||||
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 {
|
||||
id: received.id, number: received.number, width: received.width, height: received.height,
|
||||
images,
|
||||
id: received.id, number: received.number, left: edge(received.left), top: edge(received.top),
|
||||
width: received.width, height: received.height,
|
||||
images, hides,
|
||||
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
|
||||
* if its images need the room.
|
||||
*/
|
||||
this.baseLeft = 0;
|
||||
this.baseTop = 0;
|
||||
this.baseWidth = width;
|
||||
this.baseHeight = height;
|
||||
this.id = id;
|
||||
this.number = number;
|
||||
/** @type {PageImage[]} From the top of the page down. */
|
||||
this.images = [];
|
||||
/** @type {HideRect[]} In stacking order. */
|
||||
this.hides = [];
|
||||
/** Counts the changes made to `images`. */
|
||||
this.imagesRevision = 0;
|
||||
/** The page's present rectangle. See updateExtent(). */
|
||||
@@ -362,28 +476,47 @@ export class Page {
|
||||
/** @returns {PageDescriptor} */
|
||||
descriptor() {
|
||||
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),
|
||||
hides: this.hides.map((hide) => ({ ...hide })),
|
||||
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. */
|
||||
imagesBottom() {
|
||||
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. */
|
||||
isLargerThanSheet() {
|
||||
return this.baseWidth > BLANK_PAGE_WIDTH || this.baseHeight > BLANK_PAGE_HEIGHT;
|
||||
@@ -407,7 +540,38 @@ export class Page {
|
||||
* @returns {Rectangle}
|
||||
*/
|
||||
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
|
||||
*/
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
* 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;
|
||||
const b = this.strokeBounds;
|
||||
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;
|
||||
left = Math.min(left, Math.floor((b.left - WRITING_MARGIN) / step) * step);
|
||||
top = Math.min(top, Math.floor((b.top - WRITING_MARGIN) / step) * step);
|
||||
right = Math.max(right, Math.ceil((b.left + b.width + WRITING_MARGIN) / step) * step);
|
||||
bottom = Math.max(bottom, Math.ceil((b.top + b.height + WRITING_MARGIN) / step) * step);
|
||||
/** @param {number} short How far the writing's margin reaches past an edge. */
|
||||
const steps = (short) => (short > 0 ? Math.ceil(short / step) * step : 0);
|
||||
left -= steps(left - (b.left - WRITING_MARGIN));
|
||||
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 ||
|
||||
right - left !== this.width || bottom - top !== this.height;
|
||||
@@ -489,7 +676,10 @@ export class Page {
|
||||
*/
|
||||
static from(descriptor) {
|
||||
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.hides = descriptor.hides.map((hide) => ({ ...hide }));
|
||||
page.imagesRevision = descriptor.revision;
|
||||
page.updateExtent();
|
||||
return page;
|
||||
@@ -567,11 +757,12 @@ export function textsTouched(layer, x0, y0, x1, y1, radius) {
|
||||
const touched = [];
|
||||
const steps = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0) / radius));
|
||||
for (const block of layer.texts.values()) {
|
||||
const b = textBounds(block);
|
||||
for (let s = 0; s <= steps; s++) {
|
||||
const x = x0 + (x1 - x0) * s / steps;
|
||||
const y = y0 + (y1 - y0) * s / steps;
|
||||
if (x >= block.x - radius && x <= block.x + block.boxWidth + radius &&
|
||||
y >= block.y - radius && y <= block.y + block.boxHeight + radius) {
|
||||
if (x >= b.left - radius && x <= b.left + b.width + radius &&
|
||||
y >= b.top - radius && y <= b.top + b.height + radius) {
|
||||
touched.push(block.id);
|
||||
break;
|
||||
}
|
||||
@@ -588,9 +779,8 @@ export function textsTouched(layer, x0, y0, x1, y1, radius) {
|
||||
*/
|
||||
export function textAt(layer, x, y) {
|
||||
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) {
|
||||
return block;
|
||||
}
|
||||
const b = textBounds(block); // A text rectangle counts whole, empty part included.
|
||||
if (x >= b.left && x <= b.left + b.width && y >= b.top && y <= b.top + b.height) return block;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
+174
-21
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { rounded } from './page_model.js';
|
||||
import { trace } from './log.js';
|
||||
|
||||
/** Bit in PointerEvent.buttons for the eraser end of a pen. */
|
||||
const ERASER_BUTTON = 32;
|
||||
@@ -19,23 +20,53 @@ const NOMINAL_PRESSURE = 0.4;
|
||||
* @typedef {object} PenTarget
|
||||
* @property {(clientX: number, clientY: number) => [number, number]} toPage
|
||||
* 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 {() => void} beginStroke
|
||||
* @property {(points: number[]) => void} addPoints
|
||||
* @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]
|
||||
* The eraser went down at (x, y) and has come up again, having moved
|
||||
* no farther than `reach` from there.
|
||||
* @property {(x0: number, y0: number, x1: number, y1: number) => void} placeText
|
||||
* With the text tool, the pointer went down at (x0, y0) and up at (x1, y1).
|
||||
* @property {(x0: number, y0: number, x1: number) => void} [previewText]
|
||||
* 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} placeRect
|
||||
* With the text or Hide tool, the pointer went down at (x0, y0) and up
|
||||
* at (x1, y1): a tap, or a rectangle dragged open.
|
||||
* @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]
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @typedef {object} PenReport
|
||||
@@ -44,6 +75,13 @@ const NOMINAL_PRESSURE = 0.4;
|
||||
* @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 {PenTarget} target
|
||||
@@ -53,6 +91,28 @@ export function attachPenInput(surface, target) {
|
||||
let activePointer = null;
|
||||
let erasing = 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 textStartY = 0;
|
||||
let eraseStartX = 0;
|
||||
@@ -63,6 +123,9 @@ export function attachPenInput(surface, target) {
|
||||
let lastY = NaN;
|
||||
let strokeStart = 0;
|
||||
let strokePoints = 0;
|
||||
/** What the eraser removed since its press, for the trace. */
|
||||
let erasedStrokes = 0;
|
||||
let erasedTexts = 0;
|
||||
|
||||
/**
|
||||
* @param {PointerEvent} event
|
||||
@@ -103,35 +166,77 @@ export function attachPenInput(surface, target) {
|
||||
function erase(event) {
|
||||
for (const e of allEvents(event)) {
|
||||
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));
|
||||
lastX = x;
|
||||
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) => {
|
||||
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;
|
||||
// Once a pen has been used, a touch is taken to be the resting hand.
|
||||
if (event.pointerType === 'touch' && penSeen) return;
|
||||
if (event.pointerType === 'mouse' && event.button !== 0) return;
|
||||
if (event.pointerType === 'touch' && penSeen) return refuse('a touch after a pen: the resting hand');
|
||||
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();
|
||||
surface.setPointerCapture(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();
|
||||
erasing = tool === 'eraser' || (event.buttons & ERASER_BUTTON) !== 0;
|
||||
placingText = tool === 'text' && !erasing;
|
||||
placingText = (tool === 'text' || tool === 'hide') && !erasing;
|
||||
lastX = NaN;
|
||||
lastY = NaN;
|
||||
strokeStart = performance.now();
|
||||
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) {
|
||||
[textStartX, textStartY] = target.toPage(event.clientX, event.clientY);
|
||||
} else if (erasing) {
|
||||
[eraseStartX, eraseStartY] = target.toPage(event.clientX, event.clientY);
|
||||
eraseReach = 0;
|
||||
erase(event);
|
||||
if (target.beginHold) eraseWaiting = event;
|
||||
else erase(event);
|
||||
} else {
|
||||
target.beginStroke();
|
||||
draw(event);
|
||||
@@ -140,23 +245,53 @@ export function attachPenInput(surface, target) {
|
||||
|
||||
surface.addEventListener('pointermove', (event) => {
|
||||
if (event.pointerId !== activePointer) return;
|
||||
if (placingText) {
|
||||
const [x] = target.toPage(event.clientX, event.clientY);
|
||||
target.previewText?.(textStartX, textStartY, x);
|
||||
if (holding) {
|
||||
target.holdMove?.(event.clientX, event.clientY);
|
||||
return;
|
||||
}
|
||||
if (erasing) erase(event);
|
||||
else draw(event);
|
||||
if (holdTimer !== null && Math.hypot(event.clientX - pressX, event.clientY - pressY) > HOLD_SLACK_PX) {
|
||||
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 */
|
||||
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;
|
||||
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);
|
||||
if (event.type === 'pointerup') target.placeText(textStartX, textStartY, x, y);
|
||||
if (event.type === 'pointerup') target.placeRect(textStartX, textStartY, x, y);
|
||||
} else if (erasing) {
|
||||
eraseFromPress();
|
||||
if (event.type === 'pointerup') target.eraseEnded?.(eraseStartX, eraseStartY, eraseReach);
|
||||
} else {
|
||||
target.endStroke();
|
||||
@@ -164,7 +299,25 @@ export function attachPenInput(surface, target) {
|
||||
}
|
||||
surface.addEventListener('pointerup', 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.
|
||||
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 });
|
||||
}
|
||||
@@ -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
@@ -10,9 +10,10 @@
|
||||
* 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 { 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').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%,
|
||||
* Andy's first choice to try, 2026-10-03).
|
||||
* One click of the scroll wheel zooms by this factor (10%, Andy's choice
|
||||
* to try, 2026-10-03).
|
||||
*/
|
||||
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 MOST_CSS_PIXELS_PER_UNIT = 8;
|
||||
|
||||
/**
|
||||
* How the page is scaled on screen: fitted to the window, or a fixed
|
||||
* number of CSS pixels per page unit.
|
||||
* How near a corner of an image a press must be, in screen pixels, to
|
||||
* 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
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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 {TextBlock} 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.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) => {
|
||||
context.fillText(line, block.x + block.size * LEFT_GAP,
|
||||
block.y + block.size * (TOP_GAP + i * LINE_HEIGHT));
|
||||
context.fillText(line, (x - t.e) / t.a, (first + i * spacing - t.f) / t.d);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -93,16 +203,31 @@ export class Renderer {
|
||||
/** Region of the tail canvas in use, in page units, or 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.classList.add('tails');
|
||||
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
|
||||
* each image whose bytes are here, and the bytes each one shows.
|
||||
* @type {{element: HTMLImageElement, data: Uint8Array | null}[]}
|
||||
* each image, in the images' sequence, and the bytes each one shows.
|
||||
* @type {{element: HTMLImageElement, data: Uint8Array | null, hash: string}[]}
|
||||
*/
|
||||
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. */
|
||||
this.scale = 1;
|
||||
@@ -113,14 +238,10 @@ export class Renderer {
|
||||
this.zoom = 'fit';
|
||||
/** CSS pixels per page unit as last shown. */
|
||||
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. */
|
||||
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
|
||||
* 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.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) => {
|
||||
if (!event.ctrlKey) return;
|
||||
if (!(event.ctrlKey || event.metaKey)) return;
|
||||
event.preventDefault();
|
||||
this.zoomBy(event.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP, event.clientX, event.clientY);
|
||||
}, { passive: false });
|
||||
@@ -150,6 +272,63 @@ export class Renderer {
|
||||
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.
|
||||
* @param {number} factor
|
||||
@@ -208,6 +387,7 @@ export class Renderer {
|
||||
this.canvases.clear();
|
||||
this.hidden.clear();
|
||||
this.active.clear();
|
||||
this.gripShown = null;
|
||||
this.showImages();
|
||||
this.resize();
|
||||
// Once more after the browser has laid the page out, in case anything
|
||||
@@ -249,18 +429,6 @@ export class Renderer {
|
||||
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. */
|
||||
layoutImages() {
|
||||
const page = this.page;
|
||||
@@ -275,26 +443,27 @@ export class Renderer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the page's images whose bytes are here. An element that already
|
||||
* shows the right bytes is left alone, so that adding an image does not
|
||||
* make the others flicker.
|
||||
* Show the page's images whose bytes are here, in their sequence (the
|
||||
* last in front). An element that already shows an image keeps it when
|
||||
* the images are reordered, so that nothing flickers.
|
||||
*/
|
||||
showImages() {
|
||||
const images = this.page.images;
|
||||
for (const [i, image] of images.entries()) {
|
||||
let shown = this.imageElements[i];
|
||||
const spare = this.imageElements.slice();
|
||||
/** @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) {
|
||||
const element = document.createElement('img');
|
||||
element.className = 'page_image';
|
||||
element.alt = '';
|
||||
element.draggable = false;
|
||||
element.hidden = true;
|
||||
// Beneath the layers, in the sequence of the images.
|
||||
const before = this.imageElements[i - 1]?.element.nextSibling ?? this.pageElement.firstChild;
|
||||
this.pageElement.insertBefore(element, before);
|
||||
shown = this.imageElements[i] = { element, data: null };
|
||||
shown = { element, data: null, hash: image.hash };
|
||||
}
|
||||
if (shown.data === image.data) continue;
|
||||
if (shown.data !== image.data) {
|
||||
if (shown.element.src) URL.revokeObjectURL(shown.element.src);
|
||||
shown.data = image.data;
|
||||
shown.element.hidden = !image.data;
|
||||
@@ -304,13 +473,52 @@ export class Renderer {
|
||||
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);
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.className = 'layer';
|
||||
@@ -341,18 +549,13 @@ export class Renderer {
|
||||
* redraw. Called when the window changes, when the page grows and when
|
||||
* the zoom changes.
|
||||
*
|
||||
* At "Fit", the whole page is shown within the window, and refitted
|
||||
* when the page grows, except while the user is drawing (see holdFit).
|
||||
* A zoom of 'fit' (a page shown for the first time, or "Fit" pressed)
|
||||
* 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() {
|
||||
const desk = this.desk();
|
||||
const deskStyle = getComputedStyle(desk);
|
||||
// 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);
|
||||
const { width: availableWidth, height: availableHeight } = this.available();
|
||||
if (!(availableWidth > 0) || !(availableHeight > 0)) return;
|
||||
const first = this.zoom === 'fit' && this.sheetFirst;
|
||||
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,
|
||||
1 / Math.max(BLANK_PAGE_WIDTH / availableWidth, BLANK_PAGE_HEIGHT / availableHeight)));
|
||||
}
|
||||
let unitsPerCssPixel;
|
||||
if (this.zoom !== 'fit') unitsPerCssPixel = 1 / this.zoom;
|
||||
else if (this.holdFit && this.shownLeft !== null) unitsPerCssPixel = 1 / this.cssPixelsPerUnit;
|
||||
else unitsPerCssPixel = Math.max(this.page.width / availableWidth, this.page.height / availableHeight);
|
||||
const fitted = this.zoom === 'fit';
|
||||
const unitsPerCssPixel = this.zoom === 'fit'
|
||||
? Math.max(this.page.width / availableWidth, this.page.height / availableHeight)
|
||||
: 1 / this.zoom;
|
||||
this.cssPixelsPerUnit = 1 / unitsPerCssPixel;
|
||||
if (fitted) this.zoom = this.cssPixelsPerUnit;
|
||||
this.pageElement.style.width = `${this.page.width / unitsPerCssPixel}px`;
|
||||
this.pageElement.style.height = `${this.page.height / unitsPerCssPixel}px`;
|
||||
this.layoutImages();
|
||||
this.layoutGrip();
|
||||
// When the page grew to the left or upward, keep what is in view in view.
|
||||
if (this.shownLeft !== null && this.page.left < this.shownLeft) {
|
||||
desk.scrollLeft += (this.shownLeft - this.page.left) / unitsPerCssPixel;
|
||||
@@ -380,10 +585,14 @@ export class Renderer {
|
||||
|
||||
this.scale = (window.devicePixelRatio || 1) / unitsPerCssPixel;
|
||||
this.sizeCanvas(this.tailCanvas);
|
||||
this.sizeCanvas(this.groundCanvas);
|
||||
this.sizeCanvas(this.hideCanvas);
|
||||
this.tailRegion = null;
|
||||
for (const id of this.canvases.keys()) this.redrawLayer(id, true);
|
||||
this.drawGrounds();
|
||||
this.drawHides();
|
||||
this.drawTails();
|
||||
if (first) this.onZoomChanged?.();
|
||||
if (first || fitted) this.onZoomChanged?.();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -412,6 +621,7 @@ export class Renderer {
|
||||
else this.hidden.add(id);
|
||||
const canvas = this.canvases.get(id);
|
||||
if (canvas) canvas.hidden = !visible;
|
||||
this.drawGrounds();
|
||||
this.drawTails();
|
||||
}
|
||||
|
||||
@@ -438,9 +648,40 @@ export class Renderer {
|
||||
for (const block of layer.texts.values()) {
|
||||
if (block.id !== this.editingText) drawText(context, block);
|
||||
}
|
||||
this.drawGrounds();
|
||||
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.
|
||||
* @param {string} layerId
|
||||
|
||||
@@ -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
@@ -13,7 +13,8 @@ import {
|
||||
} from './crypto.js';
|
||||
import {
|
||||
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';
|
||||
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.
|
||||
* @property {() => void} onPermissionsChanged
|
||||
* 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 {(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. */
|
||||
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 {
|
||||
/**
|
||||
* @param {SessionOptions} options
|
||||
@@ -82,37 +99,14 @@ export class Session {
|
||||
this.identity = options.identity;
|
||||
this.hostId = options.hostId;
|
||||
this.name = cleanName(options.name) || 'Unnamed';
|
||||
/**
|
||||
* The image we have just added, which we may take back until anything
|
||||
* else changes on the page.
|
||||
* @type {PageImage | null}
|
||||
*/
|
||||
this.removableImage = null;
|
||||
// Every event that reports a change to the page ends that.
|
||||
/** @type {SessionEvents} */
|
||||
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();
|
||||
},
|
||||
};
|
||||
/** @type {ReturnType<typeof setTimeout> | null} A page descriptor waiting to be sent. */
|
||||
this.pageSendTimer = null;
|
||||
/** @type {{id: string, at: number, points: number[]} | null} Points of one's own stroke not yet sent. */
|
||||
this.pendingPoints = null;
|
||||
/** @type {ReturnType<typeof setTimeout> | null} */
|
||||
this.pointSendTimer = null;
|
||||
this.lastPointSend = 0;
|
||||
this.events = events;
|
||||
|
||||
this.isHost = this.identity.id === this.hostId;
|
||||
/** @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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @param {string} id
|
||||
@@ -258,48 +261,233 @@ export class Session {
|
||||
* @param {string} prepared.hash
|
||||
* @param {number} prepared.naturalWidth In page units.
|
||||
* @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.
|
||||
*/
|
||||
addImage(prepared, shrink = true) {
|
||||
addImage(prepared) {
|
||||
const page = this.page;
|
||||
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;
|
||||
/** @type {PageImage} */
|
||||
const image = {
|
||||
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.imagesRevision += 1;
|
||||
page.baseWidth = Math.max(page.baseWidth, place.pageWidth);
|
||||
page.baseHeight = Math.max(page.baseHeight, place.pageHeight);
|
||||
page.baseLeft = place.sheet.left;
|
||||
page.baseTop = place.sheet.top;
|
||||
page.baseWidth = place.sheet.width;
|
||||
page.baseHeight = place.sheet.height;
|
||||
this.send(0, { type: 'page', page: page.descriptor() });
|
||||
this.sendImage(0, image);
|
||||
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);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Take back the image we have just added: the remedy for having chosen
|
||||
* the wrong file. It works only until anything else changes on the
|
||||
* page (Andy, 2026-10-05), so nothing can have been written on the image,
|
||||
* and since it is the lowest image, no other image is affected.
|
||||
* @returns {boolean} Whether an image was taken back.
|
||||
* Give a Hide rectangle a new rectangle: moved or resized by hand.
|
||||
* @param {number} index
|
||||
* @param {import('./page_model.js').Rectangle} rect
|
||||
*/
|
||||
removeAddedImage() {
|
||||
placeHideByHand(index, rect) {
|
||||
const page = this.page;
|
||||
const image = this.removableImage;
|
||||
if (!image || page.images.at(-1) !== image) return false;
|
||||
page.images.pop();
|
||||
const hide = page.hides[index];
|
||||
if (!this.mayMakePages() || !hide) return;
|
||||
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.baseWidth = page.sheetWidth();
|
||||
page.baseHeight = page.sheetHeight();
|
||||
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.extentChanged(true);
|
||||
return true;
|
||||
@@ -314,7 +502,10 @@ export class Session {
|
||||
loadPage(kept) {
|
||||
if (!this.mayMakePages()) return;
|
||||
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.hides = kept.hides.map((hide) => ({ ...hide }));
|
||||
page.imagesRevision = kept.imagesRevision;
|
||||
for (const [id, layer] of kept.layers) {
|
||||
const copy = page.layer(id, layer.name);
|
||||
@@ -460,6 +651,38 @@ export class Session {
|
||||
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
|
||||
* list with the higher revision is the later one. Of two different
|
||||
@@ -470,11 +693,13 @@ export class Session {
|
||||
*/
|
||||
considerImages(descriptor) {
|
||||
const page = this.page;
|
||||
/** @param {Omit<PageImage, 'data'>[]} images */
|
||||
const listed = (images) => images.map((image) =>
|
||||
`${image.hash} ${image.x} ${image.y} ${image.width} ${image.height}`).join(',');
|
||||
const ours = listed(page.images);
|
||||
const theirs = listed(descriptor.images);
|
||||
/** @param {import('./page_model.js').PageDescriptor} d */
|
||||
const listed = (d) => d.images.map((image) =>
|
||||
`${image.hash} ${image.x} ${image.y} ${image.width} ${image.height}`).join(',') +
|
||||
'|' + d.hides.map((hide) => `${hide.id} ${hide.x} ${hide.y} ${hide.width} ${hide.height}`).join(',') +
|
||||
`|${d.left} ${d.top} ${d.width} ${d.height}`;
|
||||
const ours = listed(page.descriptor());
|
||||
const theirs = listed(descriptor);
|
||||
if (descriptor.revision < page.imagesRevision ||
|
||||
(descriptor.revision === page.imagesRevision && theirs <= ours)) return;
|
||||
const held = new Map();
|
||||
@@ -482,7 +707,10 @@ export class Session {
|
||||
if (image.data) held.set(image.hash, image.data);
|
||||
}
|
||||
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.baseLeft = descriptor.left;
|
||||
page.baseTop = descriptor.top;
|
||||
page.baseWidth = descriptor.width;
|
||||
page.baseHeight = descriptor.height;
|
||||
// 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);
|
||||
appendPoints(stroke, at, points);
|
||||
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.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() {
|
||||
const stroke = this.currentStroke;
|
||||
if (!stroke) return;
|
||||
this.currentStroke = null;
|
||||
stroke.done = true;
|
||||
this.sendPendingPoints();
|
||||
this.send(0, { type: 'stroke_end', id: stroke.id });
|
||||
this.events.onStrokeProgress(this.identity.id, stroke);
|
||||
}
|
||||
@@ -756,12 +1022,19 @@ export class Session {
|
||||
if (!id) return;
|
||||
if (message.type === 'page') {
|
||||
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;
|
||||
}
|
||||
if (message.type === 'permissions') {
|
||||
if (id === this.hostId) this.receivedPermissions(message.ids);
|
||||
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') {
|
||||
this.receivedImagePiece(message);
|
||||
return;
|
||||
|
||||
+165
-84
@@ -1,15 +1,19 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* 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
|
||||
* handles for moving the block and changing its width.
|
||||
* umlauts, dead keys and selection work as they do everywhere. Moving a
|
||||
* 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
|
||||
* shows it; everyone else sees it drawn, updated at every keystroke.
|
||||
*/
|
||||
|
||||
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').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. */
|
||||
const LEAST_FIELD_WIDTH = 300;
|
||||
|
||||
|
||||
export class TextEditor {
|
||||
/**
|
||||
* @param {HTMLElement} pageElement
|
||||
@@ -25,6 +30,13 @@ export class TextEditor {
|
||||
* @param {object} target
|
||||
* @param {(block: TextBlock) => void} target.setText The block changed.
|
||||
* @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) {
|
||||
this.pageElement = pageElement;
|
||||
@@ -38,6 +50,7 @@ export class TextEditor {
|
||||
this.field.spellcheck = false;
|
||||
this.field.hidden = true;
|
||||
this.field.addEventListener('input', () => this.changed());
|
||||
this.watchForHold();
|
||||
this.field.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
@@ -45,95 +58,111 @@ export class TextEditor {
|
||||
}
|
||||
});
|
||||
|
||||
this.corner = this.makeHandle('text_handle corner', 'Move the text');
|
||||
this.edge = this.makeHandle('text_edge', 'Move the text');
|
||||
this.end = this.makeHandle('text_handle end', 'Change the width');
|
||||
/** Shows the top edge while it is being dragged out. */
|
||||
/** Free text's corner mark while it is being edited (the renderer leaves the block out then). */
|
||||
this.mark = document.createElement('div');
|
||||
this.mark.className = 'text_mark';
|
||||
this.mark.hidden = true;
|
||||
/** Shows a rectangle while it is being dragged open. */
|
||||
this.preview = document.createElement('div');
|
||||
this.preview.className = 'text_preview';
|
||||
this.preview.className = 'rect_preview';
|
||||
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
|
||||
* (x1, y0), while the pointer is dragging.
|
||||
* A press that rests on the field becomes a hold that moves the block,
|
||||
* 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} y0
|
||||
* @param {number} x1
|
||||
* @param {number} y1
|
||||
*/
|
||||
showPreview(x0, y0, x1) {
|
||||
showPreview(x0, y0, x1, y1) {
|
||||
const scale = this.renderer.cssPixelsPerUnit;
|
||||
const page = this.renderer.page;
|
||||
this.preview.hidden = false;
|
||||
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.height = `${Math.abs(y1 - y0) * scale}px`;
|
||||
}
|
||||
|
||||
hidePreview() {
|
||||
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} */
|
||||
get editing() {
|
||||
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} y
|
||||
* @param {number | null} width
|
||||
* @param {number | null} height
|
||||
* @param {{color: string, size: number, font: TextFont}} style
|
||||
*/
|
||||
begin(x, y, width, style) {
|
||||
begin(x, y, width, height, style) {
|
||||
this.stop();
|
||||
/** @type {TextBlock} */
|
||||
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,
|
||||
};
|
||||
Object.assign(block, layoutText('', width, style.size, style.font));
|
||||
@@ -163,6 +192,8 @@ export class TextEditor {
|
||||
this.layout();
|
||||
this.target.setText(block); // Redrawn without this block.
|
||||
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. */
|
||||
@@ -172,58 +203,108 @@ export class TextEditor {
|
||||
this.block = null;
|
||||
this.renderer.editingText = null;
|
||||
this.field.hidden = true;
|
||||
this.mark.hidden = true;
|
||||
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);
|
||||
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. */
|
||||
changed() {
|
||||
const block = this.block;
|
||||
if (!block) return;
|
||||
block.text = this.field.value;
|
||||
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.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() {
|
||||
const block = this.block;
|
||||
if (!block) return;
|
||||
const scale = this.renderer.cssPixelsPerUnit;
|
||||
const page = this.renderer.page;
|
||||
const left = (block.x - page.left) * scale;
|
||||
const top = (block.y - page.top) * scale;
|
||||
// Everything on whole device pixels, placed as the canvas places the
|
||||
// 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 style = this.field.style;
|
||||
this.field.classList.toggle('rect', isRect);
|
||||
style.left = `${left}px`;
|
||||
style.top = `${top}px`;
|
||||
// A little wider than the block, so that the field never wraps a line
|
||||
// earlier than the layout did.
|
||||
style.width = `${fieldWidth - block.size * LEFT_GAP * scale + 2}px`;
|
||||
style.paddingTop = `${block.size * TOP_GAP * scale}px`;
|
||||
style.paddingLeft = `${block.size * LEFT_GAP * scale}px`;
|
||||
// The text's left edge and its first baseline, where the canvas draws
|
||||
// them; the lines a whole number of pixels apart.
|
||||
const textLeft = whole((block.x + block.size * LEFT_GAP - page.left) * scale);
|
||||
const firstBaseline = whole((block.y + baselineOf(block.size, block.font, 0) - page.top) * scale);
|
||||
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.lineHeight = String(LINE_HEIGHT);
|
||||
style.lineHeight = `${lineHeight}px`;
|
||||
style.color = block.color;
|
||||
style.whiteSpace = block.width === null ? 'pre' : 'pre-wrap';
|
||||
// Exactly as tall as the lines: no spare line below the text.
|
||||
style.height = `${block.lines.length * block.size * LINE_HEIGHT * scale}px`;
|
||||
|
||||
this.corner.hidden = false;
|
||||
this.corner.style.left = `${left}px`;
|
||||
this.corner.style.top = `${top}px`;
|
||||
const hasWidth = block.width !== null;
|
||||
this.edge.hidden = !hasWidth;
|
||||
this.end.hidden = !hasWidth;
|
||||
if (hasWidth) {
|
||||
this.edge.style.left = `${left}px`;
|
||||
this.edge.style.top = `${top}px`;
|
||||
this.edge.style.width = `${fieldWidth}px`;
|
||||
this.end.style.left = `${left + fieldWidth}px`;
|
||||
this.end.style.top = `${top}px`;
|
||||
}
|
||||
if (isRect) {
|
||||
const bounds = textBounds(block);
|
||||
// The canvas strokes the far lines on the pixel at the edge itself,
|
||||
// so the field reaches one pixel past it.
|
||||
style.boxSizing = 'border-box';
|
||||
style.width = `${whole((bounds.left + bounds.width - page.left) * scale) - left + 1 / dpr}px`;
|
||||
style.height = `${Math.max(block.lines.length * lineHeight + padTop + slack,
|
||||
whole((bounds.top + bounds.height - page.top) * scale) - top + 1 / dpr)}px`;
|
||||
} else {
|
||||
style.boxSizing = 'content-box';
|
||||
// A little wider than the block, so that the field never wraps a
|
||||
// line earlier than the layout did.
|
||||
style.width = `${fieldWidth - (textLeft - left) + 2}px`;
|
||||
style.height = `${block.lines.length * lineHeight}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`;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
* 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
|
||||
@@ -51,6 +52,42 @@ export function textWidth(text, size, family) {
|
||||
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
|
||||
* spaces; a word wider than the width is broken where it must be.
|
||||
|
||||
+10
-2
@@ -9,7 +9,7 @@
|
||||
* are covered too, since the listeners are on the document.
|
||||
*/
|
||||
|
||||
const DELAY_MS = 1000;
|
||||
const DELAY_MS = 2000;
|
||||
const GAP_PX = 6;
|
||||
|
||||
/** @type {HTMLElement | null} */
|
||||
@@ -66,8 +66,16 @@ export function enableTooltips() {
|
||||
timer = setTimeout(() => { timer = null; show(element); }, DELAY_MS);
|
||||
});
|
||||
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) => {
|
||||
if (performance.now() - pressedAt < 500) return;
|
||||
const element = helpElement(event.target);
|
||||
if (element) show(element);
|
||||
});
|
||||
|
||||
+135
-70
@@ -5,8 +5,7 @@
|
||||
--panel: #f6f6f7;
|
||||
--desk: #e4e4e7;
|
||||
--accent: #1f4fd8;
|
||||
--text_edge: #ababab; /* The top edge of a text block being edited: 67% grey. */
|
||||
--text_handle: #ababab; /* Its squares: the same. */
|
||||
--text_frame: #e53935; /* A text rectangle's frame: the palette's red. */
|
||||
font-family: system-ui, sans-serif;
|
||||
font-size: 15px;
|
||||
color: var(--ink);
|
||||
@@ -20,6 +19,14 @@ html, body { height: 100%; margin: 0; }
|
||||
|
||||
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 {
|
||||
padding: 0.35rem 0.8rem;
|
||||
border: 1px solid var(--line);
|
||||
@@ -35,12 +42,14 @@ button[aria-pressed="true"] {
|
||||
box-shadow: inset 0 0 0 1px var(--accent);
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
input[type="text"], select.notebook_pick {
|
||||
width: 100%;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: white;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
h2 {
|
||||
@@ -80,15 +89,29 @@ h2 {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
gap: 0.5rem 0.55rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
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; }
|
||||
|
||||
.swatch {
|
||||
@@ -100,13 +123,16 @@ h2 {
|
||||
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 {
|
||||
box-sizing: border-box;
|
||||
height: 2rem;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.toolbar button:not(.swatch) { padding-left: 0.6rem; padding-right: 0.6rem; }
|
||||
|
||||
#text_size, #text_font {
|
||||
margin: 0;
|
||||
@@ -137,6 +163,8 @@ h2 {
|
||||
}
|
||||
|
||||
.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: white;
|
||||
@@ -161,24 +189,36 @@ h2 {
|
||||
background: var(--ink);
|
||||
}
|
||||
|
||||
#zoom_in, #zoom_out {
|
||||
width: 2.2rem;
|
||||
padding: 0.1rem 0;
|
||||
font-size: 1.35rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* What concerns the page and the view, not the tool: it never moves. */
|
||||
/* The page, the notebook and the session: flush right, the page number
|
||||
and what is going on at the very end (Andy, 2026-10-08). */
|
||||
.toolbar_right {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
gap: 0.5rem 0.55rem;
|
||||
margin-left: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.page_number { color: var(--muted); }
|
||||
.status { min-width: 6.5rem; text-align: right; color: var(--muted); }
|
||||
.status.problem { color: #b42318; }
|
||||
/* The notice line between the toolbar and the page: nothing, and no
|
||||
room, while all is well; a problem in red while it lasts; a
|
||||
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 {
|
||||
display: flex;
|
||||
@@ -190,17 +230,40 @@ h2 {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
padding: 1.25rem;
|
||||
padding: 0.5rem;
|
||||
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 {
|
||||
position: relative;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
box-shadow: 0 1px 4px rgb(0 0 0 / 0.25);
|
||||
touch-action: none;
|
||||
-webkit-user-select: none; /* Safari on iOS: see the controls rule. */
|
||||
user-select: none;
|
||||
-webkit-touch-callout: none; /* No callout from a long press of the Pencil. */
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
@@ -228,75 +291,78 @@ h2 {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.text_handle {
|
||||
/* A rectangle being dragged open, for text or for Hide. */
|
||||
.rect_preview {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
margin: -5px 0 0 -5px;
|
||||
border: 1px solid white;
|
||||
border-radius: 2px;
|
||||
background: var(--text_handle);
|
||||
cursor: move;
|
||||
box-sizing: border-box;
|
||||
border: 2px dotted #ababab;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.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;
|
||||
z-index: 3;
|
||||
height: 0;
|
||||
margin-top: -1px;
|
||||
border-top: 2px solid var(--text_edge);
|
||||
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);
|
||||
box-sizing: border-box;
|
||||
border-style: solid none none solid;
|
||||
border-color: var(--text_frame);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
.panel {
|
||||
width: 16rem;
|
||||
padding: 0 1rem 1rem;
|
||||
overflow: auto;
|
||||
border-left: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
}
|
||||
/* The Session dialog: who is here, and the invitation. */
|
||||
|
||||
.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;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.2rem 0;
|
||||
}
|
||||
|
||||
.panel li.absent { color: var(--muted); }
|
||||
.panel li label { flex: 1; }
|
||||
.panel li .role { font-size: 0.8rem; color: var(--muted); }
|
||||
.panel li .allow { order: 1; padding: 0.1rem 0.4rem; font-size: 0.75rem; }
|
||||
|
||||
.panel button { margin-top: 0.4rem; }
|
||||
.session_body li.absent { color: var(--muted); }
|
||||
.session_body li label { flex: 1; }
|
||||
.session_body li .role { font-size: 0.8rem; color: var(--muted); }
|
||||
.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; }
|
||||
.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 */
|
||||
|
||||
@@ -396,7 +462,6 @@ h2 {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.files_shrink { display: block; margin-top: 0.75rem; font-size: 0.9rem; }
|
||||
|
||||
.files_existing {
|
||||
display: flex;
|
||||
|
||||
+4
-1
@@ -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
|
||||
in the table and restarts the relay. It can be run again at any time.
|
||||
`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:
|
||||
|
||||
|
||||
+144
-83
@@ -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
|
||||
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
|
||||
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.
|
||||
|
||||
## Joining
|
||||
|
||||
Open the link in a web browser. Firefox, Brave and Chrome are known to
|
||||
work; other browsers built on the same engines very probably do. You see
|
||||
Kokkoja's start screen with the invitation 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.
|
||||
Open the link in a web browser. Firefox, Brave and Chrome on a
|
||||
computer, and Safari on an iPad or an iPhone, are known to work; other
|
||||
browsers built on the same engines very probably do. You see
|
||||
Kokkoja's start screen, headed "Join a session", with the invitation
|
||||
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
|
||||
field, **Notebook for the pages of this session**, which you can leave as
|
||||
it is for now; what it does is explained under "Your notebook" below.
|
||||
choice, **Notebook for the pages of this session**, which you can leave
|
||||
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
|
||||
[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
|
||||
|
||||
Across the top is the toolbar. On the left are the tools and their
|
||||
settings; on the right, the zoom and, for the host, the buttons that
|
||||
start pages. Below it, the page fills most of the window, with a panel on
|
||||
the right. Every button explains itself: rest the pointer on it for a
|
||||
second and its explanation appears beneath it.
|
||||
Across the top is the toolbar, one row holding everything: the tools
|
||||
and their settings, the view, the host's page buttons, your notebook,
|
||||
and **Session**, which opens a window with the participants and the
|
||||
invitation. Below it the page has the whole width of the window. Every button
|
||||
explains itself: rest the pointer on it for two seconds and its
|
||||
explanation appears beneath it.
|
||||
|
||||
### 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
|
||||
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.
|
||||
@@ -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,
|
||||
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
|
||||
and green, and **+** opens a palette of more. A colour chosen from the
|
||||
palette joins the row of colours and stays there in this browser. The
|
||||
three lines to the right of the colours are the line widths: 0.4, 0.7
|
||||
and 1.2 mm at ordinary pressure.
|
||||
line wider. The colours are to the right of the tools: black, red,
|
||||
green, blue and white, and **+** opens a palette of more, in which the
|
||||
colours already in the row leave their places empty. A colour chosen
|
||||
from the palette joins the row and stays there in this browser. White
|
||||
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.
|
||||
|
||||
@@ -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 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
|
||||
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
|
||||
them, and the page grows to hold them. To make the text wrap at a chosen
|
||||
width, drag along where its top edge should run instead of tapping: the
|
||||
block begins at the left end of the drag and wraps at the right end.
|
||||
them, and the page grows to hold them; a red corner mark of 5 mm at its
|
||||
top left shows where it begins. To make a *text rectangle*
|
||||
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
|
||||
it is drawn grey, and three handles appear: the one at its top-left
|
||||
corner and the line along its top edge move it, the one at the right end
|
||||
of that line changes its width. You cannot change anyone else's text.
|
||||
Tap your own text to change it, anywhere in a box, and the cursor is at
|
||||
the end of the text. To move a block of yours, rest the pen
|
||||
on it, without moving, for a little over half a second: a green outline
|
||||
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
|
||||
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
|
||||
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
|
||||
page grows by writing, except while you are in the middle of a stroke of
|
||||
your own. **+** and **−** zoom in and out by a tenth. Holding Ctrl and
|
||||
turning the scroll wheel zooms about the pointer. When the page is
|
||||
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
|
||||
new image at the bottom of a long page is reached by scrolling.
|
||||
A page is shown whole when you first see it. Afterwards the view keeps
|
||||
its scale: when the page grows, or the host adds an image, the page
|
||||
extends beyond the window and you scroll to the rest. Three buttons and
|
||||
two gestures change the view, and none of them changes the page:
|
||||
|
||||
* **Fit** shows the whole page in the window.
|
||||
* **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
|
||||
it: same zoom, same place.
|
||||
|
||||
### The panel
|
||||
### Session
|
||||
|
||||
**Participants** lists everyone in the session, with *you* and *host*
|
||||
marked, and *away* beside anyone who has left or lost their connection;
|
||||
an absent participant's writing stays on the page. The box in front of
|
||||
each name shows or hides that participant's writing on your screen alone.
|
||||
Hiding your teacher's corrections to try an exercise again, or hiding
|
||||
your own answers to see the exercise as it was, changes nothing for
|
||||
anyone else.
|
||||
**Session** in the toolbar opens a window with the people and the
|
||||
invitation. **Participants** lists everyone in the session, with *you*
|
||||
and *host* marked, and *away* beside anyone who has left or lost their
|
||||
connection; an absent participant's writing stays on the page. The box
|
||||
in front of each name shows or hides that participant's writing on your
|
||||
screen alone. Hiding your teacher's corrections to try an exercise
|
||||
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
|
||||
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,
|
||||
below. Under them, a line reports what was last saved, and whether
|
||||
anything went wrong.
|
||||
### Notices
|
||||
|
||||
The last line of the panel reports what your pen or mouse is delivering:
|
||||
the kind of device, the pressure, and how many positions per second it
|
||||
sends. It is there for curiosity and for finding faults.
|
||||
While all is well Kokkoja says nothing. When there is something to
|
||||
know, a line appears between the toolbar and the page: in red while
|
||||
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
|
||||
|
||||
At the right end of the toolbar, **Page 3** is the number of the page you
|
||||
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*.
|
||||
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
|
||||
mouse delivers.
|
||||
|
||||
## 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:
|
||||
|
||||
* **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
|
||||
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
|
||||
file; writing you have hidden is left out. Its name is the date and
|
||||
the page number.
|
||||
|
||||
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
|
||||
again, writing and all, when someone with the right to start pages
|
||||
chooses it (see "When the host gives you more" below, and the host's
|
||||
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
|
||||
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
|
||||
their own keeps their pages apart; the name proposed is simply
|
||||
*Notebook*. **Current notebook** in the panel shows the notebook in use
|
||||
and lets you change it during the session.
|
||||
their own keeps their pages apart; until you make one, everything goes
|
||||
into the notebook *General*. The last entry of the list, **New
|
||||
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
|
||||
|
||||
@@ -193,7 +245,7 @@ to open the invitation in the installed program.
|
||||
|
||||
With the program, a notebook is a directory in `Kokkoja` in your
|
||||
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
|
||||
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
|
||||
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
|
||||
loads pages from a notebook. The host can give you the same right, for
|
||||
instance in a discussion among colleagues where everyone brings
|
||||
material. When that happens, three more buttons appear at the right of
|
||||
your toolbar:
|
||||
loads pages from a notebook. The host can make you a *co-host*, which
|
||||
gives you the same powers, for instance in a discussion among
|
||||
colleagues where everyone brings material. The Session window then
|
||||
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
|
||||
kept in each person's notebook.
|
||||
* **Image** puts an image file on the page for everyone, below any
|
||||
images already there. Your browser's file chooser asks for the file
|
||||
(with the Kokkoja program, Kokkoja's own chooser does, beginning where
|
||||
the notebooks are, with a choice whether an image wider than the page
|
||||
is shrunk to fit it). A PNG or JPEG file keeps its exact pixels unless
|
||||
the notebooks are). A PNG or JPEG file keeps its exact pixels unless
|
||||
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
|
||||
size, centred, 8 mm from the top or from the image above; one wider
|
||||
than the sheet is scaled down to fit between the margins. To put an
|
||||
image on a page of its own, press **New** first.
|
||||
size, centred, 5 mm below the image above or at the top of the page;
|
||||
one wider than the page widens it. To put an image on a page of its
|
||||
own, press **New** first.
|
||||
* **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 and small pictures of their kept pages. The kept
|
||||
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
|
||||
added anything after it, since the marks on it would make no sense
|
||||
without it. The one exception is a mistake caught at once: the person
|
||||
who added an image can take it back with a tap of the eraser on it, as
|
||||
long as nothing else has happened on the page since.
|
||||
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
|
||||
and it follows the pen; rest it near a corner and the corner follows,
|
||||
the shape kept. Writing stays where it is, so images are arranged
|
||||
first and written on afterwards. A new image always arrives below the
|
||||
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
@@ -22,21 +22,44 @@ You need:
|
||||
`python3 --version`; if Python is not installed, macOS offers to
|
||||
install the command line developer tools, which include it. Any Python
|
||||
from 3.9 on will do.
|
||||
* **A web browser.** Firefox, Brave or Chrome; other browsers built on
|
||||
the same engines very probably work.
|
||||
* **A web browser.** Firefox, Brave or Chrome, or Safari on a Mac;
|
||||
other browsers built on the same engines very probably work.
|
||||
* **A pen tablet**, if you want to write by hand. A mouse works for
|
||||
pointing and for rough marks.
|
||||
|
||||
Get Kokkoja from its repository, either by downloading it as a zip file
|
||||
and unpacking it, or with git:
|
||||
Get Kokkoja from its repository: download
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
you its address. If you give the program no relay, it expects one on
|
||||
your own computer, which is useful only for trying Kokkoja alone.
|
||||
you its address. The program remembers it, so from the second time on
|
||||
|
||||
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:
|
||||
|
||||
@@ -73,28 +101,34 @@ Other options, all optional:
|
||||
## Starting a 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
|
||||
panel on the right shows the invitation.
|
||||
and press **Start a session**. You are on page 1, a blank sheet, and
|
||||
**Session** in the toolbar shows the invitation.
|
||||
|
||||
### The notebook
|
||||
|
||||
The notebook you name is a directory under `Kokkoja` in your Documents
|
||||
directory, made when it is first needed, and the start screen offers the
|
||||
names of the notebooks you already have. For teaching, a notebook per
|
||||
student works well: everything a student is given, with everything they
|
||||
and you wrote on it, gathers in that student's notebook, and the next
|
||||
lesson with that student starts by naming 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.
|
||||
A notebook is a directory under `Kokkoja` in your Documents directory.
|
||||
The start screen lists the notebooks you have; until you make one, there
|
||||
is *General*, the notebook for whatever has no notebook of its own. The
|
||||
last entry of the list, **New notebook…**, asks for a name and makes a
|
||||
notebook of that name, which exists from the moment a page is written
|
||||
into it. For teaching, a notebook per student works well: everything a
|
||||
student is given, with everything they and you wrote on it, gathers in
|
||||
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 panel. The page you are on then continues in the new notebook.
|
||||
The notebook can be changed during a session, in the notebook list in
|
||||
the toolbar. The page you are on then continues in the notebook chosen.
|
||||
|
||||
### 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
|
||||
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
|
||||
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.
|
||||
@@ -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
|
||||
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
|
||||
is placed on the page: the first at the top of the sheet, each further
|
||||
one 8 mm below the lowest image there, centred, at its natural size. An
|
||||
image wider than the sheet is scaled down to fit between the margins, so
|
||||
a full-page A4 scan appears at about 92%; the chooser has a setting to
|
||||
turn that off, in which case the sheet widens to the image instead. 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. A page holds up to 12 images.
|
||||
is placed on the page in one way always: 5 mm below the lowest image
|
||||
there, or at the top of the page if there is none, centred across the
|
||||
page's width, at its natural size, in front of the images already
|
||||
there. Natural size is the size the file records: a scan arrives at
|
||||
the size of the paper it came from. A photo from a phone or camera
|
||||
records no useful size and arrives at a tenth of a millimetre per
|
||||
pixel. An image wider than the page widens the page on both sides to
|
||||
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 series of exercises, keep pressing **Image** on the same page: the
|
||||
sheet grows downward and everyone scrolls through them. A new image does
|
||||
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
|
||||
back, as long as nothing else has happened on the page since: no stroke,
|
||||
no text, no erasure, no further image, by anyone. After that an image
|
||||
stays, because the marks made on it would make no sense without it.
|
||||
A tap of the eraser on an image removes it, whoever added it and
|
||||
whenever. Writing on it stays where it was, so remove an image before
|
||||
anyone writes on it unless the writing is meant to stay. The page keeps
|
||||
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
|
||||
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**
|
||||
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
|
||||
front of it. Pressing it lets that guest start new pages, put images on
|
||||
the page and load pages from their notebook, exactly as you can; the
|
||||
three buttons appear on their toolbar. Pressing it again withdraws the
|
||||
right, which otherwise lasts until the session ends. Guests can never erase or
|
||||
change anyone's writing but their own; that needs no setting.
|
||||
In the **Participants** list of the Session window, each guest's name
|
||||
has a box **Co-host** beside it. Ticking it makes that guest a
|
||||
co-host: they may then start new pages, put images on the page and
|
||||
arrange them, load pages from their notebook, widen, shrink and hide,
|
||||
exactly as you can, and the buttons for it appear on their toolbar.
|
||||
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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
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.
|
||||
|
||||
**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
|
||||
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
|
||||
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
|
||||
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
|
||||
full address the program printed when it started, the one with the
|
||||
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.
|
||||
If it does not come back, check your connection; the page is safe in
|
||||
your notebook either way.
|
||||
* **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
|
||||
you start the session, so this usually means the latter.
|
||||
* **Pressure does not work.** The last line of the panel reports what
|
||||
the browser receives from your pen. If it says *mouse*, the browser is
|
||||
not seeing the pen as a pen; try the other browser.
|
||||
* **Pressure does not work.** The browser's console (F12) logs what
|
||||
the browser receives from your pen at your first stroke. If it says
|
||||
*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
@@ -37,13 +37,16 @@ page, an image once. There is no central copy.
|
||||
|
||||
Two computers behind home routers generally cannot connect to each other
|
||||
directly, so something in between has to pass the messages. The relay is
|
||||
the smallest such thing: a WebSocket server with *rooms*. A client
|
||||
connects to `wss://<server>/relay/room/<room id>`; the relay gives the
|
||||
connection a number, tells the room that a peer has arrived, and from
|
||||
then on sends every message it receives from one member to all the
|
||||
others, stamped with the sender's number. When the last member leaves,
|
||||
the room is gone. It holds at most 32 members per room and 500 rooms,
|
||||
and refuses messages over 1 MB; clients split anything larger.
|
||||
the smallest such thing: a WebSocket server that passes messages among
|
||||
the members of a session. What a user calls a session, the relay calls a
|
||||
*room*: the word appears in the relay's code and log and in the address a
|
||||
client connects to, `wss://<server>/relay/room/<room id>`, and nowhere
|
||||
else. The relay gives each connection a number, tells the others that a
|
||||
peer has arrived, and from then on sends every message it receives from
|
||||
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
|
||||
|
||||
@@ -53,7 +56,7 @@ The invitation is
|
||||
|
||||
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
|
||||
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
|
||||
decrypted on arrival. The relay sees the room id, the members' network
|
||||
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>
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
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
|
||||
@@ -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.
|
||||
|
||||
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).
|
||||
|
||||
## Pages
|
||||
|
||||
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
|
||||
8 mm on that side. An image is placed at its natural size, taken from
|
||||
Page coordinates are in units of 0.1 mm. A blank page is an A4 sheet
|
||||
in landscape, 2970 by 2100 units. "Widen" makes the sheet wider, and
|
||||
"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
|
||||
none, centred across the sheet, 8 mm from the top or from the image
|
||||
above, scaled down if wider than the sheet inside its margins (or, if the
|
||||
person adding it chooses, placed at natural size with the sheet widened
|
||||
to hold it). A page holds at most 12 images and 24 MB of image data.
|
||||
none (the nominal 72 and 96 dpi that cameras and screenshots write count
|
||||
as none), centred across the page, 5 mm from the top or from the image
|
||||
above, the sheet widened equally on both sides if it is wider than the
|
||||
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,
|
||||
is redrawn at most 2400 pixels and stored as JPEG before it is shared;
|
||||
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
|
||||
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
|
||||
@@ -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
|
||||
`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
|
||||
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
|
||||
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
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
@@ -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
|
||||
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
|
||||
|
||||
The code is Python and JavaScript only. The tests run with
|
||||
|
||||
+50
-5
@@ -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
|
||||
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.
|
||||
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.
|
||||
@@ -62,8 +62,9 @@ host runs Kokkoja.
|
||||
|
||||
**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
|
||||
Firefox and in browsers built on the Chromium engine, such as Brave and
|
||||
Chrome, on Linux and macOS.
|
||||
Firefox, in browsers built on the Chromium engine, such as Brave or
|
||||
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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
everyone follows. The host can give any guest the same abilities, for a
|
||||
discussion among colleagues.
|
||||
everyone follows. The host can make any guest a *co-host*, with the
|
||||
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
|
||||
view first, since the host sees the same screen and has a few more
|
||||
|
||||
+38
-5
@@ -106,6 +106,31 @@ def load_token(directory):
|
||||
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):
|
||||
"""Replace a file so that it is never seen half written."""
|
||||
temporary = path.with_name("." + path.name + ".writing")
|
||||
@@ -132,7 +157,8 @@ def keep_page(notebook):
|
||||
changed = current.stat().st_mtime
|
||||
except FileNotFoundError:
|
||||
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
|
||||
|
||||
kept = [entry for entry in notebook.iterdir()
|
||||
@@ -459,9 +485,10 @@ def make_server(port, relay, notebooks, token, config, site=""):
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Kokkoja local program")
|
||||
parser.add_argument("--port", type=int, default=8760)
|
||||
parser.add_argument("--relay", default="ws://127.0.0.1:8770",
|
||||
help="relay used for sessions started here "
|
||||
"(default ws://127.0.0.1:8770)")
|
||||
parser.add_argument("--relay", default="",
|
||||
help="relay used for sessions started here, such as "
|
||||
"wss://kokkoja.example/relay; remembered, so it "
|
||||
"need be given only once")
|
||||
parser.add_argument("--site", default="",
|
||||
help="Kokkoja web site that invitations link to, so that "
|
||||
"guests can join with nothing installed (default: "
|
||||
@@ -478,10 +505,16 @@ def main():
|
||||
|
||||
notebooks = arguments.notebooks or default_notebooks_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)
|
||||
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}"
|
||||
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("Ctrl-C to stop.", flush=True)
|
||||
if not arguments.no_browser:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
qrcodegen>=1.8
|
||||
+825
-161
File diff suppressed because it is too large.
Load diff
@@ -22,6 +22,9 @@ import kokkoja # noqa: E402
|
||||
TOKEN = "test-token"
|
||||
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"
|
||||
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'
|
||||
|
||||
|
||||
@@ -64,6 +67,16 @@ class LocalProgramTest(unittest.TestCase):
|
||||
self.assertEqual(self.json("GET", "/config.json", token=None),
|
||||
(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):
|
||||
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"],
|
||||
[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):
|
||||
notebook = self.notebooks / "Andy"
|
||||
notebook.mkdir(parents=True)
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user