Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
377e2812c9 | ||
|
|
2b4999f65c | ||
|
|
c1e050ded5 | ||
|
|
ac4ad56992 | ||
|
|
bb87761db2 | ||
|
|
e4ce1293ae | ||
|
|
db3f33990e | ||
|
|
b60ba3e1da | ||
|
|
9e516dfb0f | ||
|
|
bfbdbca433 | ||
|
|
34b4326c12 | ||
|
|
a07febc5a6 | ||
|
|
7bf6d31693 | ||
|
|
08def89313 | ||
|
|
ac13bf95a7 | ||
|
|
0875394e48 | ||
|
|
c287e9de30 | ||
|
|
49e914bb6e | ||
|
|
f9926ea2f0 | ||
|
|
bd1567b788 | ||
|
|
3e09519f5f | ||
|
|
2e0296eba1 | ||
|
|
581601bcb5 | ||
|
|
854f7dc222 | ||
|
|
a20e5c1295 | ||
|
|
5c41c928a6 |
-17
@@ -1,17 +0,0 @@
|
||||
# Site-specific credentials and deployment overrides.
|
||||
/cms/config.local.php
|
||||
|
||||
# Runtime CMS state; retain only the web-access guards committed below.
|
||||
/cms/data/*
|
||||
!/cms/data/.htaccess
|
||||
!/cms/data/index.php
|
||||
/cms/logs/*
|
||||
!/cms/logs/.htaccess
|
||||
!/cms/logs/index.php
|
||||
|
||||
# Uploaded content belongs to the deployed site rather than source control.
|
||||
/uploads/*
|
||||
!/uploads/.htaccess
|
||||
!/uploads/index.php
|
||||
|
||||
# Generated security-audit reports are retained deliberately and should remain visible to Git.
|
||||
@@ -1,674 +0,0 @@
|
||||
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.
|
||||
|
||||
NeoCMS - Flat File Content Management System
|
||||
Copyright (C) 2024 Andy Dixon
|
||||
|
||||
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:
|
||||
|
||||
Neo CMS (NeoCMS) Copyright (C) 2024 Andy Dixon
|
||||
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>.
|
||||
@@ -1,405 +1,24 @@
|
||||
# NeoCMS
|
||||
|
||||
## Version 3.0
|
||||
## Version 2.0
|
||||
|
||||
Welcome to NeoCMS, the lightweight, database-free content management system. Built with PHP and JavaScript, NeoCMS is
|
||||
designed to add controlled visual editing to an existing static website without changing how public pages are served.
|
||||
Version 2.0 is a complete rewrite to ensure we support the latest jQuery and TinyMCE GPL version.
|
||||
|
||||
Version 3.0 adds filesystem-backed drafts, revision history, role-based permissions, scheduled publishing, page and media
|
||||
management, SEO controls, shared content, navigation menus, responsive previews, accessibility checks, and an operational
|
||||
dashboard. Public content remains ordinary HTML; the database server may continue its well-earned rest.
|
||||
To install:
|
||||
|
||||
Further technical documentation:
|
||||
|
||||
* [Architecture](docs/ARCHITECTURE.md)
|
||||
* [Development and testing](docs/DEVELOPMENT.md)
|
||||
|
||||
## Features
|
||||
|
||||
* No Database Required: NeoCMS works without a database, so you don’t have to worry about setup hassles or performance
|
||||
slowdowns.
|
||||
* Easy Content Editing: The TinyMCE visual editor updates marked content regions without requiring authors to edit HTML.
|
||||
Adding `neo-dupe` allows an element and its children to be cloned or removed.
|
||||
* Seamless Integration: Simply add the CMS to any existing static website. Just assign a special class to the elements
|
||||
you want to make editable, and you’re all set.
|
||||
* Consistent Layouts: NeoCMS supports page templates to keep your layouts neat and uniform across all pages.
|
||||
* Drafts and Publishing: Clicking Save in the editor keeps the change as a private draft without touching the public page.
|
||||
Editors can then publish immediately or schedule a publication.
|
||||
* Revision History: Every changed or deleted page receives a timestamped filesystem snapshot that editors can restore, and
|
||||
deleted pages can be recovered from the Dashboard.
|
||||
* Page Management: Search, duplicate, rename, and delete editable HTML pages, including pages in subdirectories. The New page section of the Pages dialogue is a
|
||||
three-step wizard (template, page name, navigation group); the new page stays a private draft until it is published. Any page can
|
||||
be offered as a template with Create Template. Administrators can Lock a page to stop editors publishing, saving a draft, or
|
||||
scheduling changes to it; administrators are never restricted by a lock.
|
||||
* Media Library: Upload images, documents, video, and audio; they are sorted into Imagery, Documents, Video, and Audio by file
|
||||
extension. Reuse them from the editor's Media library button, maintain alternative text or link text, see usage counts, and
|
||||
remove files. Executables and files containing code are refused.
|
||||
* SEO Settings: Edit the page title, description, canonical URL, Open Graph image, and robots indexing setting.
|
||||
* Shared Content: Mark content with `data-neo-shared="name"` and update matching regions across the site.
|
||||
* Navigation Menus: The site scan finds each page's primary and secondary navigation and pre-fills the menus. Edit, rename, or
|
||||
delete a menu and the change is applied to every page that uses it. Menus are created by the web developer's markup, not by the
|
||||
CMS.
|
||||
* Responsive Preview: Preview the current page at desktop, tablet, and mobile widths using the device icons in the top bar.
|
||||
* Accessibility Checks: Check common authoring problems such as missing alt text, empty links, heading jumps, missing labels,
|
||||
and absent page metadata before publishing.
|
||||
* Site Scan: Analyse every managed page and the media library, then make pages editable in one step. The scan
|
||||
finds content areas, images, navigation, broken local references, and missing SEO tags, and adds the markers NeoCMS needs.
|
||||
* Users and Roles: Everyone can update their display name, email address, and password in Tools > Users. Administrators can add,
|
||||
edit, invite, block, and delete accounts. Two roles, Editor and Administrator, are described on that screen.
|
||||
* Activity: The dashboard displays drafts, schedules, deleted pages, system problems, and recent activity.
|
||||
|
||||
## Installation Guide
|
||||
|
||||
To install NeoCMS:
|
||||
|
||||
* Place the `cms/` directory in the document root of the static website.
|
||||
* Place the `uploads/` directory in the same document root.
|
||||
* Copy `cms/config.local.php.example` to `cms/config.local.php`, add password hashes, and assign roles.
|
||||
* Update the elements you want to make editable by adding the configured editable class. It is `editable` by default.
|
||||
* Confirm that PHP may write to the managed HTML files, `cms/data/`, `cms/logs/`, and `uploads/`.
|
||||
* **Live sites:** create a folder outside the web root (for example `/var/neocms-data`), make it writable by PHP, and set
|
||||
`'dataDirectory' => '/var/neocms-data'` in `cms/config.local.php`. It must be an absolute path. Without this, accounts, drafts,
|
||||
and revisions live in `cms/data/`, which is protected only by the supplied `.htaccess`. Administrators see a Dashboard
|
||||
recommendation until the data folder is outside the web root. Audit logs remain in `cms/logs/`, also protected by `.htaccess`.
|
||||
* Visit `/cms/`, log in, and begin editing.
|
||||
|
||||
The site may also live in a subfolder (for example `http://localhost/mysite/`): the URL prefix is detected automatically. See
|
||||
[Subfolder installs](#subfolder-installs).
|
||||
|
||||
That’s it! You’re up and running.
|
||||
|
||||
## Configuration
|
||||
|
||||
Tracked defaults live in `cms/config.php`. Deployment credentials and overrides belong in the Git-ignored
|
||||
`cms/config.local.php`, which must return an array. The supplied Apache rules deny direct access to both files.
|
||||
|
||||
| Setting | Type | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `authentication` | array | Maps the main administrator usernames to `password_hash()` values; plaintext credentials are rejected. These accounts are managed only here. |
|
||||
| `roles` | array | Maps those usernames to `editor` or `administrator` (`publisher` is accepted as an alias of `editor`). |
|
||||
| `security` | array | Configures session expiry, login throttling, request limits, storage quotas, and audit-log retention. |
|
||||
| `uploads` | array | Configures the per-file size limit (all media types), image dimension, file-count, and aggregate-storage limits. |
|
||||
| `audit` | boolean | Enables daily activity logs under `cms/logs/`. |
|
||||
| `skipWelcomePage` | boolean | Opens the public root instead of the built-in guide after login. |
|
||||
| `showFullUrl` | boolean | Displays the current page path in the toolbar. |
|
||||
| `editableClass` | string | Selects the single CSS class used to identify editable regions. |
|
||||
|
||||
`dataDirectory` is the absolute path of the folder holding accounts (`users.json`), drafts, revisions, schedules, and menus. It
|
||||
defaults to `cms/data/`; on a live site, point it at a folder outside the web root (see the Installation Guide) so those files
|
||||
can never be served. The folder is created on first use if PHP may create it, and the CLI scheduler uses the same setting.
|
||||
|
||||
`basePath` (URL prefix of the site root, such as `/mysite`) and `siteRoot` (the folder containing `cms/`) are detected
|
||||
automatically and only need setting behind a path-rewriting proxy.
|
||||
|
||||
### Editable Class
|
||||
|
||||
The class used to identify editable regions is configurable:
|
||||
|
||||
```php
|
||||
'editableClass' => 'editable',
|
||||
```
|
||||
|
||||
Set it to any single valid CSS class name without a leading dot, for example:
|
||||
|
||||
```php
|
||||
'editableClass' => 'cms-content',
|
||||
```
|
||||
|
||||
Your page markup must then use that class:
|
||||
|
||||
```html
|
||||
<section class="cms-content">This region can be edited.</section>
|
||||
```
|
||||
|
||||
Invalid configured values fall back to `editable`. The `neo-dupe` class remains fixed because it marks repeatable blocks,
|
||||
not general editable content. When overriding `editableClass`, update the classes in `cms/templates/` as well so newly
|
||||
created pages are immediately discoverable by the page picker.
|
||||
|
||||
### Users and Roles
|
||||
|
||||
There are two kinds of account:
|
||||
|
||||
* **Config accounts** are the main administrators. Generate a hash with `password_hash()` and store it under `authentication` in
|
||||
`cms/config.local.php`, with a role under `roles`. They can be changed only in that file (in Tools > Users they are listed as
|
||||
"Config"; only their display name and email can be set from the CMS).
|
||||
* **CMS accounts** are created in **Tools > Users** and stored in `cms/data/users.json`.
|
||||
|
||||
```php
|
||||
'authentication' => [
|
||||
'admin' => '$2y$10$...',
|
||||
],
|
||||
'roles' => [
|
||||
'admin' => 'administrator',
|
||||
],
|
||||
```
|
||||
|
||||
There are two roles:
|
||||
|
||||
| Role | Can do |
|
||||
| --- | --- |
|
||||
| `editor` | Edit content, save drafts, upload images, publish or schedule pages, and restore revisions or deleted pages. Cannot change the site structure or manage users. |
|
||||
| `administrator` | Everything an editor can, plus creating new pages, duplicate, rename, delete and lock pages, templates, navigation menus, shared content, site scan, deleting media, and managing users. |
|
||||
|
||||
The former `publisher` role is now part of `editor`; existing `publisher` assignments are treated as `editor`. Users without an
|
||||
explicit valid role receive `editor`, the least powerful role.
|
||||
|
||||
#### The Users screen
|
||||
|
||||
* **My account** (every user): see your login name (set by an administrator), change the display name, the email address (needs the
|
||||
current password), and the password (current, new, confirmation; CMS accounts only). You sign in with your login name and
|
||||
password; the email address is contact information only.
|
||||
* **Roles**: a description of each role, with your own marked.
|
||||
* **User accounts** (administrators): add an account with a password, edit an account (name, email, role, optional password
|
||||
reset), invite someone, block or unblock, or delete. Add, edit, invite, block, and delete ask for the administrator's own
|
||||
password. You cannot block, delete, or demote yourself, and config accounts cannot be changed here.
|
||||
* **Invitations** produce a one-time link that is shown once and expires after 7 days. The invitee opens it and chooses their own
|
||||
password. No email is sent by the CMS.
|
||||
|
||||
#### Account protection
|
||||
|
||||
Passwords are 12 to 72 characters and are stored only as hashes. Invite tokens are stored only as hashes and work once. Secrets
|
||||
never appear in API responses or the activity log. Repeated wrong passwords are rate limited, every account event is recorded in
|
||||
the activity feed and audit log, and blocking, deleting, changing a role, or changing a password affects that user's sessions on
|
||||
their next request. `data/.htaccess` denies web access to `users.json`.
|
||||
|
||||
Sessions expire after 30 minutes of inactivity and 12 hours in total by default.
|
||||
|
||||
When HTTPS terminates at a trusted reverse proxy, set `'cookieSecure' => true` beneath `security` in the local override.
|
||||
This keeps the session cookie marked Secure even when PHP receives the proxy's internal HTTP request.
|
||||
|
||||
Audit files rotate at 10 MiB and are retained for 90 days by default. Override `auditMaxFileBytes` and
|
||||
`auditRetentionDays` beneath `security` where local retention obligations differ.
|
||||
|
||||
### Password Hash Example
|
||||
|
||||
Generate a hash on a trusted command line, then place only the resulting hash in `cms/config.local.php`:
|
||||
|
||||
```bash
|
||||
php -r 'echo password_hash(trim(fgets(STDIN)), PASSWORD_DEFAULT), PHP_EOL;'
|
||||
```
|
||||
|
||||
There is deliberately no enabled default account.
|
||||
|
||||
## Content Features
|
||||
|
||||
### Repeatable Blocks
|
||||
|
||||
Add `neo-dupe` alongside the configured editable class. The editor provides clone-before, clone-after, and delete controls:
|
||||
|
||||
```html
|
||||
<article class="editable neo-dupe">Repeatable content</article>
|
||||
```
|
||||
|
||||
### Shared Content
|
||||
|
||||
Use `data-neo-shared` to identify a global region:
|
||||
|
||||
```html
|
||||
<footer class="editable" data-neo-shared="site-footer">Shared footer content</footer>
|
||||
```
|
||||
|
||||
The Shared Content tool updates every HTML page containing the same key. Publishing a page also captures the current value
|
||||
of its shared regions.
|
||||
|
||||
### Navigation Menus
|
||||
|
||||
Menus come from your site's own navigation. The **site scan** finds each page's `<nav>`: a header, banner, or main navigation is
|
||||
treated as `primary`, a footer or legal navigation as `secondary`, and the block is marked in place with
|
||||
`data-neo-menu="name"`. The first page that contains a menu supplies its items, and a saved menu is never overwritten.
|
||||
|
||||
**Tools > Navigation Menus** (main toolbar) lists the menus. Choose **Edit** to change a menu's display name or items, one per
|
||||
line:
|
||||
|
||||
```text
|
||||
Home | /
|
||||
About | /about.html
|
||||
Team | /team.html | About
|
||||
```
|
||||
|
||||
The optional third value is the label of the parent item. Saving rewrites only the list inside each marked `<nav>` on every
|
||||
page (other bytes, the `<nav>` attributes, and `aria-current="page"` for the current page are handled), and skips pages that
|
||||
would not change. **Delete** removes a menu from the CMS; pages keep their current navigation. **Scan site for new navigation**
|
||||
finds navigation blocks a developer has added since the last scan.
|
||||
|
||||
The CMS does not create menus: new menus are the web developer's markup, discovered by the scan. The menu key cannot be renamed,
|
||||
but the display name can.
|
||||
|
||||
### Media Library
|
||||
|
||||
**Media** uploads and lists files in four categories, decided by the file extension:
|
||||
|
||||
| Category | Accepted extensions |
|
||||
| --- | --- |
|
||||
| Imagery | jpg, jpeg, png, gif, webp |
|
||||
| Documents | pdf, docx, xlsx, pptx, odt, ods, odp, txt, csv |
|
||||
| Video | mp4, m4v, webm, mov, ogv |
|
||||
| Audio | mp3, wav, ogg, oga, m4a, aac, flac |
|
||||
|
||||
Anything not in this list is refused: executables (`.exe`, `.dll`, `.bat`, `.msi`, ...), scripts and server code (`.php`, `.js`, `.sh`, ...),
|
||||
HTML, SVG, XML, archives, the older `.doc`/`.xls`/`.ppt` formats, and macro-enabled Office files. Every upload is also checked
|
||||
by content, not just name: the file type must match its extension; files that start like an executable or contain `<?php`,
|
||||
`<?=`, `<script`, or `<%` anywhere are refused (so a code payload hidden inside an image is caught); PDFs containing
|
||||
JavaScript, launch actions, or embedded files are refused; and Office/OpenDocument files are refused if they contain macros,
|
||||
embedded objects, scripts, or executables. Names such as `report.exe.pdf` are refused too. These checks are best-effort hardening,
|
||||
not a malware scanner, so keep server-side virus scanning where policy requires it.
|
||||
|
||||
Uploaded files are saved under `uploads/` with a generated name (`price-list-3f2a9c1d.pdf`); the original name is kept as the
|
||||
default link text. Every file is limited by `uploads.maxFileBytes` (10 MiB by default), and PHP's `upload_max_filesize` and
|
||||
`post_max_size` must allow at least that. In the editor, use the **Media library** button to insert an image, a document link, a
|
||||
video player, or an audio player at the cursor. Documents are served as downloads. `uploads/.htaccess` denies executable and markup
|
||||
extensions; for Nginx use the rule shown under Web Server Security.
|
||||
|
||||
### Site Scan
|
||||
|
||||
Administrators can open **Tools > Site scan** to analyse the whole site: page and folder counts, pages that can be made
|
||||
editable, images without alternative text, broken local image/CSS/script references, uploaded files that no page uses, and
|
||||
missing title, description, and Open Graph tags. Tick the pages and the kinds of change to make, then apply:
|
||||
|
||||
* **Content areas**: adds the editable class to `<main>` (or `role="main"`), otherwise to top-level `<article>` or
|
||||
text-bearing `<section>` elements. Headers, navigation, footers, asides, and forms are skipped, and `<body>` is never
|
||||
tagged. Pages that already contain an editable region are left alone; pages with no safe container are reported for manual
|
||||
tagging.
|
||||
* **Images**: adds `data-neo-image` to images outside editable regions. In the editor, clicking one opens a dialogue to pick
|
||||
a Media Library image or change its address and alternative text.
|
||||
* **Navigation**: marks each `<nav>` (primary/secondary) with `data-neo-menu` and pre-fills Navigation Menus from it.
|
||||
* **SEO tags**: inserts only the missing title, description, `og:title`, `og:description`, and `og:image` tags, with values
|
||||
taken from the page. Existing tags are never changed, and canonical and robots values are reported but not invented.
|
||||
|
||||
The scan edits page source directly rather than re-parsing it, so nothing else in a file changes, and a revision of every
|
||||
changed page is kept in Revision history.
|
||||
|
||||
### Drafts and Revisions
|
||||
|
||||
Drafts do not alter public HTML. When opening a page with a saved draft, NeoCMS offers to load it. Publishing creates a
|
||||
revision of the previous public file first. Deleting a page also retains its last revision, and **Dashboard > Deleted pages**
|
||||
lists pages whose newest revision is "Before delete" and whose file is gone, with a **Recover** button that recreates the page
|
||||
from that snapshot. Revisions are pruned by the configured retention limits.
|
||||
|
||||
### New Pages and Templates
|
||||
|
||||
The **New page** section (administrators), under the page list in **Pages**, is a three-step wizard: choose a template, give the page a name, then choose a navigation group
|
||||
(or none). The filename is generated from the name (`About Us` becomes `about-us.html`, numbered if taken). The page is saved
|
||||
only as a private draft: no file exists, so it is not visible on the site, until it is first published (Publish Now or a
|
||||
schedule). Publishing creates the file and adds a link to the chosen menu on every page. Unpublished pages appear in Pages as
|
||||
"New - unpublished" and can be discarded.
|
||||
|
||||
Templates are the files in `cms/templates/` plus any page you choose **Create Template** for in the Pages list. A template page
|
||||
is used live (later edits to it show in new pages), and the **Delete** action in the template list deletes a template file or
|
||||
stops using a page as a template (the page is kept).
|
||||
|
||||
### Locking Pages
|
||||
|
||||
Administrators can **Lock** a page from the Pages list to stop editors publishing, saving a draft, or scheduling a change to
|
||||
it; a **Locked** badge shows next to the page name for everyone. Administrators are never restricted by a lock, and locking
|
||||
is independent of Create Template, Duplicate, Rename, and Delete, which were already administrator-only. A lock follows a
|
||||
page when it is renamed, is cleared when the page is deleted, and is never copied onto a duplicate.
|
||||
|
||||
### Subfolder Installs
|
||||
|
||||
The site can live in a subfolder such as `http://localhost/neocms/`. The URL prefix (`basePath`) and the folder that contains
|
||||
`cms/` (`siteRoot`) are detected automatically, page links stored by menus are prefixed accordingly, and the session cookie path
|
||||
follows the CMS. Root installs are unchanged.
|
||||
|
||||
NeoCMS stores metadata, drafts, scheduled content, and revisions under `cms/data/`. Back up this directory together with
|
||||
the website. The supplied `.htaccess` denies direct HTTP access on Apache; other web servers should deny `/cms/data/` and
|
||||
`/cms/logs/` explicitly. Private directories and files are created with owner-only permissions where the operating system
|
||||
permits it. Configurable quotas bound drafts, schedules, revisions, and uploaded media to reduce disk-exhaustion risk.
|
||||
|
||||
### Scheduled Publishing
|
||||
|
||||
Due schedules are processed whenever the CMS API is used. For publication without an active CMS user, run the CLI worker
|
||||
once per minute with cron:
|
||||
|
||||
```cron
|
||||
* * * * * /usr/bin/php /absolute/path/to/site/cms/publish_scheduled.php >/dev/null 2>&1
|
||||
```
|
||||
|
||||
The server timezone controls PHP date handling; the browser converts the selected local time to an absolute timestamp.
|
||||
|
||||
## Administration Workflow
|
||||
|
||||
1. Open a page from **Pages** (or create one in the **New page** section under the list).
|
||||
2. Select a region carrying the configured editable class and edit it in TinyMCE. Clicking an image opens the image dialogue.
|
||||
3. In the editor, click **Save Draft** to keep your change private, **Publish Now** to replace the public HTML after
|
||||
creating a revision, or **Schedule Publish** when publication should occur later. **Cancel** discards unapplied edits.
|
||||
4. Check revisions, accessibility findings, SEO metadata, shared content, and menus through the relevant toolbar dialogues.
|
||||
|
||||
The top bar shows the current page path on the left; your name and Log out are at the top right, with the toolbar buttons
|
||||
(Dashboard, Pages, Media, SEO, Navigation Menus, Tools) and the desktop/tablet/mobile preview icons beneath. Tools holds
|
||||
Revision history, Accessibility check, Shared content, Site scan, and Users. Dialogue buttons share one style: small
|
||||
secondary buttons for row actions, larger buttons for actions (Save, Next, Apply), and blue for the primary action.
|
||||
|
||||
Save Draft in the content editor (and Apply in the image and SEO dialogues) stores the change as a private draft. It never writes
|
||||
the public file, which only changes on Publish. This is intentional and prevents an innocent wording change from making an
|
||||
unscheduled public debut. The page header shows **Draft:** once a draft is saved, and reopening the page offers to load it.
|
||||
|
||||
## Page Markup Recommendations
|
||||
|
||||
For the best SEO and accessibility results, each page should include one `<h1>`, a non-empty `<title>`, a meta description,
|
||||
alternative text on every image, descriptive link text, and labels associated with form fields. The checker highlights
|
||||
common authoring issues but is not a complete accessibility audit.
|
||||
* Drop in the CMS folder into a static website
|
||||
* Drop in the uploads folder into the static website
|
||||
* Edit credentials in /cms/init.php for any users that are required
|
||||
* Update editable divs to contain the class 'editable'
|
||||
* It _should_ then just work by visiting /cms/ and logging in
|
||||
|
||||
## Requirements
|
||||
|
||||
* PHP 8 running on Linux
|
||||
* PHP DOM and fileinfo extensions (the fileinfo extension is used to check uploads; the zip extension is not needed)
|
||||
* PHP sessions enabled
|
||||
* Write access to `cms/data/`, `cms/logs/`, `uploads/`, and managed HTML pages
|
||||
* A modern browser with JavaScript enabled
|
||||
* PHP 8 on Linux. None of that Windows Server crap
|
||||
|
||||
The administration shell loads jQuery 4.0.0 and jQuery UI 1.14.2 from the official jQuery CDN with Subresource Integrity.
|
||||
TinyMCE 8.6.0 is bundled locally. Installations that must operate without internet access should self-host the two jQuery
|
||||
assets and update both `cms/index.php` and the administration Content Security Policy.
|
||||
## Known Bugs
|
||||
|
||||
## Web Server Security
|
||||
* File (not images) uploads need to be implemented
|
||||
* Not completely tested
|
||||
|
||||
Apache users should allow the supplied `.htaccess` rules. For Nginx, add equivalent rules such as:
|
||||
|
||||
```nginx
|
||||
location ^~ /cms/data/ { deny all; }
|
||||
location ^~ /cms/logs/ { deny all; }
|
||||
location ~ ^/cms/(config(?:\.local)?\.php|config\.local\.php\.example|publish_scheduled\.php)$ { deny all; }
|
||||
location ~ ^/uploads/.*\.(php[0-9]?|phtml|phar|cgi|pl|py|sh|exe|dll|bat|cmd|com|msi|asp|aspx|jsp|jspx|html?|xhtml|shtml|svg|xml|js|jar|swf)$ { deny all; }
|
||||
```
|
||||
|
||||
Also disable directory listings, prevent PHP source download through server misconfiguration, serve the CMS only over
|
||||
HTTPS, and back up both public HTML and `cms/data/`. NeoCMS sends CSP, anti-framing, no-sniffing, referrer, permissions,
|
||||
cross-origin isolation, no-store, and HTTPS HSTS headers where appropriate.
|
||||
|
||||
Set request-body limits in the web server and PHP as well as in NeoCMS. For the default upload and API limits, a 12 MiB
|
||||
server limit leaves room for form overhead without letting oversized requests make themselves at home. For example:
|
||||
|
||||
```nginx
|
||||
client_max_body_size 12m;
|
||||
client_body_timeout 15s;
|
||||
```
|
||||
|
||||
Match `post_max_size` and `upload_max_filesize` to your configured `security.maxRequestBytes` and `uploads.maxFileBytes`.
|
||||
|
||||
The page preview iframe is sandboxed with `allow-same-origin` but without `allow-scripts`. This preserves DOM editing while
|
||||
preventing public page scripts from reaching the parent administration session.
|
||||
|
||||
## Testing
|
||||
|
||||
Run the dependency-free integration suite with:
|
||||
|
||||
```bash
|
||||
php tests/run.php
|
||||
```
|
||||
|
||||
It exercises authentication hardening, session revocation, login throttling, iframe sandboxing, path containment, content
|
||||
limits, configurable editable regions, drafts, publication, revisions, page operations, new-page privacy and templates,
|
||||
shared content, menu detection and propagation, user accounts (roles, invitations, blocking, password confirmation, config
|
||||
accounts), and scheduled publishing in temporary directories. See [Development and testing](docs/DEVELOPMENT.md) for the complete check
|
||||
list used by this repository.
|
||||
|
||||
## Licence
|
||||
|
||||
NeoCMS is open-source software licensed under the GNU General Public Licence v3.0 (GPLv3). You are free to use, modify,
|
||||
and distribute this software under the terms of this licence.
|
||||
|
||||
For the complete terms, see [LICENSE](LICENSE).
|
||||
|
||||
## Found a Bug?
|
||||
|
||||
Report defects and feature requests through the project's GitHub issue tracker.
|
||||
If any issues arise, raise it as a bug in the issues page on GitHub
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
# Prevent directory listings and direct access to configuration or CLI-only entry points.
|
||||
Options -Indexes
|
||||
|
||||
<FilesMatch "^(config(?:\.local)?\.php|config\.local\.php\.example|publish_scheduled\.php)$">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
|
||||
<IfModule mod_headers.c>
|
||||
Header always set X-Content-Type-Options "nosniff"
|
||||
Header always set Referrer-Policy "no-referrer"
|
||||
</IfModule>
|
||||
@@ -1,22 +0,0 @@
|
||||
<?php
|
||||
/** Shared entry-point setup: loads configuration into $config and registers the NeoCMS class autoloader. */
|
||||
|
||||
// Keep stack traces and filesystem paths out of responses; every engine warning still reaches PHP's own error log.
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', '0');
|
||||
ini_set('display_startup_errors', '0');
|
||||
ini_set('log_errors', '1');
|
||||
|
||||
require_once __DIR__ . '/config.php';
|
||||
|
||||
// URL prefix of the site root: "/neocms" for http://localhost/neocms/, "" for a site at the domain root.
|
||||
// Derived from this script's URL unless config.local.php sets 'basePath' (e.g. behind a path-rewriting proxy).
|
||||
$config['basePath'] = rtrim((string) ($config['basePath'] ?? (preg_match('#^(.*)/cms/#', $_SERVER['SCRIPT_NAME'] ?? '', $neoBase) ? $neoBase[1] : '')), '/');
|
||||
// The site root on disk is always the folder containing cms/, whatever DOCUMENT_ROOT says.
|
||||
$config['siteRoot'] ??= dirname(__DIR__);
|
||||
$config['security']['cookiePath'] = $config['basePath'] . '/cms';
|
||||
|
||||
spl_autoload_register(function ($class) {
|
||||
$classPath = str_replace('\\', DIRECTORY_SEPARATOR, $class);
|
||||
require_once __DIR__ . "/src/{$classPath}.php";
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copy this file to config.local.php and replace the example values.
|
||||
* config.local.php is ignored by Git and denied by the supplied Apache rules.
|
||||
*/
|
||||
return [
|
||||
'authentication' => [
|
||||
'admin' => '$2y$10$replace-with-a-real-password-hash',
|
||||
],
|
||||
'roles' => [
|
||||
'admin' => 'administrator',
|
||||
],
|
||||
// Live sites: store CMS data (accounts, drafts, revisions) OUTSIDE the web root, as an absolute path PHP can write to,
|
||||
// for example '/var/neocms-data'. The default cms/data/ is protected only by .htaccess.
|
||||
// 'dataDirectory' => '/var/neocms-data',
|
||||
// Set this to true when HTTPS is terminated by a trusted reverse proxy.
|
||||
'security' => [
|
||||
'cookieSecure' => true,
|
||||
// Uncomment only where local audit-retention policy differs from the secure defaults.
|
||||
// 'auditMaxFileBytes' => 10 * 1024 * 1024,
|
||||
// 'auditRetentionDays' => 90,
|
||||
],
|
||||
];
|
||||
@@ -1,67 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* NeoCMS site configuration.
|
||||
*
|
||||
* Keep credentials in the ignored config.local.php override rather than this tracked defaults file.
|
||||
* No account is enabled by default; configure a password hash and role before exposing /cms/.
|
||||
*/
|
||||
|
||||
$config = [
|
||||
'authentication' => [
|
||||
// 'admin' => '$2y$10$replace-with-a-real-password-hash',
|
||||
],
|
||||
// Accounts here are the main administrators and are managed only in this file. Other accounts are created in the CMS (Tools > Users).
|
||||
// Roles: editor (edit, publish, schedule) or administrator (everything, including users). The old 'publisher' role counts as editor.
|
||||
'roles' => [
|
||||
// 'admin' => 'administrator',
|
||||
],
|
||||
// Session expiry and login throttling. Set cookieSecure to true behind a TLS reverse proxy.
|
||||
'security' => [
|
||||
'idleTimeout' => 1800,
|
||||
'absoluteTimeout' => 43200,
|
||||
'cookieSecure' => null,
|
||||
'loginWindowSeconds' => 900,
|
||||
'loginLockoutSeconds' => 900,
|
||||
'loginMaxAttempts' => 5,
|
||||
'loginMaxAddressAttempts' => 20,
|
||||
'maxContentBytes' => 5 * 1024 * 1024,
|
||||
'maxRequestBytes' => 6 * 1024 * 1024,
|
||||
'maxManagedPages' => 5000,
|
||||
'maxScannedEntries' => 20000,
|
||||
'maxDraftBytes' => 250 * 1024 * 1024,
|
||||
'maxSchedules' => 100,
|
||||
'maxScheduledBytes' => 250 * 1024 * 1024,
|
||||
'maxRevisionsPerPage' => 50,
|
||||
'maxRevisionsTotal' => 2000,
|
||||
'maxRevisionBytes' => 500 * 1024 * 1024,
|
||||
'auditMaxFileBytes' => 10 * 1024 * 1024,
|
||||
'auditRetentionDays' => 90,
|
||||
],
|
||||
// Upload constraints limit both individual image complexity and total disk consumption.
|
||||
'uploads' => [
|
||||
'maxFileBytes' => 10 * 1024 * 1024,
|
||||
'maxWidth' => 8192,
|
||||
'maxHeight' => 8192,
|
||||
'maxPixels' => 24 * 1024 * 1024,
|
||||
'maxFiles' => 2000,
|
||||
'maxTotalBytes' => 500 * 1024 * 1024,
|
||||
],
|
||||
// Record authentication and content-management activity in daily files under cms/logs/.
|
||||
'audit' => true,
|
||||
// Skip the built-in welcome page and open the public site immediately after login.
|
||||
'skipWelcomePage' => false,
|
||||
// Display the path of the page being edited in the administration toolbar.
|
||||
'showFullUrl' => true,
|
||||
// CSS class used to mark page regions as editable. Use a single class name without a leading dot.
|
||||
'editableClass' => 'editable',
|
||||
];
|
||||
|
||||
// Merge deployment credentials and overrides from the untracked local configuration, when present.
|
||||
$localConfigPath = __DIR__ . '/config.local.php';
|
||||
if (is_file($localConfigPath)) {
|
||||
$localConfig = require $localConfigPath;
|
||||
if (!is_array($localConfig)) {
|
||||
throw new \RuntimeException('cms/config.local.php must return an array');
|
||||
}
|
||||
$config = array_replace_recursive($config, $localConfig);
|
||||
}
|
||||
+63
-12
@@ -1,16 +1,67 @@
|
||||
<?php
|
||||
/**
|
||||
* JSON API entry point for authenticated CMS operations.
|
||||
*
|
||||
* The controller performs authentication, authorisation, CSRF validation, and dispatch. Keeping
|
||||
* this file thin makes it harder for an endpoint to wander off and invent its own security rules.
|
||||
*/
|
||||
require_once "../neoCMSCore.php";
|
||||
require_once "../init.php";
|
||||
|
||||
// Load the site-specific credentials, roles, and content settings.
|
||||
require_once __DIR__ . '/../bootstrap.php';
|
||||
switch ($_REQUEST["action"]) {
|
||||
case "save":
|
||||
header('Content-type: application/json');
|
||||
if (!empty($_POST['uri']) && !empty($_POST['content'])) {
|
||||
$_SESSION['core']->saveContent($_POST['uri'], $_POST['content']);
|
||||
echo json_encode(array('message'=>'Page has been saved'));
|
||||
} else {
|
||||
echo json_encode(array('error'=>'Unable to save page'));
|
||||
}
|
||||
break;
|
||||
|
||||
use NeoCMS\CMSController;
|
||||
/**
|
||||
* getTemplates AJAX call to get list of files
|
||||
*/
|
||||
case "getTemplates":
|
||||
header('Content-type: application/json');
|
||||
$files = array_diff(scandir("../templates/"), array('..', '.'));
|
||||
$returnArray = [];
|
||||
foreach ($files as $file) {
|
||||
$returnArray[] = array("id" => $file, "name" => str_replace(array(".html", ".htm"), "", $file));
|
||||
}
|
||||
echo json_encode($returnArray);
|
||||
break;
|
||||
|
||||
// Dispatch the current request and allow the controller to emit its JSON response.
|
||||
$controller = new CMSController($config ?? []);
|
||||
$controller->handleRequest();
|
||||
/**
|
||||
* newPage AJAX call to take a template file and create a new page
|
||||
*/
|
||||
case "newPage":
|
||||
header('Content-type: application/json');
|
||||
|
||||
$filename = str_replace("..", "", $_POST['filename']);
|
||||
$template = str_replace("..", "", $_POST['template']);
|
||||
if (!str_ends_with($filename, ".htm") && !str_ends_with($filename, ".html")) {
|
||||
$filename .= ".html";
|
||||
}
|
||||
|
||||
// Some vhosts can supply a trailing slash on DOCUMENT_ROOT so tidy it up
|
||||
$destination = str_replace("//", "/", $_SERVER['DOCUMENT_ROOT'] . "/" . $filename);
|
||||
|
||||
|
||||
if (!file_exists("../templates/" . $template)) die(json_encode(array("error" => "Template not found")));
|
||||
if (file_exists($destination)) die(json_encode(array("error" => "Page or file already exists")));
|
||||
|
||||
file_put_contents($destination, file_get_contents("../templates/" . $template));
|
||||
if (!file_exists($destination)) die(json_encode(array("error" => "Template copy failed")));
|
||||
echo json_encode(array("ok" => "DONE_THE_NEEDFUL", "url" => str_replace("//", "/", "/" . $filename)));
|
||||
break;
|
||||
|
||||
case "getPages":
|
||||
header('Content-type: application/json');
|
||||
$it = new RecursiveDirectoryIterator("../../");
|
||||
$returnArray = [];
|
||||
foreach (new RecursiveIteratorIterator($it) as $file) {
|
||||
// Strip document root from URI, so relative
|
||||
$file = str_replace($_SERVER["DOCUMENT_ROOT"], "", $file->getRealPath());
|
||||
|
||||
if (!str_starts_with($file, "/cms/") && (str_ends_with($file, ".html") || str_ends_with($file, ".htm")))
|
||||
$returnArray[] = array("name" => $file, "url" => $file);
|
||||
}
|
||||
echo json_encode($returnArray);
|
||||
break;
|
||||
|
||||
}
|
||||
+84
-469
@@ -1,176 +1,110 @@
|
||||
/* Full-height application shell: the toolbar takes what it needs and the preview takes the rest. */
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.pageContainer {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
height: 100%;
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Dark, wrapping administration toolbar shared by wide and narrow screens. */
|
||||
.pageContainer .controls {
|
||||
flex: 0 0 auto;
|
||||
min-height: 60px;
|
||||
position: relative;
|
||||
background-color: #1a1d20;
|
||||
flex: 0 1 auto;
|
||||
height: 70px;
|
||||
background-color: #666666;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
-webkit-box-shadow: 0 10px 13px -7px #000000, -1px -1px 15px 5px rgba(0, 0, 0, 0);
|
||||
box-shadow: 0 10px 13px -7px #000000, -1px -1px 15px 5px rgba(0, 0, 0, 0);
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
grid-template-areas: "logo url user" "logo url buttons";
|
||||
align-items: center;
|
||||
gap: 6px 16px;
|
||||
padding: 10px 16px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
-webkit-box-shadow: 0px 10px 13px -7px #000000, -1px -1px 15px 5px rgba(0, 0, 0, 0);
|
||||
box-shadow: 0px 10px 13px -7px #000000, -1px -1px 15px 5px rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
/* Same-origin preview iframe; JavaScript narrows its width for responsive previews. */
|
||||
.pageContainer .frame {
|
||||
flex: 1 1 auto;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Product mark remains a fixed size while neighbouring controls wrap around it. */
|
||||
.controls .logo {
|
||||
background-image: url("../img/logo.png");
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background-image: url("/cms/img/logo.png");
|
||||
width: 128px;
|
||||
height: 70px;
|
||||
display: block;
|
||||
grid-area: logo;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
/* Top right: user and Log out; the menu buttons sit directly beneath them. */
|
||||
.controls .buttonContainer {
|
||||
grid-area: buttons;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 7px 12px;
|
||||
width: 400px;
|
||||
margin: 18px auto;
|
||||
}
|
||||
|
||||
.toolbar-row {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
|
||||
/* Shared toolbar button treatment, including keyboard focus and destructive emphasis. */
|
||||
.headerButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 38px;
|
||||
padding: 8px 14px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Roboto', sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
|
||||
background: #6E6D70;
|
||||
box-shadow: 0px 0.5px 1px rgba(0, 0, 0, 0.1), inset 0px 0.5px 0.5px rgba(255, 255, 255, 0.5), 0px 0px 0px 0.5px rgba(0, 0, 0, 0.12);
|
||||
color: #DFDEDF;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.headerButton:focus {
|
||||
box-shadow: inset 0px 0.8px 0px -0.25px rgba(255, 255, 255, 0.2), 0px 0.5px 1px rgba(0, 0, 0, 0.1), 0px 0px 0px 3.5px rgba(58, 108, 217, 0.5);
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.headerButton:hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
/* Current page path and unpublished-state label. */
|
||||
.controls #urlbox {
|
||||
grid-area: url;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.3;
|
||||
color: #dee2e6;
|
||||
}
|
||||
|
||||
.controls #urlbox div {
|
||||
display: inline-block;
|
||||
margin-left: 10px;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: bottom;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Compact account, role, logout, and operational-warning area. */
|
||||
.controls .loggedInDetails {
|
||||
grid-area: user;
|
||||
justify-self: end;
|
||||
color: #dee2e6;
|
||||
text-align: right;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
position: absolute;
|
||||
top: 25px;
|
||||
right: 10px;
|
||||
}
|
||||
|
||||
/* Name and Log out share one line. */
|
||||
.controls .loggedInDetails br:first-of-type { display: none; }
|
||||
.controls .loggedInDetails .link-button { margin-left: 10px; }
|
||||
|
||||
.link-button {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
color: #9ecbff;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Legacy list support retained for installations with locally customised toolbar markup. */
|
||||
.controls .buttonContainer ul {
|
||||
list-style-type: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.controls .buttonContainer li {
|
||||
display: block;
|
||||
display: inline;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
/* New-page dialogue: template selection followed by the destination filename. */
|
||||
.blueButton {
|
||||
box-shadow: inset 0px 0px 15px 3px #23395e;
|
||||
background: linear-gradient(to bottom, #2e466e 5%, #415989 100%);
|
||||
background-color: #2e466e;
|
||||
border-radius: 17px;
|
||||
border: 1px solid #1f2f47;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
color: #ffffff;
|
||||
font-family: Arial;
|
||||
font-size: 15px;
|
||||
padding: 6px 13px;
|
||||
text-decoration: none;
|
||||
text-shadow: 0px 1px 0px #263666;
|
||||
}
|
||||
|
||||
.blueButton:hover {
|
||||
background: linear-gradient(to bottom, #415989 5%, #2e466e 100%);
|
||||
background-color: #415989;
|
||||
}
|
||||
|
||||
.greenButton {
|
||||
box-shadow: inset 0px 0px 15px 3px #3e7327;
|
||||
background: linear-gradient(to bottom, #77b55a 5%, #72b352 100%);
|
||||
background-color: #77b55a;
|
||||
border-radius: 17px;
|
||||
border: 1px solid #4b8f29;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
color: #ffffff;
|
||||
font-family: Arial;
|
||||
font-size: 15px;
|
||||
padding: 6px 13px;
|
||||
text-decoration: none;
|
||||
text-shadow: 0px 1px 0px #5b8a3c;
|
||||
}
|
||||
|
||||
.greenButton:hover {
|
||||
background: linear-gradient(to bottom, #72b352 5%, #77b55a 100%);
|
||||
background-color: #72b352;
|
||||
}
|
||||
|
||||
.greenButton:active {
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
New Page Modal
|
||||
*/
|
||||
|
||||
#newPageDialog {
|
||||
display: none;
|
||||
@@ -202,355 +136,36 @@ body {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Page-picker dialogue and searchable action rows. */
|
||||
/**
|
||||
File List Modal
|
||||
*/
|
||||
|
||||
#fileListDialog {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#pageSearch {
|
||||
width: 100%;
|
||||
padding: 9px;
|
||||
margin-bottom: 12px;
|
||||
box-sizing: border-box;
|
||||
.filelist-content {
|
||||
padding: 20px;
|
||||
background-color: #fefefe;
|
||||
border: 1px solid #888;
|
||||
width: 400px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.cms-dialog label {
|
||||
display: block;
|
||||
margin: 12px 0;
|
||||
font-weight: 600;
|
||||
#fileList {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Consistent form controls across SEO, scheduling, shared-content, and menu dialogues. */
|
||||
.cms-dialog input[type="text"],
|
||||
.cms-dialog input[type="email"],
|
||||
.cms-dialog input[type="password"],
|
||||
.cms-dialog select,
|
||||
.cms-dialog input[type="url"],
|
||||
.cms-dialog input[type="datetime-local"],
|
||||
.cms-dialog textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 5px;
|
||||
padding: 8px;
|
||||
box-sizing: border-box;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/* Content-editor actions sit beneath TinyMCE now that no Bootstrap modal is required. */
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* Light, regular-weight buttons; Publish Now is the only filled one, in green. */
|
||||
#editModal .editor-actions button {
|
||||
padding: 6px 14px;
|
||||
border: 1px solid #c9cfd8;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: #374151;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#editModal .editor-actions button:hover {
|
||||
background: #f3f5f8;
|
||||
}
|
||||
|
||||
#editModal .editor-actions #saveBtn {
|
||||
border-color: #9db8d8;
|
||||
color: #2f5a8a;
|
||||
}
|
||||
|
||||
#editModal .editor-actions #saveBtn:hover {
|
||||
background: #eaf1fb;
|
||||
}
|
||||
|
||||
#editModal .editor-actions .publish-now {
|
||||
background: #2e7d4f;
|
||||
border-color: #2e7d4f;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
#editModal .editor-actions .publish-now:hover {
|
||||
background: #256a42;
|
||||
}
|
||||
|
||||
/* Tool launcher expands to available width without requiring a row of tiny buttons. */
|
||||
.tool-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tool-grid button,
|
||||
.cms-dialog button,
|
||||
#fileList button {
|
||||
padding: 7px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Media cards keep previews useful while accommodating long generated filenames. */
|
||||
.media-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 14px;
|
||||
max-height: 65vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.media-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
#fileList li {
|
||||
padding: 10px;
|
||||
border: 1px solid #ccc;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.media-card img {
|
||||
width: 100%;
|
||||
height: 130px;
|
||||
object-fit: contain;
|
||||
background: #eee;
|
||||
cursor: pointer;
|
||||
background-color: #f0f0f0;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
/* Reusable rows serve revisions, schedules, activity, shared blocks, and menus. */
|
||||
.list-row {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 5px 0;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
text-align: left;
|
||||
box-sizing: border-box;
|
||||
#fileList li:hover {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
/* Findings use a restrained warning treatment; no flashing sirens are required. */
|
||||
.issue {
|
||||
margin: 7px 0;
|
||||
padding: 9px;
|
||||
border-left: 4px solid #b52b3a;
|
||||
background: #fff0f1;
|
||||
}
|
||||
|
||||
/* Semantic status colours shared by checks and destructive actions. */
|
||||
.success-text {
|
||||
color: #18733c;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.danger-text {
|
||||
color: #a51d2d;
|
||||
}
|
||||
|
||||
.redText {
|
||||
color: #ff4d4d;
|
||||
}
|
||||
|
||||
/* At medium widths the buttons take their own row under the user block; the path stays beside the logo. */
|
||||
@media (max-width: 1100px) {
|
||||
.pageContainer .controls {
|
||||
grid-template-areas: "logo url user" "buttons buttons buttons";
|
||||
}
|
||||
}
|
||||
|
||||
/* On small screens controls stack into touch-friendly full-width rows; the user block stays top right. */
|
||||
@media (max-width: 760px) {
|
||||
.pageContainer .controls {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
grid-template-areas: "logo user" "url url" "buttons buttons";
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.controls .logo {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.toolbar-row {
|
||||
flex: 1 1 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.controls #urlbox {
|
||||
text-align: left;
|
||||
}
|
||||
.controls .buttonContainer ul {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.controls .buttonContainer li,
|
||||
.controls .buttonContainer .headerButton {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.controls #urlbox div {
|
||||
display: block;
|
||||
margin-left: 0;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Site scan dialogue: selectable page rows and the option group. */
|
||||
#siteScanOptions { border: 1px solid #ccc; margin: 12px 0; padding: 8px 12px; }
|
||||
#siteScanOptions label { display: block; margin: 4px 0; }
|
||||
#siteScanPages { max-height: 40vh; overflow: auto; }
|
||||
.scan-row { display: block; cursor: pointer; }
|
||||
.scan-note { color: #555; font-size: 0.9em; }
|
||||
|
||||
/* Image dialogue: thumbnail picker and address fields. */
|
||||
#imagePicker img { cursor: pointer; }
|
||||
#imageForm label { display: block; margin: 8px 0; }
|
||||
#imageForm input[type="text"] { display: block; width: 100%; box-sizing: border-box; }
|
||||
/* Page picker: full-width table with a document icon, draft badge, and per-row actions. */
|
||||
.filelist-content { padding: 4px 0 0; }
|
||||
.table-wrap { max-height: 60vh; overflow: auto; border: 1px solid #dfe3ea; border-radius: 8px; }
|
||||
.data-table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
.data-table th {
|
||||
position: sticky; top: 0; z-index: 1; text-align: left; padding: 10px 14px;
|
||||
background: #f3f5f8; color: #4b5563; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em;
|
||||
border-bottom: 1px solid #dfe3ea;
|
||||
}
|
||||
.data-table td { padding: 10px 14px; border-bottom: 1px solid #edf0f4; vertical-align: middle; }
|
||||
.data-table tbody tr:last-child td { border-bottom: 0; }
|
||||
.data-table tbody tr:nth-child(even) { background: #fafbfc; }
|
||||
.page-row { cursor: pointer; }
|
||||
.page-row:hover { background: #eaf1fb !important; }
|
||||
.page-cell { white-space: nowrap; }
|
||||
.doc-icon { vertical-align: middle; margin-right: 8px; color: #4a7bb7; flex: none; }
|
||||
.data-table .page-open { border: 0; background: none; padding: 0; font: inherit; font-weight: 600; color: #1f2937; cursor: pointer; text-align: left; }
|
||||
.data-table .page-open:hover, .data-table .page-open:focus { color: #2563a8; text-decoration: underline; }
|
||||
.page-title { color: #4b5563; }
|
||||
.page-date { color: #6b7280; white-space: nowrap; }
|
||||
.row-actions { white-space: nowrap; text-align: right; }
|
||||
.row-actions button, .row-button { margin-left: 4px; padding: 4px 9px; font-size: 12px; cursor: pointer; }
|
||||
.badge-draft {
|
||||
margin-left: 10px; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 600;
|
||||
background: #fff3d6; color: #8a5a00; border: 1px solid #f0d58a;
|
||||
}
|
||||
.badge-locked { background: #fde2e1; color: #8a1f1f; border-color: #f3b4b2; }
|
||||
@media (max-width: 700px) { #fileList .page-title, #fileList th:nth-child(2), #fileList .page-date, #fileList th:nth-child(3) { display: none; } }
|
||||
|
||||
/* Site scan progress bar and file count. */
|
||||
#siteScanProgress { margin: 0 0 12px; }
|
||||
#scanProgressBar { width: 100%; height: 16px; accent-color: #4a7bb7; }
|
||||
#scanProgressText { margin-top: 4px; font-size: 13px; color: #4b5563; }
|
||||
|
||||
|
||||
/* Shared polish for dialogue content: stat tiles, section headings, empty states, badges. */
|
||||
.stat-row { display: flex; flex-wrap: wrap; gap: 10px; margin: 0 0 14px; }
|
||||
.stat { padding: 10px 14px; background: #f3f5f8; border: 1px solid #dfe3ea; border-radius: 8px; font-size: 13px; color: #4b5563; }
|
||||
.stat strong { font-size: 18px; color: #1f2937; margin-right: 2px; }
|
||||
.dialog-section { margin: 18px 0 8px; font-size: 14px; color: #374151; }
|
||||
.empty-state { padding: 18px; text-align: center; color: #6b7280; background: #fafbfc; border: 1px dashed #dfe3ea; border-radius: 8px; }
|
||||
.page-sub { display: block; margin: 2px 0 0 26px; font-size: 12px; font-weight: 400; color: #6b7280; white-space: normal; }
|
||||
.check-cell { width: 34px; text-align: center; }
|
||||
.data-table .page-cell strong { font-weight: 600; color: #1f2937; }
|
||||
.badge-pending, .badge-failed { padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; }
|
||||
.badge-pending { background: #e7f0fb; color: #1e5a99; border: 1px solid #bcd3ee; }
|
||||
.badge-failed { background: #fdeaec; color: #a51d2d; border: 1px solid #f0b8bf; }
|
||||
.issue { border-radius: 6px; }
|
||||
|
||||
/* New-page templates as selectable rows. */
|
||||
#radioList { display: grid; gap: 8px; margin-bottom: 14px; }
|
||||
.template-option { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border: 1px solid #dfe3ea; border-radius: 8px; cursor: pointer; }
|
||||
.template-option:hover { background: #f3f7fc; }
|
||||
.template-option:has(input:checked) { border-color: #4a7bb7; background: #eaf1fb; }
|
||||
.template-option .doc-icon { margin: 0; }
|
||||
|
||||
/* Media library cards. */
|
||||
.media-card { border-radius: 10px; border-color: #dfe3ea; background: #fff; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); transition: box-shadow 0.15s; }
|
||||
.media-card:hover { box-shadow: 0 4px 14px rgba(0, 0, 0, 0.12); }
|
||||
.media-card img { border-radius: 6px; }
|
||||
.media-card strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; }
|
||||
.media-card small { color: #6b7280; }
|
||||
|
||||
/* Snackbar: bottom-centre toast for success and error messages. */
|
||||
#message-bar {
|
||||
position: fixed; left: 50%; bottom: 28px; z-index: 100000;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
min-width: 280px; max-width: min(560px, calc(100vw - 32px)); box-sizing: border-box;
|
||||
padding: 12px 10px 12px 14px; background: #fff; color: #1f2937;
|
||||
border-left: 5px solid #18733c; border-radius: 10px; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.28);
|
||||
font: 14px/1.4 system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
opacity: 0; transform: translate(-50%, 24px); pointer-events: none;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
#message-bar[hidden] { display: none; }
|
||||
#message-bar.show { opacity: 1; transform: translate(-50%, 0); pointer-events: auto; }
|
||||
#message-bar.is-error { border-left-color: #a51d2d; }
|
||||
.snack-icon { flex: none; display: grid; place-items: center; width: 24px; height: 24px; border-radius: 50%; background: #18733c; color: #fff; font-size: 14px; font-weight: 700; }
|
||||
#message-bar.is-error .snack-icon { background: #a51d2d; }
|
||||
#message-bar.is-success .snack-icon::before { content: "\2713"; }
|
||||
#message-bar.is-error .snack-icon::before { content: "!"; }
|
||||
.snack-text { flex: 1; word-break: break-word; }
|
||||
.snack-close { flex: none; border: 0; background: none; padding: 0 6px; font-size: 22px; line-height: 1; color: #6b7280; cursor: pointer; }
|
||||
.snack-close:hover { color: #111827; }
|
||||
@media (prefers-reduced-motion: reduce) { #message-bar { transition: none; } }
|
||||
|
||||
/* New-page dialogue: fill the dialogue instead of sitting in a bordered box inside it. */
|
||||
.newpage-content { padding: 0; background: none; border: 0; width: auto; }
|
||||
#newPageForm > label[for="filename"] { margin: 4px 0 6px; font-weight: 600; }
|
||||
#newPageForm input[type="text"] { width: 100%; box-sizing: border-box; padding: 9px; margin-bottom: 16px; border: 1px solid #c9cfd8; border-radius: 6px; }
|
||||
/* Dialogue buttons use the small row-button metrics; primary actions are filled blue. */
|
||||
.ui-dialog .ui-dialog-content :where(button:not([class*="tox"]):not([class*="ui-"]):not(.page-open)) {
|
||||
padding: 4px 9px; font-family: inherit; font-size: 12px; font-weight: 400; color: #374151;
|
||||
background: #fff; border: 1px solid #c9cfd8; border-radius: 4px; cursor: pointer;
|
||||
}
|
||||
.ui-dialog .ui-dialog-content :where(button:not([class*="tox"]):not([class*="ui-"]):not(.page-open)):hover { background: #f3f5f8; }
|
||||
.ui-dialog .ui-dialog-content button.danger-text { color: #a51d2d; }
|
||||
.ui-dialog .ui-dialog-content button[type="submit"], .ui-dialog .ui-dialog-content .step-next, .ui-dialog .ui-dialog-content #siteScanApply {
|
||||
border-color: #3b6ea5; background: #3b6ea5; color: #fff; font-weight: 600;
|
||||
}
|
||||
.ui-dialog .ui-dialog-content button[type="submit"]:hover, .ui-dialog .ui-dialog-content .step-next:hover, .ui-dialog .ui-dialog-content #siteScanApply:hover { background: #2f5a8a; }
|
||||
#fileList .row-actions button { padding: 4px 9px; font-size: 12px; }
|
||||
/* Action buttons (editor Save/Schedule/Publish, dialogue Save/Next/Apply) are a step larger than row buttons. */
|
||||
#editModal .editor-actions button, .ui-dialog .ui-dialog-content button[type="submit"], .ui-dialog .ui-dialog-content .step-next, .ui-dialog .ui-dialog-content .step-back, .ui-dialog .ui-dialog-content #siteScanApply { padding: 6px 14px; font-size: 13px; }
|
||||
#sharedList, #menuList { margin-bottom: 16px; }
|
||||
|
||||
/* Device-preview buttons are icon-only; the active one is highlighted. */
|
||||
.viewport-button {
|
||||
min-width: 42px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.viewport-button.active {
|
||||
outline: 2px solid rgba(255, 255, 255, 0.85);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
|
||||
/* New-page wizard. */
|
||||
.step-indicator { margin: 0 0 4px; font-size: 12px; color: #6b7280; text-transform: uppercase; letter-spacing: .04em; }
|
||||
.step-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
|
||||
#menuChoice { display: grid; gap: 8px; }
|
||||
#newPageForm section[hidden] { display: none; }
|
||||
|
||||
/* Wizard lists reuse the page-table look. */
|
||||
#radioList, #menuChoice { display: block; margin-bottom: 14px; }
|
||||
#radioList .table-wrap, #menuChoice .table-wrap { max-height: 46vh; overflow: auto; }
|
||||
|
||||
/* Users dialogue. */
|
||||
#usersDialog form { margin-bottom: 8px; }
|
||||
#usersDialog #inviteLink { width: calc(100% - 100px); box-sizing: border-box; padding: 6px; }
|
||||
|
||||
/* Important administrator recommendations (for example the data directory notice). */
|
||||
.important-note, .cms-dialog .scan-note.important-note { color: #b3141f; font-weight: 600; }
|
||||
|
||||
/* Media library: upload, category tabs, and file cards. */
|
||||
.media-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin-bottom: 10px; }
|
||||
.media-tabs { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.media-tab[aria-selected="true"] { background: #3b6ea5 !important; border-color: #3b6ea5 !important; color: #fff !important; }
|
||||
.media-thumb { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; height: 130px; background: #eef2f5; color: #4a7bb7; font-weight: 600; font-size: 12px; }
|
||||
.media-thumb svg { width: 40px; height: 40px; }
|
||||
.media-actions { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
#mediaUploadReport:empty { display: none; }
|
||||
|
||||
/* New page wizard sits under the page list in the Pages dialogue. */
|
||||
#newPageSection { margin-top: 22px; padding-top: 6px; border-top: 1px solid #d4dde4; }
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
/* Standalone login palette kept deliberately separate from the administration application. */
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
background-color: #111519;
|
||||
}
|
||||
|
||||
/* Centred credential card with enough contrast to survive a late-night publishing session. */
|
||||
.login-container {
|
||||
width: 350px;
|
||||
margin: 100px auto;
|
||||
padding: 40px;
|
||||
background-color: #212529;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 10px rgb(0, 0, 0);
|
||||
}
|
||||
|
||||
/* Form typography and labels follow the same light-on-dark visual hierarchy. */
|
||||
.login-container h2 {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
color: #dee2e6;
|
||||
}
|
||||
|
||||
.login-container label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #dee2e6;
|
||||
}
|
||||
|
||||
/* Inputs use border-box so their declared width includes padding and borders. */
|
||||
.login-container input[type="text"],
|
||||
.login-container input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid #1a1a1a;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
background: #2b3035;
|
||||
color: #dee2e6;
|
||||
}
|
||||
|
||||
/* The single primary action spans the card and receives a clear hover state. */
|
||||
.login-container button {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background-color: #0066cc;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #ffffff;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.login-container button:hover {
|
||||
background-color: #005bb5;
|
||||
}
|
||||
|
||||
/* Authentication failures remain visible without exposing sensitive detail. */
|
||||
.error-message {
|
||||
color: #ff4d4d;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Brand artwork centres independently of its source image dimensions. */
|
||||
.logo {
|
||||
display: block;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
/* Low-contrast project credit: present, but not competing with the login form. */
|
||||
.shamelessPlug {
|
||||
margin: 12px 0 -30px 0;
|
||||
padding: 0;
|
||||
display: block;
|
||||
text-align: center;
|
||||
color: #111519;
|
||||
font-size: 0.8em;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/* General Styles */
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background-color: #f9f9f9;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* Header Styles */
|
||||
header {
|
||||
background-color: #1b6ac9;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
/* Main Content Styles */
|
||||
main {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.5em;
|
||||
color: #1b6ac9;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 1.1em;
|
||||
line-height: 1.6;
|
||||
max-width: 700px;
|
||||
margin: 10px auto;
|
||||
}
|
||||
|
||||
/* Button Styles */
|
||||
.button {
|
||||
display: inline-block;
|
||||
margin-top: 30px;
|
||||
padding: 15px 30px;
|
||||
background-color: #1b6ac9;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
font-size: 1em;
|
||||
border-radius: 5px;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background-color: #155a9c;
|
||||
}
|
||||
|
||||
/* Footer Styles */
|
||||
footer {
|
||||
background-color: #333;
|
||||
color: #fff;
|
||||
padding: 15px 0;
|
||||
text-align: center;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
bottom: 0;
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
# NeoCMS metadata may contain drafts and revision history; never serve it directly.
|
||||
Require all denied
|
||||
@@ -1,3 +0,0 @@
|
||||
<?php
|
||||
// Metadata is private even when a web server ignores the accompanying .htaccess file.
|
||||
http_response_code(404);
|
||||
+29
-193
@@ -1,213 +1,49 @@
|
||||
<?php
|
||||
/**
|
||||
* Authenticated media-upload endpoint (images, documents, video, audio), also used by TinyMCE for pasted images.
|
||||
*
|
||||
* Every file is checked by MediaTypes::inspect (extension allow-list, content sniffing, code scanning) before it receives a
|
||||
* server-generated filename. Trusting only the browser-supplied extension would be admirably optimistic.
|
||||
*/
|
||||
// Allowed MIME types
|
||||
$allowed_types = ['image/jpeg', 'image/png', 'image/gif'];
|
||||
|
||||
// Load credentials, role assignments, and shared application settings.
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
|
||||
use NeoCMS\Activity;
|
||||
use NeoCMS\Authentication;
|
||||
use NeoCMS\FileStore;
|
||||
use NeoCMS\Logger;
|
||||
use NeoCMS\MediaTypes;
|
||||
use NeoCMS\SecurityHeaders;
|
||||
|
||||
SecurityHeaders::json(isset($config['security']['cookieSecure']) ? (bool) $config['security']['cookieSecure'] : null);
|
||||
|
||||
// Authentication is required before any upload details are processed.
|
||||
$authentication = new Authentication(...\NeoCMS\UserStore::authArgs($config));
|
||||
if (!$authentication->isLoggedIn()) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'Unauthorised']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Editors may upload; unauthorised roles receive a clear forbidden response.
|
||||
if (!$authentication->can('upload')) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Your role cannot upload files']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Uploads alter server state and therefore accept POST only.
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
header('Allow: POST');
|
||||
echo json_encode(['error' => 'Invalid request method']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Accept the token as form data or a conventional request header.
|
||||
$csrfToken = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? null;
|
||||
if (!$authentication->isValidCsrfToken($csrfToken)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Invalid CSRF token']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Apply configured byte, dimension, pixel-count, file-count, and total-storage limits.
|
||||
$uploadConfig = is_array($config['uploads'] ?? null) ? $config['uploads'] : [];
|
||||
$max_file_size = max(1024, (int) ($uploadConfig['maxFileBytes'] ?? 10 * 1024 * 1024));
|
||||
$maxWidth = max(1, (int) ($uploadConfig['maxWidth'] ?? 8192));
|
||||
$maxHeight = max(1, (int) ($uploadConfig['maxHeight'] ?? 8192));
|
||||
$maxPixels = max(1, (int) ($uploadConfig['maxPixels'] ?? 24 * 1024 * 1024));
|
||||
$maxFiles = max(1, (int) ($uploadConfig['maxFiles'] ?? 2000));
|
||||
$maxTotalBytes = max($max_file_size, (int) ($uploadConfig['maxTotalBytes'] ?? 500 * 1024 * 1024));
|
||||
if ((int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > $max_file_size + 1024 * 1024) {
|
||||
http_response_code(413);
|
||||
echo json_encode(['error' => 'This file is larger than the ' . round($max_file_size / 1048576, 1) . ' MB limit.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Continue only when PHP reports a complete upload.
|
||||
if (isset($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK) {
|
||||
// Check if a file has been uploaded
|
||||
if (isset($_FILES['file'])) {
|
||||
$file = $_FILES['file'];
|
||||
|
||||
// Enforce the application limit even if php.ini permits larger files.
|
||||
if ($file['size'] > $max_file_size) {
|
||||
// Validate the file type
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mime_type = finfo_file($finfo, $file['tmp_name']);
|
||||
finfo_close($finfo);
|
||||
|
||||
if (!in_array($mime_type, $allowed_types)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'This file is larger than the ' . round($max_file_size / 1048576, 1) . ' MB limit.']);
|
||||
echo json_encode(['error' => 'Invalid file type.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// A pasted image may arrive without an extension; name it from its detected type so it can be checked like any other file.
|
||||
$originalName = (string) $file['name'];
|
||||
if (pathinfo($originalName, PATHINFO_EXTENSION) === '') {
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$detected = (string) finfo_file($finfo, $file['tmp_name']);
|
||||
finfo_close($finfo);
|
||||
$originalName .= '.' . (['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp'][$detected] ?? '');
|
||||
// Set the upload directory
|
||||
$upload_dir = '../uploads/';
|
||||
|
||||
// Ensure the upload directory exists
|
||||
if (!is_dir($upload_dir)) {
|
||||
mkdir($upload_dir, 0777, true);
|
||||
}
|
||||
|
||||
// Extension allow-list, content sniffing, code scanning and per-format checks (see MediaTypes::inspect).
|
||||
try {
|
||||
$inspection = MediaTypes::inspect($file['tmp_name'], $originalName, [
|
||||
'maxBytes' => $max_file_size, 'maxWidth' => $maxWidth, 'maxHeight' => $maxHeight, 'maxPixels' => $maxPixels,
|
||||
]);
|
||||
} catch (\RuntimeException $exception) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => $exception->getMessage()]);
|
||||
exit;
|
||||
}
|
||||
// Generate a unique name for the image
|
||||
$extension = pathinfo($file['name'], PATHINFO_EXTENSION);
|
||||
$filename = uniqid() . '.' . $extension;
|
||||
|
||||
// Generated names keep the original filename out of the URL namespace and allow only one extension.
|
||||
$filename = MediaTypes::storedName($originalName, $inspection['ext']);
|
||||
|
||||
// Create the public upload directory on a fresh installation.
|
||||
$uploadRoot = __DIR__ . '/../uploads';
|
||||
if (!is_dir($uploadRoot) && !mkdir($uploadRoot, 0755, true)) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'Failed to create upload directory.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$upload_dir = realpath($uploadRoot);
|
||||
if ($upload_dir === false) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'Upload directory is invalid.']);
|
||||
exit;
|
||||
}
|
||||
$upload_dir .= DIRECTORY_SEPARATOR;
|
||||
@chmod($upload_dir, 0755);
|
||||
|
||||
// Build the final path from the canonical upload directory and generated safe filename.
|
||||
// Set the target file path
|
||||
$target_file = $upload_dir . $filename;
|
||||
|
||||
// Serialise quota checks and moves so concurrent uploads cannot race past the storage ceiling.
|
||||
$store = new FileStore((string) ($config['dataDirectory'] ?? (__DIR__ . '/data')));
|
||||
$lockPath = $store->directory('locks') . 'uploads.lock';
|
||||
$uploadLock = fopen($lockPath, 'c+');
|
||||
if ($uploadLock === false || !flock($uploadLock, LOCK_EX)) {
|
||||
if (is_resource($uploadLock)) {
|
||||
fclose($uploadLock);
|
||||
}
|
||||
http_response_code(503);
|
||||
echo json_encode(['error' => 'Upload service is temporarily unavailable.']);
|
||||
exit;
|
||||
}
|
||||
@chmod($lockPath, 0600);
|
||||
|
||||
$existingFiles = array_values(array_filter(glob($upload_dir . '*') ?: [], 'is_file'));
|
||||
$existingBytes = array_sum(array_map(static fn(string $path): int => (int) filesize($path), $existingFiles));
|
||||
if (count($existingFiles) >= $maxFiles || $existingBytes + (int) $file['size'] > $maxTotalBytes) {
|
||||
flock($uploadLock, LOCK_UN);
|
||||
fclose($uploadLock);
|
||||
http_response_code(507);
|
||||
echo json_encode(['error' => 'Upload storage quota has been reached.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// move_uploaded_file() additionally confirms that PHP received the source as an upload.
|
||||
// Move the uploaded file to the target directory
|
||||
if (move_uploaded_file($file['tmp_name'], $target_file)) {
|
||||
// Public images need read access but no executable or world-writable permissions.
|
||||
chmod($target_file, 0644);
|
||||
|
||||
// Seed media metadata from the original basename; editors can improve the alt text later.
|
||||
$location = $config['basePath'] . '/uploads/' . $filename;
|
||||
$metadata = $store->read('media');
|
||||
$originalBase = pathinfo((string) $file['name'], PATHINFO_FILENAME);
|
||||
$altText = substr(preg_replace('/[\x00-\x1F\x7F]/u', '', $originalBase) ?? '', 0, 200);
|
||||
$metadata[$filename] = ['alt' => $altText, 'original' => substr(preg_replace('/[\x00-\x1F\x7F]/u', '', basename($originalName)) ?? '', 0, 200), 'by' => $authentication->getLoggedInUser()];
|
||||
try {
|
||||
$store->write('media', $metadata);
|
||||
} catch (\Throwable $exception) {
|
||||
@unlink($target_file);
|
||||
flock($uploadLock, LOCK_UN);
|
||||
fclose($uploadLock);
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'Unable to record uploaded media.']);
|
||||
exit;
|
||||
}
|
||||
flock($uploadLock, LOCK_UN);
|
||||
fclose($uploadLock);
|
||||
// The file is saved by now, so a failure to write the audit entry must not fail the upload.
|
||||
try {
|
||||
(new Activity($store, new Logger($config['audit'] ?? true, $config['security'] ?? [])))
|
||||
->record($authentication->getLoggedInUser(), 'Uploaded ' . (['imagery' => 'image', 'documents' => 'document', 'video' => 'video', 'audio' => 'audio'][$inspection['category']]), $filename . ' (' . max(1, (int) round(((int) $file['size']) / 1024)) . ' KB)');
|
||||
} catch (\Throwable $exception) {
|
||||
// Deliberately ignored.
|
||||
}
|
||||
echo json_encode(['location' => $location, 'name' => $filename, 'category' => $inspection['category']]);
|
||||
// Return a JSON response with the image location
|
||||
$location = $target_file;
|
||||
echo json_encode(['location' => $location]);
|
||||
} else {
|
||||
flock($uploadLock, LOCK_UN);
|
||||
fclose($uploadLock);
|
||||
// A failed move is a server error because validation has already succeeded.
|
||||
// Return an error response
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'Failed to move uploaded file.']);
|
||||
}
|
||||
} else {
|
||||
// Translate PHP's numeric upload failures into messages an editor can act upon.
|
||||
$error_message = 'No file uploaded.';
|
||||
if (isset($_FILES['file']['error'])) {
|
||||
switch ($_FILES['file']['error']) {
|
||||
case UPLOAD_ERR_INI_SIZE:
|
||||
case UPLOAD_ERR_FORM_SIZE:
|
||||
$error_message = 'File size exceeds the maximum limit.';
|
||||
break;
|
||||
case UPLOAD_ERR_PARTIAL:
|
||||
$error_message = 'The uploaded file was only partially uploaded.';
|
||||
break;
|
||||
case UPLOAD_ERR_NO_FILE:
|
||||
$error_message = 'No file was uploaded.';
|
||||
break;
|
||||
case UPLOAD_ERR_NO_TMP_DIR:
|
||||
$error_message = 'Missing a temporary folder.';
|
||||
break;
|
||||
case UPLOAD_ERR_CANT_WRITE:
|
||||
$error_message = 'Failed to write file to disk.';
|
||||
break;
|
||||
case UPLOAD_ERR_EXTENSION:
|
||||
$error_message = 'A PHP extension stopped the file upload.';
|
||||
break;
|
||||
default:
|
||||
$error_message = 'Unknown upload error.';
|
||||
break;
|
||||
}
|
||||
}
|
||||
// No file was uploaded
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => $error_message]);
|
||||
}
|
||||
echo json_encode(['error' => 'No file uploaded.']);
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 9.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 2.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.8 KiB |
+66
-273
@@ -1,299 +1,92 @@
|
||||
<?php
|
||||
/** Server-rendered shell for the authenticated NeoCMS administration application. */
|
||||
|
||||
// Load configuration before initialising sessions and role-aware controls.
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
|
||||
use NeoCMS\Authentication;
|
||||
use NeoCMS\SecurityHeaders;
|
||||
|
||||
// Prepare the session values required by both the rendered shell and JavaScript client.
|
||||
$authentication = new Authentication(...\NeoCMS\UserStore::authArgs($config));
|
||||
$csrfToken = $authentication->getCsrfToken();
|
||||
// Only a single valid CSS class is allowed; malformed values fall back safely.
|
||||
$editableClass = $config['editableClass'] ?? 'editable';
|
||||
if (!is_string($editableClass) || !preg_match('/^[a-zA-Z_][a-zA-Z0-9_-]*$/', $editableClass)) {
|
||||
$editableClass = 'editable';
|
||||
}
|
||||
|
||||
// Keep the administration shell private by redirecting anonymous requests to login.
|
||||
if (!$authentication->isLoggedIn()) {
|
||||
header("Location: " . $config['basePath'] . "/cms/login/");
|
||||
exit;
|
||||
}
|
||||
SecurityHeaders::html(true, isset($config['security']['cookieSecure']) ? (bool) $config['security']['cookieSecure'] : null);
|
||||
require_once "neoCMSCore.php";
|
||||
require_once "init.php";
|
||||
?>
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en-gb">
|
||||
<head>
|
||||
<title>NeoCMS</title>
|
||||
<meta name="csrf-token" content="<?php echo htmlspecialchars($csrfToken, ENT_QUOTES, 'UTF-8'); ?>">
|
||||
<meta name="neo-base-path" content="<?php echo htmlspecialchars($config['basePath'], ENT_QUOTES, 'UTF-8'); ?>">
|
||||
<meta name="neo-editable-class" content="<?php echo htmlspecialchars($editableClass, ENT_QUOTES, 'UTF-8'); ?>">
|
||||
<script src="https://code.jquery.com/jquery-4.0.0.min.js" integrity="sha384-fgGyf7Mo7DURSOMnOy7ed+dkq5Job205Gnzu6QIg0BOHKaqt4D76Dt8VlDCzcMHV" crossorigin="anonymous"></script>
|
||||
<script src="https://code.jquery.com/ui/1.14.2/jquery-ui.min.js" integrity="sha384-tBcEcHGtNy7/Mx08+YxuvQ6v6s0N2jgehtFiT+bLtGwTj/txXtB/L5GqXfggm5sS" crossorigin="anonymous"></script>
|
||||
<script src="<?php echo htmlspecialchars($config['basePath'], ENT_QUOTES, 'UTF-8'); ?>/cms/tinymce/tinymce.min.js"></script>
|
||||
<link rel="stylesheet" href="https://code.jquery.com/ui/1.14.2/themes/base/jquery-ui.css" integrity="sha384-pUvA/6DQjteMxpaV6uGxZ1QuYrFLJgrLMvBWf06VcJIg6ky/Y5m3UZJlrv11V1I+" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="<?php echo htmlspecialchars($config['basePath'], ENT_QUOTES, 'UTF-8'); ?>/cms/css/editor.css">
|
||||
<!-- Include jQuery -->
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
<script src="https://code.jquery.com/ui/1.14.0/jquery-ui.min.js" crossorigin="anonymous"></script>
|
||||
<!-- Include TinyMCE -->
|
||||
<script src="/cms/tinymce/tinymce.min.js"></script>
|
||||
<!-- Include Bootstrap for modal -->
|
||||
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap.min.css">
|
||||
|
||||
<!-- Include JqueryUI CSS -->
|
||||
<link rel="stylesheet" href="https://code.jquery.com/ui/1.14.0/themes/base/jquery-ui.css">
|
||||
|
||||
<!-- Include editor styles -->
|
||||
<link rel="stylesheet" href="/cms/css/editor.css">
|
||||
|
||||
<!-- Include Bootstrap JS -->
|
||||
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.4.1/js/bootstrap.min.js"></script>
|
||||
</head>
|
||||
<body data-role="<?php echo htmlspecialchars($authentication->getRole(), ENT_QUOTES, 'UTF-8'); ?>">
|
||||
<!-- Status messages sit outside the flex layout so they may overlay the full viewport. -->
|
||||
<div id="message-bar" role="status" aria-live="polite" hidden>
|
||||
<span class="snack-icon" aria-hidden="true"></span>
|
||||
<span class="snack-text"></span>
|
||||
<button type="button" class="snack-close" aria-label="Dismiss message">×</button>
|
||||
</div>
|
||||
<body>
|
||||
<div id="message-bar" style="display:none;"></div>
|
||||
<div class="pageContainer">
|
||||
<div class="controls">
|
||||
<div class="logo"></div>
|
||||
<?php if ($config['showFullUrl']) {?>
|
||||
<div id="urlbox"></div>
|
||||
<?php } ?>
|
||||
<!-- Primary content tools and publication controls are grouped into two wrapping rows. -->
|
||||
<div class="buttonContainer">
|
||||
<div class="toolbar-row">
|
||||
<button class="headerButton" id="dashboardButton">Dashboard</button>
|
||||
<button class="headerButton" id="selectPage">Pages</button>
|
||||
<button class="headerButton" id="mediaButton">Media</button>
|
||||
<button class="headerButton" id="seoButton">SEO</button>
|
||||
<button class="headerButton manage-only" id="menusButton">Navigation Menus</button>
|
||||
<button class="headerButton" id="moreButton">Tools</button>
|
||||
</div>
|
||||
<div class="toolbar-row publish-controls">
|
||||
<button class="headerButton viewport-button active" data-width="100%" title="Desktop view" aria-label="Desktop view" aria-pressed="true"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2.5" y="4" width="19" height="12.5" rx="1.5"/><path d="M8 20.5h8M12 16.5v4"/></svg></button>
|
||||
<button class="headerButton viewport-button" data-width="768px" title="Tablet view" aria-label="Tablet view" aria-pressed="false"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="2.5" width="14" height="19" rx="2"/><path d="M11 18.5h2"/></svg></button>
|
||||
<button class="headerButton viewport-button" data-width="390px" title="Mobile view" aria-label="Mobile view" aria-pressed="false"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="7" y="2.5" width="10" height="19" rx="2"/><path d="M11 18.5h2"/></svg></button>
|
||||
</div>
|
||||
<ul>
|
||||
<li><a href="#" id="selectPage" class="blueButton">Select Page</a></li>
|
||||
<li><a href="#" id="newPage" class="blueButton">New Page</a></li>
|
||||
<li><a href="#" id="savePage" class="greenButton">Save Changes</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="loggedInDetails">
|
||||
<span id="whoami"><?php $profile = \NeoCMS\UserStore::fromConfig($config)->profile($authentication->getLoggedInUser()); echo htmlspecialchars($profile['name'] !== '' ? $profile['name'] : $authentication->getLoggedInUser(), ENT_QUOTES, 'UTF-8'); ?></span>
|
||||
(<?php echo htmlspecialchars($authentication->getRole(), ENT_QUOTES, 'UTF-8'); ?>)<br/>
|
||||
<button id="logoutButton" class="link-button">Log out</button><br/>
|
||||
<?php
|
||||
if (!is_writable("logs/")) {
|
||||
echo "<span class='redText'>Make /cms/logs/ writable.</span>";
|
||||
}
|
||||
?>
|
||||
Logged in as: <?php echo $_SESSION["core"]->getLoggedinUser(); ?>
|
||||
</div>
|
||||
</div>
|
||||
<iframe id="frameContainer" src="<?php echo $config['skipWelcomePage'] ? htmlspecialchars($config['basePath'], ENT_QUOTES, 'UTF-8') . "/" : "welcome.html"; ?>"
|
||||
class="frame" sandbox="allow-same-origin"></iframe>
|
||||
<iframe id="frameContainer" src="welcome.html" class="frame"></iframe>
|
||||
</div>
|
||||
<!-- TinyMCE content editor. Save Draft keeps changes private; Schedule Publish and Publish Now act on the whole page. -->
|
||||
<div id="editModal" class="cms-dialog" title="Edit Content">
|
||||
<textarea id="editor"></textarea>
|
||||
<div class="editor-actions">
|
||||
<button id="saveBtn" type="button" title="Applies your changes and saves them as a private draft">Save Draft</button>
|
||||
<button id="scheduleBtn" type="button" class="publish-only" title="Publishes this page automatically at a time you choose">Schedule Publish</button>
|
||||
<button id="publishBtn" type="button" class="publish-only publish-now" title="Replaces the live page now; a revision of the old page is kept">Publish Now</button>
|
||||
<button id="closeEditorBtn" type="button">Cancel</button>
|
||||
<!-- Modal -->
|
||||
<div id="editModal" class="modal fade" role="dialog">
|
||||
<div class="modal-dialog modal-lg">
|
||||
|
||||
<!-- Modal content-->
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title">Edit Content</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<textarea id="editor"></textarea>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button id="saveBtn" class="btn btn-primary">Save</button>
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Searchable page picker with role-dependent management actions. -->
|
||||
<div id="fileListDialog" title="Pages">
|
||||
<!--New Page modal -->
|
||||
<div id="newPageDialog" title="Create a New Page">
|
||||
<div class="newpage-content">
|
||||
<form id="newPageForm">
|
||||
<div id="radioList">
|
||||
<!-- Radio buttons will be populated here -->
|
||||
</div>
|
||||
<label for="filename">New Filename:</label>
|
||||
<input type="text" id="filename" name="filename" placeholder="Enter new filename" required>
|
||||
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Page list modal -->
|
||||
<div id="fileListDialog" title="Select an Existing Page">
|
||||
<div class="filelist-content">
|
||||
<input id="pageSearch" type="search" placeholder="Search pages" aria-label="Search pages">
|
||||
<div class="table-wrap">
|
||||
<table id="fileList" class="data-table">
|
||||
<thead>
|
||||
<tr><th>Page</th><th>Title</th><th>Last modified</th><th class="manage-col">Actions</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- JavaScript populates discovered editable pages here. -->
|
||||
</tbody>
|
||||
</table>
|
||||
<p id="pageListEmpty" class="scan-note" hidden>No pages match your search.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- New page wizard: template, name, navigation group. Administrators only. -->
|
||||
<div id="newPageSection" class="manage-only">
|
||||
<h3 class="dialog-section">New page</h3>
|
||||
<div class="newpage-content">
|
||||
<form id="newPageForm">
|
||||
<p id="newPageStep" class="step-indicator"></p>
|
||||
<section data-step="1">
|
||||
<h3 class="dialog-section">Choose a template</h3>
|
||||
<div id="radioList">
|
||||
<!-- JavaScript populates available filesystem templates here. -->
|
||||
</div>
|
||||
<div class="step-actions"><button type="button" class="step-next">Next</button></div>
|
||||
</section>
|
||||
<section data-step="2" hidden>
|
||||
<h3 class="dialog-section">Name your page</h3>
|
||||
<label for="pageName">Page name</label>
|
||||
<input type="text" id="pageName" maxlength="80" placeholder="For example: About us" autocomplete="off">
|
||||
<p class="scan-note">Used as the page title and the navigation link text. The filename is created for you.</p>
|
||||
<div class="step-actions"><button type="button" class="step-back">Back</button><button type="button" class="step-next">Next</button></div>
|
||||
</section>
|
||||
<section data-step="3" hidden>
|
||||
<h3 class="dialog-section">Choose a navigation group</h3>
|
||||
<div id="menuChoice">
|
||||
<!-- JavaScript lists the saved navigation menus here. -->
|
||||
</div>
|
||||
<p class="scan-note">The page is saved as a private draft. It appears on the site, and in this menu, only once you publish it.</p>
|
||||
<div class="step-actions"><button type="button" class="step-back">Back</button><button type="submit">Save</button></div>
|
||||
</section>
|
||||
</form>
|
||||
</div>
|
||||
<ul id="fileList">
|
||||
<!-- File names will be populated here -->
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Operational overview: pending work, schedules, activity, and filesystem warnings. -->
|
||||
<div id="dashboardDialog" class="cms-dialog" title="Dashboard"><div id="dashboardContent"></div></div>
|
||||
|
||||
<!-- Secondary authoring and site-wide management tools. -->
|
||||
<div id="toolsDialog" class="cms-dialog" title="Tools">
|
||||
<div class="tool-grid">
|
||||
<button id="revisionsButton">Revision history</button>
|
||||
<button id="accessibilityButton">Accessibility check</button>
|
||||
<button id="sharedButton" class="manage-only">Shared content</button>
|
||||
<button id="siteScanButton" class="manage-only">Site scan</button>
|
||||
<button id="usersButton">Users</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reusable uploaded-image browser and metadata editor. -->
|
||||
<div id="mediaDialog" class="cms-dialog" title="Media Library">
|
||||
<div class="media-toolbar">
|
||||
<button type="button" id="mediaUploadButton">Upload files</button>
|
||||
<input type="file" id="mediaFile" multiple hidden accept="<?php echo htmlspecialchars('.' . implode(',.', \NeoCMS\MediaTypes::extensions()), ENT_QUOTES, 'UTF-8'); ?>">
|
||||
<div id="mediaTabs" class="media-tabs" role="tablist" aria-label="Media categories"></div>
|
||||
</div>
|
||||
<p id="mediaHint" class="scan-note"></p>
|
||||
<div id="mediaUploadReport" class="scan-note" role="status" aria-live="polite"></div>
|
||||
<div id="mediaList" class="media-grid"></div>
|
||||
</div>
|
||||
|
||||
<!-- Page history and publisher-only restoration controls. -->
|
||||
<div id="revisionsDialog" class="cms-dialog" title="Revision History"><div id="revisionsList"></div></div>
|
||||
|
||||
<!-- In-document title, search metadata, canonical URL, and robots settings. -->
|
||||
<div id="seoDialog" class="cms-dialog" title="SEO and Page Settings">
|
||||
<form id="seoForm">
|
||||
<label>Page title<input id="seoTitle" type="text" required></label>
|
||||
<label>Meta description<textarea id="seoDescription"></textarea></label>
|
||||
<label>Canonical URL<input id="seoCanonical" type="url"></label>
|
||||
<label>Open Graph image<input id="seoImage" type="text" placeholder="/uploads/image.jpg"></label>
|
||||
<label><input id="seoNoIndex" type="checkbox"> Ask search engines not to index this page</label>
|
||||
<button type="submit">Apply settings</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Future publication time is entered locally and submitted as an absolute timestamp. -->
|
||||
<div id="scheduleDialog" class="cms-dialog" title="Schedule Publication">
|
||||
<form id="scheduleForm">
|
||||
<label>Publish date and time<input id="publishAt" type="datetime-local" required></label>
|
||||
<button type="submit">Schedule</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Results from the browser-side authoring accessibility checks. -->
|
||||
<div id="accessibilityDialog" class="cms-dialog" title="Accessibility Check"><div id="accessibilityResults"></div></div>
|
||||
|
||||
<!-- Site-wide shared-region registry and propagation form. -->
|
||||
<div id="sharedDialog" class="cms-dialog" title="Shared Content">
|
||||
<div id="sharedList"></div>
|
||||
<form id="sharedForm">
|
||||
<label>Block name<input id="sharedKey" type="text" pattern="[A-Za-z0-9_-]+" required></label>
|
||||
<label>HTML content<textarea id="sharedContent" rows="8" required></textarea></label>
|
||||
<button type="submit">Save and update every page</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Line-oriented menu editor supporting an optional parent label for nesting. -->
|
||||
<div id="menusDialog" class="cms-dialog" title="Navigation Menus">
|
||||
<div id="menuList"></div>
|
||||
<p class="scan-note"><button id="menuScanButton" type="button">Scan site for new navigation</button> Finds navigation added to any page since the last scan.</p>
|
||||
<form id="menuForm" hidden>
|
||||
<h3 class="dialog-section">Editing menu: <code id="menuKey"></code></h3>
|
||||
<input id="menuName" type="hidden">
|
||||
<label>Menu name<input id="menuTitle" type="text" maxlength="60" placeholder="Display name"></label>
|
||||
<label>One item per line: Label | URL | Optional parent label<textarea id="menuItems" rows="9" placeholder="Home | / About | /about.html Team | /team.html | About" required></textarea></label>
|
||||
<button type="submit">Save menu</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Own account (everyone), role descriptions, and account management (administrators only). -->
|
||||
<div id="usersDialog" class="cms-dialog" title="Users">
|
||||
<h3 class="dialog-section">My account</h3>
|
||||
<form id="profileForm" autocomplete="off">
|
||||
<label>Login name <span class="scan-note">(used to sign in; set by an administrator)</span><input id="profileLogin" type="text" readonly></label>
|
||||
<label>Display name<input id="profileName" type="text" maxlength="80" required autocomplete="name"></label>
|
||||
<label>Email address <span class="scan-note">(contact details only; not used to sign in)</span><input id="profileEmail" type="email" maxlength="254" autocomplete="email"></label>
|
||||
<label>Current password <span class="scan-note">(only needed to change the email address)</span><input id="profileCurrent" type="password" maxlength="4096" autocomplete="current-password"></label>
|
||||
<button type="submit">Save details</button>
|
||||
</form>
|
||||
<form id="passwordForm" autocomplete="off">
|
||||
<h3 class="dialog-section">Change password</h3>
|
||||
<label>Current password<input id="pwCurrent" type="password" maxlength="4096" required autocomplete="current-password"></label>
|
||||
<label>New password <span class="scan-note">(12 to 72 characters)</span><input id="pwNew" type="password" minlength="12" maxlength="72" required autocomplete="new-password"></label>
|
||||
<label>Confirm new password<input id="pwConfirm" type="password" minlength="12" maxlength="72" required autocomplete="new-password"></label>
|
||||
<button type="submit">Change password</button>
|
||||
</form>
|
||||
<p id="passwordManaged" class="scan-note" hidden>Your password is managed in the site configuration (config.local.php) and cannot be changed here.</p>
|
||||
|
||||
<h3 class="dialog-section">Roles</h3>
|
||||
<div id="rolesList"></div>
|
||||
|
||||
<div id="userAdmin" class="manage-only">
|
||||
<h3 class="dialog-section">User accounts</h3>
|
||||
<div id="userList"></div>
|
||||
<p class="scan-note"><button id="userAddButton" type="button">Add user</button> <button id="userInviteButton" type="button">Invite user</button> Accounts marked Config are managed in config.local.php and cannot be changed here.</p>
|
||||
<form id="userForm" autocomplete="off" hidden>
|
||||
<h3 class="dialog-section" id="userFormTitle"></h3>
|
||||
<input id="userExisting" type="hidden">
|
||||
<label id="userUsernameRow">Login name <span class="scan-note">(3 to 32 letters, numbers, . - _)</span><input id="userUsername" type="text" maxlength="32" autocomplete="off"></label>
|
||||
<label>Display name<input id="userName" type="text" maxlength="80" required autocomplete="off"></label>
|
||||
<label>Email address <span class="scan-note">(contact details only; not used to sign in)</span><input id="userEmail" type="email" maxlength="254" autocomplete="off"></label>
|
||||
<label>Role<select id="userRole"><option value="editor">Editor</option><option value="administrator">Administrator</option></select></label>
|
||||
<label id="userPasswordRow">Password <span class="scan-note" id="userPasswordNote"></span><input id="userPassword" type="password" maxlength="72" autocomplete="new-password"></label>
|
||||
<label>Your password <span class="scan-note">(to confirm this change)</span><input id="userConfirm" type="password" maxlength="4096" required autocomplete="current-password"></label>
|
||||
<button type="submit" id="userSubmit">Save user</button> <button type="button" id="userCancel">Cancel</button>
|
||||
</form>
|
||||
<form id="confirmBox" autocomplete="off" hidden>
|
||||
<h3 class="dialog-section" id="confirmTitle"></h3>
|
||||
<label>Your password<input id="confirmPassword" type="password" maxlength="4096" required autocomplete="current-password"></label>
|
||||
<button type="submit">Confirm</button> <button type="button" id="confirmCancel">Cancel</button>
|
||||
</form>
|
||||
<div id="inviteResult" hidden>
|
||||
<h3 class="dialog-section">Invitation link</h3>
|
||||
<p class="scan-note">Send this link to the person. It is shown only once, works once, and expires in 7 days. Their login name is <strong id="inviteLogin"></strong>.</p>
|
||||
<input id="inviteLink" type="text" readonly> <button type="button" id="inviteCopy">Copy link</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Whole-site analysis with one-click tagging of content regions, images, and SEO tags. Administrators only. -->
|
||||
<div id="siteScanDialog" class="cms-dialog" title="Site Scan">
|
||||
<div id="siteScanProgress" hidden>
|
||||
<progress id="scanProgressBar" max="100" value="0"></progress>
|
||||
<div id="scanProgressText" role="status" aria-live="polite"></div>
|
||||
</div>
|
||||
<div id="siteScanSummary"></div>
|
||||
<fieldset id="siteScanOptions">
|
||||
<legend>Changes to make</legend>
|
||||
<label><input type="checkbox" id="scanContent" checked> Make content areas editable</label>
|
||||
<label><input type="checkbox" id="scanImages" checked> Make images editable</label>
|
||||
<label><input type="checkbox" id="scanSeo" checked> Add missing SEO tags (never overwrites existing ones)</label>
|
||||
<label><input type="checkbox" id="scanMenus" checked> Detect navigation menus (fills Navigation Menus; never overwrites a saved menu)</label>
|
||||
</fieldset>
|
||||
<div id="siteScanPages"></div>
|
||||
<p class="scan-note">Each changed page keeps a revision, so this can be undone from Revision history.</p>
|
||||
<button id="siteScanApply" type="button">Apply to selected pages</button>
|
||||
</div>
|
||||
|
||||
<!-- Replace or describe an image that sits outside an editable region. -->
|
||||
<div id="imageDialog" class="cms-dialog" title="Edit Image">
|
||||
<div id="imagePicker" class="media-grid"></div>
|
||||
<form id="imageForm">
|
||||
<label>Image address<input id="imageSrc" type="text" required></label>
|
||||
<label>Alternative text<input id="imageAlt" type="text"></label>
|
||||
<button type="submit">Apply image</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script src="<?php echo htmlspecialchars($config['basePath'], ENT_QUOTES, 'UTF-8'); ?>/cms/js/cms.js"></script>
|
||||
<script src="/cms/js/cms.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
/*
|
||||
* Authentication stores the usernames and passwords for people to be able to use the CMS.
|
||||
* This will be used as well to write to the audit log once it has been implemented in code.
|
||||
*/
|
||||
$authentication = [
|
||||
"admin" => "password",
|
||||
"editor" => "password",
|
||||
];
|
||||
|
||||
/**
|
||||
* DO NOT CHANGE ANYTHING BENEATH THIS LINE - IT WILL BUGGER STUFF UP
|
||||
*/
|
||||
session_start();
|
||||
require_once "neoCMSCore.php";
|
||||
|
||||
if (empty($_SESSION["core"])) {
|
||||
$_SESSION["core"] = new neoCMSCore();
|
||||
$_SESSION["core"]->users = $authentication;
|
||||
}
|
||||
if (!$_SESSION["core"]->isLoggedIn() && stripos($_SERVER['REQUEST_URI'], "/login") === FALSE) {
|
||||
header('Location: /cms/login');
|
||||
die("Redirecting to login..");
|
||||
}
|
||||
|
||||
|
||||
// str_starts_with compatibility for php 7
|
||||
if (!function_exists('str_starts_with')) {
|
||||
function str_starts_with($haystack, $needle)
|
||||
{
|
||||
//str_starts_with(string $haystack, string $needle): bool
|
||||
|
||||
$strlen_needle = mb_strlen($needle);
|
||||
if (mb_substr($haystack, 0, $strlen_needle) == $needle) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
//str_ends_with compatibility for php 7
|
||||
if (!function_exists('str_ends_with')) {
|
||||
function str_ends_with($haystack, $needle)
|
||||
{
|
||||
//str_starts_with(string $haystack, string $needle): bool
|
||||
|
||||
$strlen_needle = mb_strlen($needle);
|
||||
if (mb_substr($haystack, -$strlen_needle, $strlen_needle) == $needle) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$_SESSION["core"]->version = '2.00';
|
||||
|
||||
+201
-1454
File diff suppressed because it is too large
Load Diff
+76
-123
@@ -1,147 +1,100 @@
|
||||
<?php
|
||||
/** Login form and authentication request handler. */
|
||||
|
||||
// Load account configuration before constructing authentication services.
|
||||
require_once __DIR__ . '/../bootstrap.php';
|
||||
|
||||
use NeoCMS\Authentication;
|
||||
use NeoCMS\LoginRateLimiter;
|
||||
use NeoCMS\Logger;
|
||||
use NeoCMS\SecurityHeaders;
|
||||
use NeoCMS\UserStore;
|
||||
|
||||
// Keep one escaped, user-facing error message for the rendered form.
|
||||
require_once "../neoCMSCore.php";
|
||||
require_once "../init.php";
|
||||
// Initialize error message variable
|
||||
$error = '';
|
||||
|
||||
// Authentication owns the session; the logger records successful and failed attempts.
|
||||
$authentication = new Authentication(...\NeoCMS\UserStore::authArgs($config));
|
||||
$logger = new Logger($config['audit'] ?? true, $config['security'] ?? []);
|
||||
$dataDirectory = (string) ($config['dataDirectory'] ?? (__DIR__ . '/../data'));
|
||||
$rateLimiter = new LoginRateLimiter($dataDirectory, $config['security'] ?? []);
|
||||
SecurityHeaders::html(false, isset($config['security']['cookieSecure']) ? (bool) $config['security']['cookieSecure'] : null);
|
||||
|
||||
// An invitation link (?invite=TOKEN) shows a set-password form instead of the login form.
|
||||
$users = UserStore::fromConfig($config);
|
||||
$invite = is_string($_POST['invite'] ?? null) ? $_POST['invite'] : (is_string($_GET['invite'] ?? null) ? $_GET['invite'] : '');
|
||||
$invite = preg_match('/^[a-f0-9]{64}$/', $invite) ? $invite : '';
|
||||
|
||||
// GET displays the form, while POST validates its token and submitted credentials.
|
||||
$maxLoginRequestBytes = 16 * 1024;
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && (int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > $maxLoginRequestBytes) {
|
||||
http_response_code(413);
|
||||
$error = 'Login request is too large';
|
||||
} elseif ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$csrfToken = $_POST['csrf_token'] ?? null;
|
||||
if (!$authentication->isValidCsrfToken($csrfToken)) {
|
||||
http_response_code(400);
|
||||
$logger->write(
|
||||
'User login attempt rejected due to invalid CSRF token from ' . (string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown'),
|
||||
'anonymous'
|
||||
);
|
||||
$error = "Invalid request token";
|
||||
} elseif ($invite !== '' && isset($_POST['invite'])) {
|
||||
$address = (string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown');
|
||||
$password = is_string($_POST['password'] ?? null) ? $_POST['password'] : '';
|
||||
$confirm = is_string($_POST['confirm'] ?? null) ? $_POST['confirm'] : '';
|
||||
if (($retryAfter = $rateLimiter->retryAfter($address, 'invite')) > 0) {
|
||||
http_response_code(429);
|
||||
header('Retry-After: ' . $retryAfter);
|
||||
$error = 'Too many attempts. Please try again later.';
|
||||
} elseif (!hash_equals($password, $confirm)) {
|
||||
$error = 'The passwords do not match';
|
||||
} else {
|
||||
try {
|
||||
$username = $users->acceptInvite($invite, $password);
|
||||
$rateLimiter->clear($address, 'invite');
|
||||
$authentication = new Authentication(...UserStore::authArgs($config));
|
||||
$authentication->login($username, $password);
|
||||
$logger->write("Invitation accepted for {$username} from {$address}", $username);
|
||||
header('Location: ' . $config['basePath'] . '/cms/');
|
||||
exit;
|
||||
} catch (\RuntimeException $exception) {
|
||||
$rateLimiter->recordFailure($address, 'invite');
|
||||
$logger->write('Invitation rejected from ' . $address, 'anonymous');
|
||||
$error = $exception->getMessage();
|
||||
}
|
||||
}
|
||||
if (isset($_POST['username']) && isset($_POST['password'])) {
|
||||
if (!$_SESSION['core']->login($_POST['username'], $_POST['password'])) {
|
||||
$error = "Invalid username or password";
|
||||
} else {
|
||||
$username = is_string($_POST['username'] ?? null) ? trim($_POST['username']) : '';
|
||||
$password = is_string($_POST['password'] ?? null) ? $_POST['password'] : '';
|
||||
$address = (string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown');
|
||||
|
||||
if (strlen($username) > 128 || strlen($password) > 4096) {
|
||||
http_response_code(400);
|
||||
$error = 'Invalid login name or password';
|
||||
} elseif (($retryAfter = $rateLimiter->retryAfter($address, $username)) > 0) {
|
||||
http_response_code(429);
|
||||
header('Retry-After: ' . $retryAfter);
|
||||
$logger->write("Login throttled from {$address}", $username ?: 'anonymous');
|
||||
$error = 'Too many login attempts. Please try again later.';
|
||||
} elseif ($authentication->login($username, $password)) {
|
||||
$rateLimiter->clear($address, $username);
|
||||
$users->rehashIfNeeded($username, $password);
|
||||
|
||||
// Record the source address before entering the privileged interface.
|
||||
$logger->write(
|
||||
"User login for {$username} was successful from {$address}",
|
||||
$username
|
||||
);
|
||||
|
||||
// Redirect after POST so browser refreshes do not resubmit credentials.
|
||||
header("Location: " . $config['basePath'] . "/cms/");
|
||||
exit;
|
||||
} else {
|
||||
$rateLimiter->recordFailure($address, $username);
|
||||
// Record denied attempts without logging the supplied password. Obviously.
|
||||
$logger->write(
|
||||
"User login for {$username} was denied due to incorrect credentials from {$address}",
|
||||
$username
|
||||
);
|
||||
|
||||
$error = "Invalid login name or password";
|
||||
}
|
||||
header("Location:/cms/");
|
||||
die("Well done, champ! You got this!! <3");
|
||||
}
|
||||
}
|
||||
?>
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en-gb">
|
||||
<html>
|
||||
<head>
|
||||
<title>NeoCMS Login</title>
|
||||
<link rel="stylesheet" href="<?php echo htmlspecialchars($config['basePath'], ENT_QUOTES, 'UTF-8'); ?>/cms/css/login.css"/>
|
||||
<title>Login</title>
|
||||
<style>
|
||||
/* Basic styling for the login form */
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
background-color: #eef1f5;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
width: 350px;
|
||||
margin: 100px auto;
|
||||
padding: 40px;
|
||||
background-color: #ffffff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.login-container h2 {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.login-container label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #555555;
|
||||
}
|
||||
|
||||
.login-container input[type="text"],
|
||||
.login-container input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid #cccccc;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.login-container button {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background-color: #0066cc;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #ffffff;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.login-container button:hover {
|
||||
background-color: #005bb5;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #ff4d4d;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- The compact login card is intentionally independent of the heavier administration UI. -->
|
||||
<div class="login-container">
|
||||
<img class="logo" src="<?php echo htmlspecialchars($config['basePath'], ENT_QUOTES, 'UTF-8'); ?>/cms/img/loginlogo.png" alt="NeoCMS logo"/>
|
||||
<h2><?php echo $invite !== '' ? 'Set your password' : 'NeoCMS Login'; ?></h2>
|
||||
<h2>NeoCMS Login</h2>
|
||||
<?php if (!empty($error)): ?>
|
||||
<div class="error-message"><?php echo htmlspecialchars($error, ENT_QUOTES, 'UTF-8'); ?></div>
|
||||
<div class="error-message"><?php echo htmlspecialchars($error); ?></div>
|
||||
<?php endif; ?>
|
||||
<!-- CSRF protection applies to login too, preventing forced authentication state changes. -->
|
||||
<form method="post">
|
||||
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($authentication->getCsrfToken(), ENT_QUOTES, 'UTF-8'); ?>">
|
||||
<?php if ($invite !== ''): ?>
|
||||
<input type="hidden" name="invite" value="<?php echo htmlspecialchars($invite, ENT_QUOTES, 'UTF-8'); ?>">
|
||||
<label for="password">New password (12 to 72 characters)</label>
|
||||
<input type="password" id="password" name="password" required minlength="12" maxlength="72" autocomplete="new-password" autofocus>
|
||||
|
||||
<label for="confirm">Confirm password</label>
|
||||
<input type="password" id="confirm" name="confirm" required minlength="12" maxlength="72" autocomplete="new-password">
|
||||
|
||||
<button type="submit">Set password and sign in</button>
|
||||
<?php else: ?>
|
||||
<label for="username">Login name</label>
|
||||
<input type="text" id="username" name="username" required autofocus autocomplete="username">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" required autofocus>
|
||||
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required autocomplete="current-password">
|
||||
<input type="password" id="password" name="password" required>
|
||||
|
||||
<button type="submit">Log In</button>
|
||||
<?php endif; ?>
|
||||
<p class="shamelessPlug">NeoCMS ©<?php echo date('Y'); ?> Andy Dixon</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
@@ -1,2 +0,0 @@
|
||||
# Audit entries contain usernames, addresses, and operational detail; keep them private.
|
||||
Require all denied
|
||||
+1
-3
@@ -1,4 +1,2 @@
|
||||
<?php
|
||||
// Audit logs are not a public attraction; return to the authenticated CMS instead.
|
||||
http_response_code(404);
|
||||
header("Location: ../");
|
||||
header("Location: /cms/");
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
class neoCMSCore
|
||||
{
|
||||
|
||||
public $auditEnabled = true;
|
||||
public $users = [];
|
||||
public $loggedIn = false;
|
||||
public $version = '2.0';
|
||||
private $loggedInUser = '';
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public function login($username, $password)
|
||||
{
|
||||
$this->loggedIn = (!empty($this->users[$username]) && $this->users[$username] == $password);
|
||||
if ($this->loggedIn) $this->loggedInUser = $username;
|
||||
$this->writeAudit("User login for " . $username . " was " . ($this->loggedIn ? "successful" : "denied due to incorrect credentials") . " from " . $_SERVER['REMOTE_ADDR']);
|
||||
return $this->loggedIn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write an entry into the audit log
|
||||
* @param $value
|
||||
* @return void
|
||||
*/
|
||||
public function writeAudit($value)
|
||||
{
|
||||
if ($this->auditEnabled)
|
||||
file_put_contents(__DIR__ . "/" . date('y-m-d-') . "audit.txt", date('Y-m-d H:i:s') . "\t" . $this->loggedInUser . "\t" . trim($value) . "\n", FILE_APPEND);
|
||||
}
|
||||
|
||||
public function isLoggedIn()
|
||||
{
|
||||
return $this->loggedIn;
|
||||
}
|
||||
|
||||
public function getLoggedinUser()
|
||||
{
|
||||
return $this->loggedInUser;
|
||||
}
|
||||
|
||||
public function saveContent($uri, $content)
|
||||
{
|
||||
$this->writeAudit("Content for " . $uri . " was saved.");
|
||||
$uri = str_replace("../", "./", $uri);
|
||||
|
||||
//check to see if we're dealing with a default (index.htm{l}) page
|
||||
if (str_ends_with($uri, "/") || $uri == '/') {
|
||||
if (file_exists($_SERVER['DOCUMENT_ROOT'] . $uri . 'index.htm')) {
|
||||
$uri = $uri . '/index.htm';
|
||||
} elseif (file_exists($_SERVER['DOCUMENT_ROOT'] . $uri . 'index.html')) {
|
||||
$uri = $uri . '/index.html';
|
||||
} else {
|
||||
die('Unknown default file:' . $uri);
|
||||
}
|
||||
}
|
||||
file_put_contents($_SERVER['DOCUMENT_ROOT'] . $uri, $content);
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Command-line worker for scheduled publications.
|
||||
*
|
||||
* Run this file from cron once per minute. It is intentionally unavailable over HTTP: scheduled
|
||||
* publishing is useful, while a public button marked "publish everything due" is rather less so.
|
||||
*/
|
||||
|
||||
// Load the same configuration and autoloader used by the interactive CMS.
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
|
||||
// Refuse web requests even if the web server accidentally exposes this script.
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Cron may omit DOCUMENT_ROOT, so derive it from the CMS directory when necessary.
|
||||
if (empty($_SERVER['DOCUMENT_ROOT'])) {
|
||||
$_SERVER['DOCUMENT_ROOT'] = dirname(__DIR__);
|
||||
}
|
||||
|
||||
// Publish every due job and print a concise result suitable for a cron log.
|
||||
$controller = new NeoCMS\CMSController($config ?? []);
|
||||
$count = $controller->publishScheduled();
|
||||
echo "Published {$count} scheduled page(s).\n";
|
||||
@@ -1,22 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace NeoCMS;
|
||||
|
||||
/** Records one operational event in both the Dashboard activity feed and the daily audit log. */
|
||||
final class Activity
|
||||
{
|
||||
public function __construct(private FileStore $store, private Logger $logger)
|
||||
{
|
||||
}
|
||||
|
||||
public function record(string $user, string $action, string $target): void
|
||||
{
|
||||
$entry = ['created' => date(DATE_ATOM), 'user' => $user, 'action' => $action, 'target' => $target];
|
||||
// Retaining the latest 250 entries keeps the dashboard useful without growing forever.
|
||||
$this->store->update('activity', function (array $entries) use ($entry) {
|
||||
$entries[] = $entry;
|
||||
return array_slice($entries, -250);
|
||||
});
|
||||
$this->logger->write($action . ': ' . $target, $user);
|
||||
}
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace NeoCMS;
|
||||
|
||||
/**
|
||||
* Manages session authentication, role capabilities, and CSRF tokens.
|
||||
*
|
||||
* Credentials remain configuration-backed to preserve NeoCMS's database-free design. Only hashes
|
||||
* created by password_hash() are accepted; plaintext credentials are rejected.
|
||||
*/
|
||||
class Authentication
|
||||
{
|
||||
/** Valid bcrypt hash used solely to equalise unknown-user password verification work. */
|
||||
private const DUMMY_PASSWORD_HASH = '$2y$10$NaiFDtsxcQzcxAiQrTmEPuDde8sDsdpjAlSgoiYwAvdnF5Cwytute';
|
||||
|
||||
/** Map of usernames to password hashes. */
|
||||
private array $credentials;
|
||||
|
||||
/** Map of usernames to editor or administrator roles (the legacy publisher role counts as editor). */
|
||||
private array $roles;
|
||||
|
||||
/** Maximum permitted inactivity for an authenticated session. */
|
||||
private int $idleTimeout;
|
||||
|
||||
/** Maximum total lifetime of an authenticated session. */
|
||||
private int $absoluteTimeout;
|
||||
|
||||
/** URL path the session cookie is scoped to; the CMS directory, which moves when the site is in a subfolder. */
|
||||
private string $cookiePath;
|
||||
|
||||
/** Optional explicit Secure-cookie setting for TLS-terminating reverse proxies. */
|
||||
private ?bool $cookieSecure;
|
||||
|
||||
/** Initialise secure session handling and retain the configured account maps. */
|
||||
public function __construct(array $credentials, array $roles = [], array $sessionOptions = [])
|
||||
{
|
||||
$this->credentials = $credentials;
|
||||
$this->roles = $roles;
|
||||
$this->idleTimeout = max(300, (int) ($sessionOptions['idleTimeout'] ?? 1800));
|
||||
$this->absoluteTimeout = max($this->idleTimeout, (int) ($sessionOptions['absoluteTimeout'] ?? 43200));
|
||||
$this->cookieSecure = isset($sessionOptions['cookieSecure']) ? (bool) $sessionOptions['cookieSecure'] : null;
|
||||
$this->cookiePath = (string) ($sessionOptions['cookiePath'] ?? '/cms');
|
||||
$this->startSession();
|
||||
$this->enforceSessionLifetime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the PHP session with conservative cookie settings.
|
||||
*
|
||||
* HTTPS cookies are marked Secure when the request is encrypted. SameSite=Lax protects common
|
||||
* cross-site request scenarios without making ordinary login redirects needlessly dramatic.
|
||||
*/
|
||||
private function startSession(): void
|
||||
{
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
return;
|
||||
}
|
||||
|
||||
$isHttps = $this->cookieSecure ?? (
|
||||
(!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
||||
|| (isset($_SERVER['SERVER_PORT']) && (int) $_SERVER['SERVER_PORT'] === 443)
|
||||
);
|
||||
|
||||
ini_set('session.use_strict_mode', '1');
|
||||
ini_set('session.use_only_cookies', '1');
|
||||
ini_set('session.cookie_httponly', '1');
|
||||
ini_set('session.cookie_samesite', 'Lax');
|
||||
session_name('NEOCMSSESSID');
|
||||
|
||||
session_set_cookie_params([
|
||||
'httponly' => true,
|
||||
'path' => $this->cookiePath,
|
||||
'samesite' => 'Lax',
|
||||
'secure' => $isHttps,
|
||||
]);
|
||||
|
||||
session_start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate a user and establish a fresh privileged session.
|
||||
*
|
||||
* @param mixed $username Submitted account name.
|
||||
* @param mixed $password Submitted plaintext password.
|
||||
*/
|
||||
public function login($username, $password): bool
|
||||
{
|
||||
$username = (string) $username;
|
||||
if ($this->credentialsAreValid($username, (string) $password)) {
|
||||
// Rotate the identifier at the privilege boundary to prevent session fixation.
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['loggedIn'] = true;
|
||||
$_SESSION['loggedInUser'] = $username;
|
||||
$_SESSION['role'] = UserStore::normaliseRole($this->roles[$username] ?? 'editor');
|
||||
$_SESSION['credentialFingerprint'] = hash('sha256', (string) $this->credentials[$username]);
|
||||
$_SESSION['csrfToken'] = bin2hex(random_bytes(32));
|
||||
$_SESSION['authenticatedAt'] = time();
|
||||
$_SESSION['lastActivityAt'] = time();
|
||||
$_SESSION['lastRegeneratedAt'] = time();
|
||||
return true;
|
||||
}
|
||||
|
||||
unset(
|
||||
$_SESSION['loggedIn'],
|
||||
$_SESSION['loggedInUser'],
|
||||
$_SESSION['role'],
|
||||
$_SESSION['credentialFingerprint'],
|
||||
$_SESSION['authenticatedAt'],
|
||||
$_SESSION['lastActivityAt'],
|
||||
$_SESSION['lastRegeneratedAt']
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Return whether the current session belongs to an authenticated user. */
|
||||
public function isLoggedIn(): bool
|
||||
{
|
||||
return $_SESSION['loggedIn'] ?? false;
|
||||
}
|
||||
|
||||
/** Return the current username, or a safe label when no user is authenticated. */
|
||||
public function getLoggedInUser(): string
|
||||
{
|
||||
return $_SESSION['loggedInUser'] ?? 'Not logged in';
|
||||
}
|
||||
|
||||
/** Return the session role, defaulting unauthorised sessions to the least powerful role. */
|
||||
public function getRole(): string
|
||||
{
|
||||
$username = $this->getLoggedInUser();
|
||||
$role = UserStore::normaliseRole($this->roles[$username] ?? 'editor');
|
||||
$_SESSION['role'] = $role;
|
||||
return $role;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the current role includes a named capability.
|
||||
*
|
||||
* Capability checks live here so controllers and upload handlers share one policy table.
|
||||
*/
|
||||
public function can(string $capability): bool
|
||||
{
|
||||
$permissions = [
|
||||
'editor' => ['draft', 'upload', 'publish', 'schedule'],
|
||||
'administrator' => ['draft', 'upload', 'publish', 'schedule', 'manage'],
|
||||
];
|
||||
|
||||
return in_array($capability, $permissions[$this->getRole()] ?? [], true);
|
||||
}
|
||||
|
||||
/** Re-bind the current session to a changed credential so the user stays signed in while their other sessions end. */
|
||||
public function renewSession(string $credentialHash): void
|
||||
{
|
||||
$_SESSION['credentialFingerprint'] = hash('sha256', $credentialHash);
|
||||
if (session_status() === PHP_SESSION_ACTIVE && !headers_sent()) {
|
||||
session_regenerate_id(true);
|
||||
}
|
||||
$_SESSION['lastRegeneratedAt'] = time();
|
||||
}
|
||||
|
||||
/** Verify the signed-in user's password (for re-authentication before sensitive actions). */
|
||||
public function verifyCurrentPassword(string $password): bool
|
||||
{
|
||||
return $this->credentialsAreValid($this->getLoggedInUser(), $password);
|
||||
}
|
||||
|
||||
/** Return the session's CSRF token, generating a cryptographically random token when absent. */
|
||||
public function getCsrfToken(): string
|
||||
{
|
||||
if (empty($_SESSION['csrfToken'])) {
|
||||
$_SESSION['csrfToken'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
|
||||
return $_SESSION['csrfToken'];
|
||||
}
|
||||
|
||||
/** Compare a submitted CSRF token in constant time to avoid leaking useful timing information. */
|
||||
public function isValidCsrfToken(?string $token): bool
|
||||
{
|
||||
if (!is_string($token) || $token === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return hash_equals($this->getCsrfToken(), $token);
|
||||
}
|
||||
|
||||
/** Clear session data, expire its cookie, and destroy the server-side session. */
|
||||
public function logout(): void
|
||||
{
|
||||
$_SESSION = [];
|
||||
|
||||
if (ini_get('session.use_cookies')) {
|
||||
$params = session_get_cookie_params();
|
||||
setcookie(session_name(), '', [
|
||||
'expires' => time() - 42000,
|
||||
'path' => $params['path'],
|
||||
'domain' => $params['domain'],
|
||||
'secure' => $params['secure'],
|
||||
'httponly' => $params['httponly'],
|
||||
'samesite' => 'Lax',
|
||||
]);
|
||||
}
|
||||
|
||||
session_destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate submitted credentials against a password_hash() value.
|
||||
*/
|
||||
private function credentialsAreValid(string $username, string $password): bool
|
||||
{
|
||||
$storedCredential = $this->credentials[$username] ?? null;
|
||||
$passwordInfo = is_string($storedCredential) ? password_get_info($storedCredential) : [];
|
||||
$configuredHashIsValid = is_string($storedCredential)
|
||||
&& $storedCredential !== ''
|
||||
&& ($passwordInfo['algoName'] ?? 'unknown') !== 'unknown';
|
||||
$verificationHash = $configuredHashIsValid ? $storedCredential : self::DUMMY_PASSWORD_HASH;
|
||||
$verified = password_verify($password, $verificationHash);
|
||||
return $configuredHashIsValid && $verified;
|
||||
}
|
||||
|
||||
/** Expire stale sessions and periodically rotate active session identifiers. */
|
||||
private function enforceSessionLifetime(): void
|
||||
{
|
||||
if (empty($_SESSION['loggedIn'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$username = is_string($_SESSION['loggedInUser'] ?? null) ? $_SESSION['loggedInUser'] : '';
|
||||
$storedCredential = $this->credentials[$username] ?? null;
|
||||
$passwordInfo = is_string($storedCredential) ? password_get_info($storedCredential) : [];
|
||||
if (!is_string($storedCredential) || ($passwordInfo['algoName'] ?? 'unknown') === 'unknown') {
|
||||
$this->invalidateSession();
|
||||
return;
|
||||
}
|
||||
$fingerprint = hash('sha256', $storedCredential);
|
||||
if (isset($_SESSION['credentialFingerprint']) && !hash_equals((string) $_SESSION['credentialFingerprint'], $fingerprint)) {
|
||||
$this->invalidateSession();
|
||||
return;
|
||||
}
|
||||
$_SESSION['credentialFingerprint'] = $fingerprint;
|
||||
|
||||
$_SESSION['authenticatedAt'] = (int) ($_SESSION['authenticatedAt'] ?? $now);
|
||||
$_SESSION['lastActivityAt'] = (int) ($_SESSION['lastActivityAt'] ?? $now);
|
||||
$_SESSION['lastRegeneratedAt'] = (int) ($_SESSION['lastRegeneratedAt'] ?? $now);
|
||||
|
||||
$idleExpired = $now - $_SESSION['lastActivityAt'] > $this->idleTimeout;
|
||||
$absoluteExpired = $now - $_SESSION['authenticatedAt'] > $this->absoluteTimeout;
|
||||
if ($idleExpired || $absoluteExpired) {
|
||||
$this->invalidateSession();
|
||||
return;
|
||||
}
|
||||
|
||||
if ($now - $_SESSION['lastRegeneratedAt'] > 900) {
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['lastRegeneratedAt'] = $now;
|
||||
}
|
||||
$_SESSION['lastActivityAt'] = $now;
|
||||
}
|
||||
|
||||
/** Clear privileged session state and rotate the identifier after expiry or account revocation. */
|
||||
private function invalidateSession(): void
|
||||
{
|
||||
$_SESSION = [];
|
||||
session_regenerate_id(true);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,185 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace NeoCMS;
|
||||
|
||||
/** Pure HTML transformations for shared regions, generated menus, and editable-region detection. */
|
||||
final class ContentDom
|
||||
{
|
||||
/** Return each data-neo-shared region's inner HTML, keyed by its shared name. */
|
||||
public static function sharedBlocks(string $html): array
|
||||
{
|
||||
$found = [];
|
||||
foreach ((new \DOMXPath(self::load($html)))->query('//*[@data-neo-shared]') as $node) {
|
||||
$key = $node->getAttribute('data-neo-shared');
|
||||
if ($key !== '') {
|
||||
$found[$key] = self::innerHtml($node);
|
||||
}
|
||||
}
|
||||
return $found;
|
||||
}
|
||||
|
||||
/** Replace every region marked data-neo-shared="$key"; null when the page has none. */
|
||||
public static function withShared(string $html, string $key, string $content): ?string
|
||||
{
|
||||
if (!str_contains($html, 'data-neo-shared')) {
|
||||
return null;
|
||||
}
|
||||
$dom = self::load($html);
|
||||
// $key is restricted to [a-zA-Z0-9_-] by the caller, so it cannot break out of the XPath string.
|
||||
$nodes = (new \DOMXPath($dom))->query('//*[@data-neo-shared="' . $key . '"]');
|
||||
if ($nodes->length === 0) {
|
||||
return null;
|
||||
}
|
||||
foreach ($nodes as $node) {
|
||||
self::replaceInnerHtml($dom, $node, $content);
|
||||
}
|
||||
return $dom->saveHTML();
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the list inside every element marked data-neo-menu="$name", keeping the element's own attributes
|
||||
* and every other byte of the page. Null when nothing needs to change.
|
||||
*/
|
||||
public static function withMenu(string $html, string $name, array $items, string $uri = '', string $basePath = ''): ?string
|
||||
{
|
||||
if (!str_contains($html, 'data-neo-menu')) {
|
||||
return null;
|
||||
}
|
||||
$list = self::menuList($items, $uri, $basePath);
|
||||
$flat = fn(string $s) => preg_replace('/>\s+</', '><', trim($s));
|
||||
$edits = [];
|
||||
foreach (SiteAnalyser::navs($html) as $nav) {
|
||||
if ($nav['menu'] !== $name || $nav['close'] === null) {
|
||||
continue;
|
||||
}
|
||||
if ($flat(substr($html, $nav['end'], $nav['close'] - $nav['end'])) !== $flat($list)) {
|
||||
$edits[] = [$nav['end'], $nav['close'] - $nav['end']];
|
||||
}
|
||||
}
|
||||
rsort($edits);
|
||||
foreach ($edits as [$offset, $length]) {
|
||||
$html = substr($html, 0, $offset) . $list . substr($html, $offset + $length);
|
||||
}
|
||||
return $edits ? $html : null;
|
||||
}
|
||||
|
||||
/** A menu as a standalone <nav> (the list itself comes from menuList). */
|
||||
public static function renderMenu(string $name, array $items): string
|
||||
{
|
||||
return '<nav data-neo-menu="' . htmlspecialchars($name, ENT_QUOTES) . '">' . self::menuList($items) . '</nav>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a nested, escaped list from flat parent-labelled items, marking the link to the page being written.
|
||||
* Circular parent references are stopped by the ancestor list rather than pursued forever.
|
||||
*/
|
||||
public static function menuList(array $items, string $uri = '', string $basePath = ''): string
|
||||
{
|
||||
$children = [];
|
||||
foreach ($items as $item) {
|
||||
$children[$item['parent'] ?? ''][] = $item;
|
||||
}
|
||||
$render = function (string $parent, array $ancestors = []) use (&$render, $children, $uri, $basePath): string {
|
||||
if (empty($children[$parent])) {
|
||||
return '';
|
||||
}
|
||||
$html = '<ul>';
|
||||
foreach ($children[$parent] as $item) {
|
||||
$label = htmlspecialchars($item['label'], ENT_QUOTES);
|
||||
$url = htmlspecialchars($item['url'], ENT_QUOTES);
|
||||
$current = $uri !== '' && self::isPage($item['url'], $uri, $basePath) ? ' aria-current="page"' : '';
|
||||
$nested = in_array($item['label'], $ancestors, true) ? '' : $render($item['label'], array_merge($ancestors, [$item['label']]));
|
||||
$html .= '<li><a href="' . $url . '"' . $current . '>' . $label . '</a>' . $nested . '</li>';
|
||||
}
|
||||
return $html . '</ul>';
|
||||
};
|
||||
return $render('');
|
||||
}
|
||||
|
||||
/** Turn a page link into a site path (relative links resolved against the page, query/fragment kept); null for external or fragment-only links. */
|
||||
public static function resolveLink(string $url, string $pageUri): ?string
|
||||
{
|
||||
$url = trim($url);
|
||||
$path = parse_url($url, PHP_URL_PATH);
|
||||
if ($url === '' || $url[0] === '#' || preg_match('#^([a-z][a-z0-9+.-]*:|//)#i', $url) || !is_string($path) || $path === '') {
|
||||
return null;
|
||||
}
|
||||
if ($path[0] !== '/') {
|
||||
$path = rtrim(str_replace('\\', '/', dirname($pageUri)), '/') . '/' . $path;
|
||||
}
|
||||
$out = [];
|
||||
foreach (explode('/', $path) as $segment) {
|
||||
if ($segment === '' || $segment === '.') {
|
||||
continue;
|
||||
}
|
||||
$segment === '..' ? array_pop($out) : $out[] = $segment;
|
||||
}
|
||||
return '/' . implode('/', $out) . (str_ends_with($path, '/') && $out ? '/' : '') . substr($url, strcspn($url, '?#'));
|
||||
}
|
||||
|
||||
/** Whether a menu link points at the page with this URI (a folder link means its index.html; a subfolder install's prefix is ignored). */
|
||||
private static function isPage(string $url, string $uri, string $basePath): bool
|
||||
{
|
||||
$resolved = self::resolveLink($url, $uri);
|
||||
if ($resolved === null) {
|
||||
return false;
|
||||
}
|
||||
$index = fn(string $p) => str_ends_with($p, '/') ? $p . 'index.html' : $p;
|
||||
$path = $index((string) parse_url($resolved, PHP_URL_PATH));
|
||||
$uri = $index($uri);
|
||||
return $path === $uri || ($basePath !== '' && $path === $basePath . $uri);
|
||||
}
|
||||
/** Determine whether a document contains at least one element carrying exactly this CSS class token. */
|
||||
public static function hasClass(string $html, string $class): bool
|
||||
{
|
||||
$dom = new \DOMDocument();
|
||||
@$dom->loadHTML($html);
|
||||
// Class-token matching avoids treating "editable-extra" as though it were "editable".
|
||||
$query = '//*[contains(concat(" ", normalize-space(@class), " "), " ' . $class . ' ")]';
|
||||
return (new \DOMXPath($dom))->query($query)->length > 0;
|
||||
}
|
||||
|
||||
/** Parse HTML as UTF-8 (libxml assumes Latin-1 otherwise) without keeping the encoding hint in the output. */
|
||||
private static function load(string $html): \DOMDocument
|
||||
{
|
||||
$dom = new \DOMDocument();
|
||||
@$dom->loadHTML('<?xml encoding="UTF-8">' . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
|
||||
foreach (iterator_to_array($dom->childNodes) as $node) {
|
||||
if ($node->nodeType === XML_PI_NODE) {
|
||||
$dom->removeChild($node);
|
||||
}
|
||||
}
|
||||
return $dom;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a DOM node's children with an HTML fragment.
|
||||
*
|
||||
* DOMDocument parses forgiving HTML here rather than strict XML, allowing ordinary authoring
|
||||
* fragments such as <br> without demanding that editors suddenly become XML librarians.
|
||||
*/
|
||||
private static function replaceInnerHtml(\DOMDocument $dom, \DOMNode $node, string $html): void
|
||||
{
|
||||
while ($node->firstChild) {
|
||||
$node->removeChild($node->firstChild);
|
||||
}
|
||||
$wrapper = self::load('<div id="neo-fragment">' . $html . '</div>')->getElementById('neo-fragment');
|
||||
if (!$wrapper) {
|
||||
$node->appendChild($dom->createTextNode($html));
|
||||
return;
|
||||
}
|
||||
foreach (iterator_to_array($wrapper->childNodes) as $child) {
|
||||
$node->appendChild($dom->importNode($child, true));
|
||||
}
|
||||
}
|
||||
|
||||
/** Serialise only the children of a DOM node, excluding the node's own wrapper tag. */
|
||||
private static function innerHtml(\DOMNode $node): string
|
||||
{
|
||||
$html = '';
|
||||
foreach ($node->childNodes as $child) {
|
||||
$html .= $node->ownerDocument->saveHTML($child);
|
||||
}
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace NeoCMS;
|
||||
|
||||
/**
|
||||
* Persists NeoCMS metadata as human-readable JSON and provides managed subdirectories.
|
||||
*
|
||||
* Public pages remain ordinary HTML files. This store contains only the supporting cast: drafts,
|
||||
* revisions, schedules, menus, media metadata, and activity. No database server need feel left out.
|
||||
*/
|
||||
final class FileStore
|
||||
{
|
||||
/** Absolute data-directory path, always ending with the platform separator. */
|
||||
private string $root;
|
||||
|
||||
/**
|
||||
* Prepare the configured data directory.
|
||||
*
|
||||
* @param string $root Absolute or project-relative storage path.
|
||||
*/
|
||||
public function __construct(string $root)
|
||||
{
|
||||
$this->root = rtrim($root, '/\\') . DIRECTORY_SEPARATOR;
|
||||
$this->ensureDirectory($this->root);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a named JSON document, returning the supplied default when it does not yet exist.
|
||||
* Invalid JSON is treated as absent data so callers always receive the promised array shape.
|
||||
*
|
||||
* @param string $name Logical document name without the .json suffix.
|
||||
* @param array $default Value returned for a missing or unreadable document.
|
||||
* @return array Decoded document contents.
|
||||
*/
|
||||
public function read(string $name, array $default = []): array
|
||||
{
|
||||
$path = $this->jsonPath($name);
|
||||
if (!is_file($path)) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
$value = json_decode((string) file_get_contents($path), true);
|
||||
return is_array($value) ? $value : $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically replace a named JSON document.
|
||||
*
|
||||
* Data is written to a temporary neighbour and renamed only after the write succeeds. Readers
|
||||
* therefore see either the old document or the complete new one, never half a JSON sandwich.
|
||||
*
|
||||
* @param string $name Logical document name without the .json suffix.
|
||||
* @param array $value Serializable document contents.
|
||||
*/
|
||||
public function write(string $name, array $value): void
|
||||
{
|
||||
$path = $this->jsonPath($name);
|
||||
$temporary = $path . '.' . bin2hex(random_bytes(6)) . '.tmp';
|
||||
$json = json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
if ($json === false || file_put_contents($temporary, $json . "\n", LOCK_EX) === false) {
|
||||
throw new \RuntimeException('Unable to write CMS data');
|
||||
}
|
||||
@chmod($temporary, 0600);
|
||||
if (!rename($temporary, $path)) {
|
||||
@unlink($temporary);
|
||||
throw new \RuntimeException('Unable to replace CMS data');
|
||||
}
|
||||
@chmod($path, 0600);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read, modify, and write a JSON document under an exclusive lock so concurrent requests cannot lose updates.
|
||||
*
|
||||
* @param callable $fn Receives the current array and returns the array to store.
|
||||
* @return array The stored document.
|
||||
*/
|
||||
public function update(string $name, callable $fn, array $default = []): array
|
||||
{
|
||||
$lockPath = $this->directory('locks') . preg_replace('/[^a-zA-Z0-9_.-]/', '', $name) . '.lock';
|
||||
$lock = fopen($lockPath, 'c+');
|
||||
if ($lock === false || !flock($lock, LOCK_EX)) {
|
||||
if (is_resource($lock)) {
|
||||
fclose($lock);
|
||||
}
|
||||
throw new \RuntimeException('Unable to lock CMS data');
|
||||
}
|
||||
try {
|
||||
$value = $fn($this->read($name, $default));
|
||||
$this->write($name, $value);
|
||||
return $value;
|
||||
} finally {
|
||||
flock($lock, LOCK_UN);
|
||||
fclose($lock);
|
||||
}
|
||||
}
|
||||
|
||||
/** Atomically write a private content file beneath a managed subdirectory. */
|
||||
public function writePrivateFile(string $directory, string $filename, string $content): string
|
||||
{
|
||||
$target = $this->directory($directory) . basename($filename);
|
||||
$temporary = $target . '.' . bin2hex(random_bytes(6)) . '.tmp';
|
||||
if (file_put_contents($temporary, $content, LOCK_EX) === false) {
|
||||
throw new \RuntimeException('Unable to write private CMS file');
|
||||
}
|
||||
@chmod($temporary, 0600);
|
||||
if (!rename($temporary, $target)) {
|
||||
@unlink($temporary);
|
||||
throw new \RuntimeException('Unable to replace private CMS file');
|
||||
}
|
||||
@chmod($target, 0600);
|
||||
return $target;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a managed subdirectory, creating it when first required.
|
||||
*
|
||||
* @param string $name Relative directory name such as drafts or revisions.
|
||||
*/
|
||||
public function directory(string $name): string
|
||||
{
|
||||
$path = $this->root . trim($name, '/\\') . DIRECTORY_SEPARATOR;
|
||||
$this->ensureDirectory($path);
|
||||
return $path;
|
||||
}
|
||||
|
||||
/** Convert a logical document name into a safe JSON path beneath the data root. */
|
||||
private function jsonPath(string $name): string
|
||||
{
|
||||
return $this->root . preg_replace('/[^a-zA-Z0-9_.-]/', '', $name) . '.json';
|
||||
}
|
||||
|
||||
/** Create a directory recursively or fail with a useful application-level exception. */
|
||||
private function ensureDirectory(string $path): void
|
||||
{
|
||||
if (!is_dir($path) && !@mkdir($path, 0700, true) && !is_dir($path)) {
|
||||
throw new \RuntimeException('Unable to create CMS data directory');
|
||||
}
|
||||
@chmod($path, 0700);
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace NeoCMS;
|
||||
|
||||
/** Writes simple, daily, append-only audit logs for operational traceability. */
|
||||
class Logger
|
||||
{
|
||||
/** Whether audit writes are enabled by configuration. */
|
||||
private $auditEnabled;
|
||||
|
||||
/** Absolute directory containing the dated audit files. */
|
||||
private $logsDir;
|
||||
|
||||
/** Maximum size of one active daily log before it is rotated. */
|
||||
private $maxFileBytes;
|
||||
|
||||
/** Number of days for which audit logs are retained. */
|
||||
private $retentionDays;
|
||||
|
||||
/**
|
||||
* Configure logging and ensure its destination exists.
|
||||
*
|
||||
* @param mixed $auditEnabled Truthy values enable audit output.
|
||||
* @param array<string, mixed> $security Security limits shared with the rest of the CMS.
|
||||
*/
|
||||
public function __construct($auditEnabled, array $security = [])
|
||||
{
|
||||
$this->auditEnabled = $auditEnabled;
|
||||
|
||||
// Conservative defaults prevent a forgotten audit directory from becoming its own archive service.
|
||||
$this->maxFileBytes = max(1024, (int) ($security['auditMaxFileBytes'] ?? 10 * 1024 * 1024));
|
||||
$this->retentionDays = max(1, (int) ($security['auditRetentionDays'] ?? 90));
|
||||
|
||||
$this->logsDir = __DIR__ . '/../../logs';
|
||||
$this->logsDir = rtrim($this->logsDir, '/\\') . DIRECTORY_SEPARATOR;
|
||||
|
||||
// Create the directory lazily so a fresh installation needs no ceremonial folder setup.
|
||||
if (!is_dir($this->logsDir)) {
|
||||
if (!mkdir($this->logsDir, 0700, true)) {
|
||||
throw new \Exception('Failed to create logs directory: ' . $this->logsDir);
|
||||
}
|
||||
}
|
||||
@chmod($this->logsDir, 0700);
|
||||
|
||||
// Pruning scans every rotated log file; a Logger is built on every request, so do this
|
||||
// occasionally rather than on each one (the same probabilistic approach as PHP's own session GC).
|
||||
if ($this->auditEnabled && random_int(1, 100) === 1) {
|
||||
$this->pruneExpiredLogs();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a timestamped user action to today's audit file.
|
||||
*
|
||||
* @param mixed $message Human-readable action description.
|
||||
* @param mixed $user Username responsible for the action.
|
||||
*/
|
||||
public function write($message, $user): void
|
||||
{
|
||||
if (!$this->auditEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// One file per day keeps inspection and rotation pleasantly unsurprising.
|
||||
$logFile = $this->logsDir . date('Y-m-d') . '-audit.txt';
|
||||
$this->rotateFullLog($logFile);
|
||||
$entry = sprintf(
|
||||
"[%s]\tUser: %s\t%s\n",
|
||||
date('Y-m-d H:i:s'),
|
||||
$this->normaliseLogField($user),
|
||||
$this->normaliseLogField($message)
|
||||
);
|
||||
|
||||
if (file_put_contents($logFile, $entry, FILE_APPEND | LOCK_EX) === false) {
|
||||
throw new \RuntimeException('Unable to write audit log');
|
||||
}
|
||||
@chmod($logFile, 0600);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a full daily log aside before appending another entry.
|
||||
*
|
||||
* @param string $logFile Absolute path to today's active log.
|
||||
*/
|
||||
private function rotateFullLog(string $logFile): void
|
||||
{
|
||||
clearstatcache(true, $logFile);
|
||||
if (!is_file($logFile) || (int) filesize($logFile) < $this->maxFileBytes) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rotated = $this->logsDir . date('Y-m-d-His') . '-' . bin2hex(random_bytes(3)) . '-audit.txt';
|
||||
if (!rename($logFile, $rotated)) {
|
||||
throw new \RuntimeException('Unable to rotate audit log');
|
||||
}
|
||||
@chmod($rotated, 0600);
|
||||
}
|
||||
|
||||
/** Remove audit files older than the configured retention period. */
|
||||
private function pruneExpiredLogs(): void
|
||||
{
|
||||
$cutoff = time() - ($this->retentionDays * 86400);
|
||||
$logFiles = glob($this->logsDir . '*-audit.txt') ?: [];
|
||||
|
||||
foreach ($logFiles as $logFile) {
|
||||
if (is_file($logFile) && (int) filemtime($logFile) < $cutoff && !unlink($logFile)) {
|
||||
throw new \RuntimeException('Unable to remove expired audit log');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove control characters that could forge extra audit entries or columns.
|
||||
*
|
||||
* @param mixed $value Value destined for one tab-separated log field.
|
||||
*/
|
||||
private function normaliseLogField($value): string
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
return str_replace(["\r", "\n", "\t"], [' ', ' ', ' '], $value);
|
||||
}
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace NeoCMS;
|
||||
|
||||
/**
|
||||
* Provides filesystem-backed throttling for failed login attempts.
|
||||
*
|
||||
* Limits are applied to both the source address and the source-address/username pair. This slows
|
||||
* password guessing without allowing one remote address to lock an account for everybody else.
|
||||
*/
|
||||
final class LoginRateLimiter
|
||||
{
|
||||
private string $path;
|
||||
private int $windowSeconds;
|
||||
private int $lockoutSeconds;
|
||||
private int $maxIdentityAttempts;
|
||||
private int $maxAddressAttempts;
|
||||
|
||||
/** Hard ceiling on tracked buckets, so a distributed flood of distinct addresses or login names cannot grow the state file without bound. */
|
||||
private const MAX_TRACKED_KEYS = 20000;
|
||||
|
||||
/** Configure the protected state file and throttling thresholds. */
|
||||
public function __construct(string $dataDirectory, array $options = [])
|
||||
{
|
||||
$directory = rtrim($dataDirectory, '/\\') . DIRECTORY_SEPARATOR;
|
||||
if (!is_dir($directory) && !mkdir($directory, 0700, true) && !is_dir($directory)) {
|
||||
throw new \RuntimeException('Unable to create login security directory');
|
||||
}
|
||||
@chmod($directory, 0700);
|
||||
|
||||
$this->path = $directory . 'login-attempts.json';
|
||||
$this->windowSeconds = max(60, (int) ($options['loginWindowSeconds'] ?? 900));
|
||||
$this->lockoutSeconds = max(60, (int) ($options['loginLockoutSeconds'] ?? 900));
|
||||
$this->maxIdentityAttempts = max(1, (int) ($options['loginMaxAttempts'] ?? 5));
|
||||
$this->maxAddressAttempts = max($this->maxIdentityAttempts, (int) ($options['loginMaxAddressAttempts'] ?? 20));
|
||||
}
|
||||
|
||||
/** Return the remaining lockout in seconds, or zero when another attempt is permitted. */
|
||||
public function retryAfter(string $address, string $username): int
|
||||
{
|
||||
return $this->withState(function (array &$state) use ($address, $username): int {
|
||||
$now = time();
|
||||
$this->prune($state, $now);
|
||||
$retryAt = max(
|
||||
(int) ($state[$this->addressKey($address)]['locked_until'] ?? 0),
|
||||
(int) ($state[$this->identityKey($address, $username)]['locked_until'] ?? 0)
|
||||
);
|
||||
return max(0, $retryAt - $now);
|
||||
});
|
||||
}
|
||||
|
||||
/** Record one denied login and lock a bucket once its configured threshold is reached. */
|
||||
public function recordFailure(string $address, string $username): void
|
||||
{
|
||||
$this->withState(function (array &$state) use ($address, $username): void {
|
||||
$now = time();
|
||||
$this->prune($state, $now);
|
||||
$this->increment($state, $this->addressKey($address), $this->maxAddressAttempts, $now);
|
||||
$this->increment($state, $this->identityKey($address, $username), $this->maxIdentityAttempts, $now);
|
||||
});
|
||||
}
|
||||
|
||||
/** Clear successful login buckets so typographical mistakes do not linger unnecessarily. */
|
||||
public function clear(string $address, string $username): void
|
||||
{
|
||||
$this->withState(function (array &$state) use ($address, $username): void {
|
||||
unset($state[$this->addressKey($address)], $state[$this->identityKey($address, $username)]);
|
||||
$this->prune($state, time());
|
||||
});
|
||||
}
|
||||
|
||||
/** Update one attempt bucket and begin a fixed lockout when its threshold is reached. */
|
||||
private function increment(array &$state, string $key, int $limit, int $now): void
|
||||
{
|
||||
$record = $state[$key] ?? ['attempts' => [], 'locked_until' => 0];
|
||||
$record['attempts'][] = $now;
|
||||
if (count($record['attempts']) >= $limit) {
|
||||
$record['locked_until'] = $now + $this->lockoutSeconds;
|
||||
$record['attempts'] = [];
|
||||
}
|
||||
$state[$key] = $record;
|
||||
}
|
||||
|
||||
/** Remove expired attempts and stale unlocked buckets to keep the state file bounded. */
|
||||
private function prune(array &$state, int $now): void
|
||||
{
|
||||
$cutoff = $now - $this->windowSeconds;
|
||||
foreach ($state as $key => &$record) {
|
||||
$record['attempts'] = array_values(array_filter(
|
||||
is_array($record['attempts'] ?? null) ? $record['attempts'] : [],
|
||||
static fn($timestamp): bool => is_int($timestamp) && $timestamp >= $cutoff
|
||||
));
|
||||
$record['locked_until'] = (int) ($record['locked_until'] ?? 0);
|
||||
if ($record['attempts'] === [] && $record['locked_until'] <= $now) {
|
||||
unset($state[$key]);
|
||||
}
|
||||
}
|
||||
unset($record);
|
||||
|
||||
if (count($state) > self::MAX_TRACKED_KEYS) {
|
||||
// Evict the least recently active buckets first; an evicted bucket simply restarts its count.
|
||||
uasort($state, static function (array $a, array $b): int {
|
||||
$lastA = max((int) ($a['locked_until'] ?? 0), $a['attempts'] ? (int) end($a['attempts']) : 0);
|
||||
$lastB = max((int) ($b['locked_until'] ?? 0), $b['attempts'] ? (int) end($b['attempts']) : 0);
|
||||
return $lastA <=> $lastB;
|
||||
});
|
||||
$state = array_slice($state, -self::MAX_TRACKED_KEYS, null, true);
|
||||
}
|
||||
}
|
||||
|
||||
/** Serialise state updates beneath an exclusive lock to avoid concurrent lost updates. */
|
||||
private function withState(callable $callback)
|
||||
{
|
||||
$handle = fopen($this->path, 'c+');
|
||||
if ($handle === false || !flock($handle, LOCK_EX)) {
|
||||
if (is_resource($handle)) {
|
||||
fclose($handle);
|
||||
}
|
||||
throw new \RuntimeException('Unable to access login security state');
|
||||
}
|
||||
@chmod($this->path, 0600);
|
||||
|
||||
try {
|
||||
rewind($handle);
|
||||
$decoded = json_decode((string) stream_get_contents($handle), true);
|
||||
$state = is_array($decoded) ? $decoded : [];
|
||||
$result = $callback($state);
|
||||
$json = json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
if ($json === false) {
|
||||
throw new \RuntimeException('Unable to encode login security state');
|
||||
}
|
||||
rewind($handle);
|
||||
if (!ftruncate($handle, 0) || fwrite($handle, $json . "\n") === false || !fflush($handle)) {
|
||||
throw new \RuntimeException('Unable to write login security state');
|
||||
}
|
||||
@chmod($this->path, 0600);
|
||||
return $result;
|
||||
} finally {
|
||||
flock($handle, LOCK_UN);
|
||||
fclose($handle);
|
||||
}
|
||||
}
|
||||
|
||||
/** Hash address data so the throttling file does not become a convenient IP-address register. */
|
||||
private function addressKey(string $address): string
|
||||
{
|
||||
return 'address:' . hash('sha256', $address);
|
||||
}
|
||||
|
||||
/** Hash the combined address and case-normalised username for the tighter identity bucket. */
|
||||
private function identityKey(string $address, string $username): string
|
||||
{
|
||||
return 'identity:' . hash('sha256', $address . "\0" . strtolower($username));
|
||||
}
|
||||
}
|
||||
@@ -1,299 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace NeoCMS;
|
||||
|
||||
/**
|
||||
* The one list of uploadable media: allowed extensions, their categories, and the safety checks applied to every upload.
|
||||
*
|
||||
* Only extensions listed here are accepted, so executables, scripts, archives, HTML/SVG and macro-enabled Office files are refused
|
||||
* by omission. On top of that, content is sniffed and scanned for embedded code. This is best-effort hardening, not a malware scanner.
|
||||
*/
|
||||
final class MediaTypes
|
||||
{
|
||||
public const CATEGORIES = ['imagery' => 'Imagery', 'documents' => 'Documents', 'video' => 'Video', 'audio' => 'Audio'];
|
||||
|
||||
/** extension => category */
|
||||
private const EXTENSIONS = [
|
||||
'jpg' => 'imagery', 'jpeg' => 'imagery', 'png' => 'imagery', 'gif' => 'imagery', 'webp' => 'imagery',
|
||||
'pdf' => 'documents', 'docx' => 'documents', 'xlsx' => 'documents', 'pptx' => 'documents',
|
||||
'odt' => 'documents', 'ods' => 'documents', 'odp' => 'documents', 'txt' => 'documents', 'csv' => 'documents',
|
||||
'mp4' => 'video', 'm4v' => 'video', 'webm' => 'video', 'mov' => 'video', 'ogv' => 'video',
|
||||
'mp3' => 'audio', 'wav' => 'audio', 'ogg' => 'audio', 'oga' => 'audio', 'm4a' => 'audio', 'aac' => 'audio', 'flac' => 'audio',
|
||||
];
|
||||
|
||||
/** Extensions that must never appear anywhere in a filename (for example report.exe.pdf), even though the file is renamed. */
|
||||
private const DENIED_PARTS = [
|
||||
'exe', 'dll', 'bat', 'cmd', 'com', 'msi', 'scr', 'ps1', 'vbs', 'vbe', 'js', 'jse', 'jar', 'wsf', 'hta', 'cpl', 'reg',
|
||||
'php', 'php3', 'php4', 'php5', 'php7', 'php8', 'phtml', 'phar', 'cgi', 'pl', 'py', 'rb', 'sh', 'bash', 'asp', 'aspx', 'jsp', 'jspx',
|
||||
'html', 'htm', 'xhtml', 'shtml', 'svg', 'xml', 'swf', 'lnk', 'apk', 'app', 'dmg', 'iso', 'so', 'bin',
|
||||
'docm', 'xlsm', 'pptm', 'dotm', 'xltm', 'potm', 'xlam', 'ppam',
|
||||
];
|
||||
|
||||
/** MIME types that always mean executable or markup content, whatever the extension claims. */
|
||||
private const BLOCKED_MIMES = [
|
||||
'application/x-dosexec', 'application/x-executable', 'application/x-sharedlib', 'application/x-mach-binary', 'application/x-msdownload',
|
||||
'application/x-elf', 'application/x-msi', 'application/java-archive', 'application/x-httpd-php', 'application/javascript',
|
||||
'text/javascript', 'text/x-php', 'text/x-shellscript', 'text/x-script.python', 'text/html', 'image/svg+xml', 'text/xml', 'application/xml',
|
||||
];
|
||||
|
||||
/** Case-insensitive markers of server-side or browser code, refused anywhere in any upload. Long enough never to occur by chance in compressed data. */
|
||||
private const CODE_MARKERS = ['<?php', '<script'];
|
||||
|
||||
/** Three-byte markers also refused, but only in plain-text uploads: in compressed media they appear by chance in any file of a few megabytes. */
|
||||
private const TEXT_CODE_MARKERS = ['<?=', '<%@', '<%='];
|
||||
|
||||
/** Entry names refused inside Office/OpenDocument packages. */
|
||||
private const DENIED_ENTRY_ENDINGS = ['.exe', '.dll', '.bat', '.cmd', '.com', '.msi', '.scr', '.ps1', '.vbs', '.js', '.jar', '.php', '.html', '.htm', '.sh', '.py', '.pl', '.bin'];
|
||||
|
||||
/** Expected ODF mimetype entry per extension. */
|
||||
private const ODF = ['odt' => 'application/vnd.oasis.opendocument.text', 'ods' => 'application/vnd.oasis.opendocument.spreadsheet', 'odp' => 'application/vnd.oasis.opendocument.presentation'];
|
||||
|
||||
/** Expected marker entry per OOXML extension. */
|
||||
private const OOXML = ['docx' => 'word/document.xml', 'xlsx' => 'xl/workbook.xml', 'pptx' => 'ppt/presentation.xml'];
|
||||
|
||||
public static function extensions(): array
|
||||
{
|
||||
return array_keys(self::EXTENSIONS);
|
||||
}
|
||||
|
||||
public static function categoryForExtension(string $extension): ?string
|
||||
{
|
||||
return self::EXTENSIONS[strtolower($extension)] ?? null;
|
||||
}
|
||||
|
||||
/** Category of a stored filename, from its extension. */
|
||||
public static function categoryForName(string $name): ?string
|
||||
{
|
||||
return self::categoryForExtension(pathinfo($name, PATHINFO_EXTENSION));
|
||||
}
|
||||
|
||||
/** Regular-expression fragment (unanchored) matching every stored media filename: legacy 32-hex names or slug-8hex names. */
|
||||
public static function nameRegex(): string
|
||||
{
|
||||
return '(?:[a-f0-9]{32}|[a-z0-9](?:[a-z0-9-]{0,78}[a-z0-9])?-[a-f0-9]{8})\.(?:' . implode('|', self::extensions()) . ')';
|
||||
}
|
||||
|
||||
public static function isManagedName(string $name): bool
|
||||
{
|
||||
return preg_match('/^' . self::nameRegex() . '$/', $name) === 1;
|
||||
}
|
||||
|
||||
/** New stored filename: a slug of the original name, a random suffix, and exactly one dot. */
|
||||
public static function storedName(string $originalName, string $extension): string
|
||||
{
|
||||
$base = pathinfo(basename($originalName), PATHINFO_FILENAME);
|
||||
if (function_exists('iconv')) {
|
||||
$base = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $base) ?: $base;
|
||||
}
|
||||
$slug = trim((string) preg_replace('/[^a-z0-9]+/', '-', strtolower($base)), '-');
|
||||
$slug = trim(substr($slug, 0, 60), '-');
|
||||
return ($slug !== '' ? $slug : 'file') . '-' . bin2hex(random_bytes(4)) . '.' . $extension;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check an uploaded file and report how to store it.
|
||||
*
|
||||
* @param array $limits maxBytes, maxWidth, maxHeight, maxPixels
|
||||
* @return array{ext: string, category: string, mime: string}
|
||||
* @throws \RuntimeException with a message safe to show the user
|
||||
*/
|
||||
public static function inspect(string $path, string $originalName, array $limits = []): array
|
||||
{
|
||||
$parts = explode('.', strtolower(basename(str_replace('\\', '/', $originalName))));
|
||||
$extension = count($parts) > 1 ? (string) end($parts) : '';
|
||||
$category = self::EXTENSIONS[$extension] ?? null;
|
||||
foreach (array_slice($parts, 1) as $part) {
|
||||
if (in_array($part, self::DENIED_PARTS, true)) {
|
||||
throw new \RuntimeException('Files of this type (.' . $part . ') are not allowed.');
|
||||
}
|
||||
}
|
||||
if ($category === null) {
|
||||
throw new \RuntimeException('This file type is not allowed. Accepted: images, documents (PDF, Word, Excel, PowerPoint, OpenDocument, text, CSV), video, and audio.');
|
||||
}
|
||||
$size = @filesize($path);
|
||||
if ($size === false || $size < 1) {
|
||||
throw new \RuntimeException('The file is empty.');
|
||||
}
|
||||
if (isset($limits['maxBytes']) && $size > $limits['maxBytes']) {
|
||||
throw new \RuntimeException('This file is larger than the ' . round($limits['maxBytes'] / 1048576, 1) . ' MB limit.');
|
||||
}
|
||||
|
||||
$head = (string) file_get_contents($path, false, null, 0, 8);
|
||||
foreach (["MZ", "\x7fELF", "#!", "\xCF\xFA\xED\xFE", "\xFE\xED\xFA\xCF", "\xCA\xFE\xBA\xBE"] as $signature) {
|
||||
if (str_starts_with($head, $signature)) {
|
||||
throw new \RuntimeException('Executable files are not allowed.');
|
||||
}
|
||||
}
|
||||
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mime = (string) finfo_file($finfo, $path);
|
||||
finfo_close($finfo);
|
||||
if (in_array($mime, self::BLOCKED_MIMES, true)) {
|
||||
throw new \RuntimeException('Executable or script content is not allowed.');
|
||||
}
|
||||
self::assertNoCode($path, $extension === 'txt' || $extension === 'csv');
|
||||
|
||||
match (true) {
|
||||
$category === 'imagery' => self::checkImage($path, $extension, $mime, $limits),
|
||||
$extension === 'pdf' => self::checkPdf($path, $mime),
|
||||
isset(self::OOXML[$extension]) || isset(self::ODF[$extension]) => self::checkPackage($path, $extension),
|
||||
$extension === 'txt' || $extension === 'csv' => self::checkText($path, $mime),
|
||||
default => self::checkAv($path, $extension, $mime, $category),
|
||||
};
|
||||
return ['ext' => $extension === 'jpeg' ? 'jpg' : $extension, 'category' => $category, 'mime' => $mime];
|
||||
}
|
||||
|
||||
private static function assertNoCode(string $path, bool $plainText): void
|
||||
{
|
||||
$handle = fopen($path, 'rb');
|
||||
if ($handle === false) {
|
||||
throw new \RuntimeException('The file could not be read.');
|
||||
}
|
||||
$carry = '';
|
||||
while (!feof($handle)) {
|
||||
$chunk = (string) fread($handle, 1048576);
|
||||
$window = strtolower($carry . $chunk);
|
||||
foreach ($plainText ? array_merge(self::CODE_MARKERS, self::TEXT_CODE_MARKERS) : self::CODE_MARKERS as $marker) {
|
||||
if (str_contains($window, $marker)) {
|
||||
fclose($handle);
|
||||
throw new \RuntimeException('Files containing code are not allowed.');
|
||||
}
|
||||
}
|
||||
$carry = substr($chunk, -8);
|
||||
}
|
||||
fclose($handle);
|
||||
}
|
||||
|
||||
private static function checkImage(string $path, string $extension, string $mime, array $limits): void
|
||||
{
|
||||
$expected = ['jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'gif' => 'image/gif', 'webp' => 'image/webp'][$extension];
|
||||
if ($mime !== $expected) {
|
||||
throw new \RuntimeException('The file contents do not match its image type.');
|
||||
}
|
||||
$info = @getimagesize($path);
|
||||
if ($info === false) {
|
||||
throw new \RuntimeException('Uploaded file is not a valid image.');
|
||||
}
|
||||
[$width, $height] = $info;
|
||||
if ($width > ($limits['maxWidth'] ?? 8192) || $height > ($limits['maxHeight'] ?? 8192) || $width * $height > ($limits['maxPixels'] ?? 24 * 1024 * 1024)) {
|
||||
throw new \RuntimeException('Image dimensions exceed the configured limit.');
|
||||
}
|
||||
}
|
||||
|
||||
private static function checkPdf(string $path, string $mime): void
|
||||
{
|
||||
$data = (string) file_get_contents($path);
|
||||
if ($mime !== 'application/pdf' || !str_contains(substr($data, 0, 1024), '%PDF-')) {
|
||||
throw new \RuntimeException('The file contents do not match a PDF.');
|
||||
}
|
||||
if (preg_match('#/JavaScript|/JS\b|/Launch|/EmbeddedFile|/RichMedia#', $data)) {
|
||||
throw new \RuntimeException('PDFs containing scripts, launch actions or embedded files are not allowed.');
|
||||
}
|
||||
}
|
||||
|
||||
private static function checkText(string $path, string $mime): void
|
||||
{
|
||||
$data = (string) file_get_contents($path);
|
||||
if (str_contains($data, "\0") || !preg_match('//u', $data)) {
|
||||
throw new \RuntimeException('Text files must be plain UTF-8 text.');
|
||||
}
|
||||
if (!str_starts_with($mime, 'text/') && $mime !== 'application/csv') {
|
||||
throw new \RuntimeException('The file contents do not match a text file.');
|
||||
}
|
||||
}
|
||||
|
||||
/** Audio and video must sniff as such; when libmagic only says "octet-stream", the container signature must match the extension. */
|
||||
private static function checkAv(string $path, string $extension, string $mime, string $category): void
|
||||
{
|
||||
if (str_starts_with($mime, 'audio/') || str_starts_with($mime, 'video/') || $mime === 'application/ogg') {
|
||||
return;
|
||||
}
|
||||
$head = (string) file_get_contents($path, false, null, 0, 12);
|
||||
$sync = strlen($head) > 1 && ord($head[0]) === 0xFF;
|
||||
$matches = match ($extension) {
|
||||
'mp4', 'm4v', 'mov', 'm4a' => in_array(substr($head, 4, 4), ['ftyp', 'moov', 'mdat', 'free', 'wide', 'skip', 'pnot'], true),
|
||||
'webm' => str_starts_with($head, "\x1A\x45\xDF\xA3"),
|
||||
'ogv', 'ogg', 'oga' => str_starts_with($head, 'OggS'),
|
||||
'mp3' => str_starts_with($head, 'ID3') || ($sync && (ord($head[1]) & 0xE0) === 0xE0),
|
||||
'aac' => $sync && (ord($head[1]) & 0xF0) === 0xF0,
|
||||
'wav' => str_starts_with($head, 'RIFF'),
|
||||
'flac' => str_starts_with($head, 'fLaC'),
|
||||
default => false,
|
||||
};
|
||||
if ($mime !== 'application/octet-stream' || !$matches) {
|
||||
throw new \RuntimeException('The file contents do not match ' . ($category === 'video' ? 'a video' : 'an audio') . ' file.');
|
||||
}
|
||||
}
|
||||
|
||||
/** OOXML and OpenDocument files are ZIP packages: read the directory only, and refuse macros, embedded objects, and executables. */
|
||||
private static function checkPackage(string $path, string $extension): void
|
||||
{
|
||||
$entries = self::zipEntries($path);
|
||||
if (isset(self::ODF[$extension])) {
|
||||
$type = self::zipRead($path, $entries['mimetype'] ?? null);
|
||||
if ($type !== self::ODF[$extension]) {
|
||||
throw new \RuntimeException('The file contents do not match an OpenDocument file.');
|
||||
}
|
||||
} elseif (!isset($entries['[Content_Types].xml']) || !isset($entries[self::OOXML[$extension]])) {
|
||||
throw new \RuntimeException('The file contents do not match an Office document.');
|
||||
}
|
||||
foreach (array_keys($entries) as $name) {
|
||||
$lower = strtolower((string) $name);
|
||||
$bad = str_contains($lower, 'vbaproject') || str_contains($lower, 'activex') || str_contains($lower, 'embeddings/')
|
||||
|| str_starts_with($lower, 'basic/') || str_starts_with($lower, 'scripts/') || str_contains($lower, '..') || str_starts_with($lower, '/');
|
||||
foreach (self::DENIED_ENTRY_ENDINGS as $ending) {
|
||||
$bad = $bad || str_ends_with($lower, $ending);
|
||||
}
|
||||
if ($bad) {
|
||||
throw new \RuntimeException('Documents containing macros, embedded objects or scripts are not allowed.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string, array{offset: int, csize: int, usize: int, method: int}> */
|
||||
private static function zipEntries(string $path): array
|
||||
{
|
||||
$size = (int) filesize($path);
|
||||
$tail = (string) file_get_contents($path, false, null, max(0, $size - 65557));
|
||||
$eocd = strrpos($tail, "PK\x05\x06");
|
||||
if ($eocd === false || strlen($tail) < $eocd + 22 || !str_starts_with((string) file_get_contents($path, false, null, 0, 4), "PK\x03\x04")) {
|
||||
throw new \RuntimeException('The file is not a valid document package.');
|
||||
}
|
||||
$count = unpack('v', substr($tail, $eocd + 10, 2))[1];
|
||||
$directorySize = unpack('V', substr($tail, $eocd + 12, 4))[1];
|
||||
$directoryOffset = unpack('V', substr($tail, $eocd + 16, 4))[1];
|
||||
if ($count === 0xFFFF || $directorySize === 0xFFFFFFFF || $count > 5000 || $directorySize > 5 * 1048576) {
|
||||
throw new \RuntimeException('The document package is too complex.');
|
||||
}
|
||||
$directory = (string) file_get_contents($path, false, null, $directoryOffset, $directorySize);
|
||||
$entries = [];
|
||||
$position = 0;
|
||||
while ($position + 46 <= strlen($directory) && substr($directory, $position, 4) === "PK\x01\x02") {
|
||||
$method = unpack('v', substr($directory, $position + 10, 2))[1];
|
||||
$csize = unpack('V', substr($directory, $position + 20, 4))[1];
|
||||
$usize = unpack('V', substr($directory, $position + 24, 4))[1];
|
||||
[$nameLength, $extraLength, $commentLength] = array_values(unpack('v3', substr($directory, $position + 28, 6)));
|
||||
$offset = unpack('V', substr($directory, $position + 42, 4))[1];
|
||||
$entries[substr($directory, $position + 46, $nameLength)] = ['offset' => $offset, 'csize' => $csize, 'usize' => $usize, 'method' => $method];
|
||||
$position += 46 + $nameLength + $extraLength + $commentLength;
|
||||
}
|
||||
if (!$entries) {
|
||||
throw new \RuntimeException('The file is not a valid document package.');
|
||||
}
|
||||
return $entries;
|
||||
}
|
||||
|
||||
/** Read one stored (uncompressed) entry, used only for the small ODF mimetype file. */
|
||||
private static function zipRead(string $path, ?array $entry): string
|
||||
{
|
||||
if ($entry === null || $entry['method'] !== 0 || $entry['usize'] > 256) {
|
||||
return '';
|
||||
}
|
||||
$header = (string) file_get_contents($path, false, null, $entry['offset'], 30);
|
||||
if (strlen($header) < 30) {
|
||||
return '';
|
||||
}
|
||||
[$nameLength, $extraLength] = array_values(unpack('v2', substr($header, 26, 4)));
|
||||
return (string) file_get_contents($path, false, null, $entry['offset'] + 30 + $nameLength + $extraLength, $entry['usize']);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace NeoCMS;
|
||||
|
||||
/** Raised when a URI does not resolve to an existing managed page. */
|
||||
final class PageNotFoundException extends \RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace NeoCMS;
|
||||
|
||||
/** Resolves public page URIs to filesystem paths inside the document root, rejecting anything that escapes it. */
|
||||
final class PagePaths
|
||||
{
|
||||
public function __construct(private string $documentRoot, private int $maxManagedPages, private int $maxScannedEntries)
|
||||
{
|
||||
}
|
||||
|
||||
/** Resolve an existing public URI to a canonical, in-root HTML file path. */
|
||||
public function existing(string $uri): string
|
||||
{
|
||||
$uri = $this->normaliseUri($uri);
|
||||
$candidate = $this->documentRoot . ltrim($uri, '/');
|
||||
if (is_dir($candidate)) {
|
||||
$candidate = rtrim($candidate, '/\\') . '/index.html';
|
||||
} elseif (!preg_match('/\.html?$/i', $candidate)) {
|
||||
$candidate .= '.html';
|
||||
}
|
||||
$real = realpath($candidate);
|
||||
if (!$real || !str_starts_with($real, $this->documentRoot) || str_starts_with($real, $this->documentRoot . 'cms' . DIRECTORY_SEPARATOR) || !preg_match('/\.html?$/i', $real)) {
|
||||
throw new PageNotFoundException('Invalid page path');
|
||||
}
|
||||
return $real;
|
||||
}
|
||||
|
||||
/** Build a safe path for a page that may not exist yet. */
|
||||
public function newPath(string $uri): string
|
||||
{
|
||||
$path = $this->documentRoot . ltrim($uri, '/');
|
||||
if (!str_starts_with($path, $this->documentRoot)) {
|
||||
throw new \RuntimeException('Invalid page path');
|
||||
}
|
||||
$this->assertNoSymlinks($path);
|
||||
$parent = realpath(dirname($path));
|
||||
if ($parent && !str_starts_with($parent . DIRECTORY_SEPARATOR, $this->documentRoot)) {
|
||||
throw new \RuntimeException('Invalid page path');
|
||||
}
|
||||
if (str_starts_with($path, $this->documentRoot . 'cms' . DIRECTORY_SEPARATOR)) {
|
||||
throw new \RuntimeException('CMS files cannot be managed as pages');
|
||||
}
|
||||
return $path;
|
||||
}
|
||||
|
||||
/** Reject page targets whose existing path components contain symbolic links. */
|
||||
public function assertNoSymlinks(string $path): void
|
||||
{
|
||||
$relative = substr($path, strlen($this->documentRoot));
|
||||
$current = rtrim($this->documentRoot, DIRECTORY_SEPARATOR);
|
||||
foreach (explode('/', str_replace('\\', '/', $relative)) as $component) {
|
||||
if ($component === '') {
|
||||
continue;
|
||||
}
|
||||
$current .= DIRECTORY_SEPARATOR . $component;
|
||||
if (is_link($current)) {
|
||||
throw new \RuntimeException('Symbolic links are not permitted in managed page paths');
|
||||
}
|
||||
if (file_exists($current)) {
|
||||
$real = realpath($current);
|
||||
if ($real === false || !str_starts_with($real . (is_dir($real) ? DIRECTORY_SEPARATOR : ''), $this->documentRoot)) {
|
||||
throw new \RuntimeException('Invalid page path');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Canonicalise a new page URI, append .html when absent, and enforce safe characters. */
|
||||
public function normaliseNewUri(string $uri): string
|
||||
{
|
||||
$uri = $this->normaliseUri($uri);
|
||||
if (!preg_match('/\.html?$/i', $uri)) {
|
||||
$uri .= '.html';
|
||||
}
|
||||
if (!preg_match('#^/[a-zA-Z0-9_./-]+\.html?$#', $uri)) {
|
||||
throw new \RuntimeException('Invalid page name');
|
||||
}
|
||||
return $uri;
|
||||
}
|
||||
|
||||
/** Canonicalise a request URI and reject traversal or null-byte input. */
|
||||
public function normaliseUri(string $uri): string
|
||||
{
|
||||
$path = parse_url($uri, PHP_URL_PATH) ?: '/';
|
||||
$path = '/' . ltrim(str_replace('\\', '/', $path), '/');
|
||||
if (str_contains($path, '..') || str_contains($path, "\0")) {
|
||||
throw new \RuntimeException('Invalid URI');
|
||||
}
|
||||
return $path;
|
||||
}
|
||||
|
||||
/** Accept an absolute HTTP(S) link or normalise a local site path. */
|
||||
public function normaliseLink(string $url): string
|
||||
{
|
||||
if (preg_match('#^https?://#i', $url)) {
|
||||
return filter_var($url, FILTER_SANITIZE_URL);
|
||||
}
|
||||
return $this->normaliseUri($url);
|
||||
}
|
||||
|
||||
/** Return every public .html or .htm file while excluding the CMS application itself. */
|
||||
public function files(): array
|
||||
{
|
||||
$files = [];
|
||||
$scanned = 0;
|
||||
$iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($this->documentRoot, \FilesystemIterator::SKIP_DOTS));
|
||||
foreach ($iterator as $file) {
|
||||
$scanned++;
|
||||
if ($scanned > $this->maxScannedEntries) {
|
||||
throw new \RuntimeException('Document root scan limit exceeded');
|
||||
}
|
||||
if (count($files) >= $this->maxManagedPages) {
|
||||
throw new \RuntimeException('Managed page limit exceeded');
|
||||
}
|
||||
if ($file->isLink()) {
|
||||
continue;
|
||||
}
|
||||
$path = $file->getRealPath();
|
||||
if (!$file->isFile() || $path === false || !str_starts_with($path, $this->documentRoot) || !preg_match('/\.html?$/i', $path)) {
|
||||
continue;
|
||||
}
|
||||
if (str_starts_with($path, $this->documentRoot . 'cms' . DIRECTORY_SEPARATOR)) {
|
||||
continue;
|
||||
}
|
||||
$files[] = $path;
|
||||
}
|
||||
return $files;
|
||||
}
|
||||
|
||||
/** Convert an absolute page path back into its public, slash-separated URI. */
|
||||
public function uriFor(string $path): string
|
||||
{
|
||||
return '/' . str_replace(DIRECTORY_SEPARATOR, '/', substr($path, strlen($this->documentRoot)));
|
||||
}
|
||||
/**
|
||||
* Whether a page's local asset reference points at a real file inside the site.
|
||||
*
|
||||
* @return bool|null null for external or non-file references (http:, //, data:, #anchor), which are not checked.
|
||||
*/
|
||||
public function refExists(string $pagePath, string $ref, string $basePath = ''): ?bool
|
||||
{
|
||||
$ref = trim($ref);
|
||||
if ($ref === '' || preg_match('#^([a-z][a-z0-9+.-]*:|//|\#)#i', $ref)) {
|
||||
return null;
|
||||
}
|
||||
$ref = rawurldecode((string) preg_replace('/[?#].*$/s', '', $ref));
|
||||
if ($ref === '' || str_contains($ref, "\0")) {
|
||||
return $ref === '' ? null : false;
|
||||
}
|
||||
if ($ref[0] === '/') {
|
||||
// The upload endpoint writes URLs with the site's URL prefix; strip it to get a path inside the site.
|
||||
if ($basePath !== '' && str_starts_with($ref, $basePath . '/')) {
|
||||
$ref = substr($ref, strlen($basePath));
|
||||
}
|
||||
$candidate = $this->documentRoot . ltrim($ref, '/');
|
||||
} else {
|
||||
$candidate = dirname($pagePath) . DIRECTORY_SEPARATOR . $ref;
|
||||
}
|
||||
$real = realpath($candidate);
|
||||
return $real !== false && str_starts_with($real, $this->documentRoot) && is_file($real);
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace NeoCMS;
|
||||
|
||||
/** Applies consistent browser security and cache-control headers to CMS responses. */
|
||||
final class SecurityHeaders
|
||||
{
|
||||
/** Protect an HTML administration page, optionally allowing the pinned UI CDNs. */
|
||||
public static function html(bool $allowUiCdns = false, ?bool $secureTransport = null): void
|
||||
{
|
||||
self::common($secureTransport);
|
||||
header('Cache-Control: no-store, private');
|
||||
header('Pragma: no-cache');
|
||||
|
||||
$scriptSources = $allowUiCdns
|
||||
? "'self' https://code.jquery.com"
|
||||
: "'self'";
|
||||
$styleSources = $allowUiCdns
|
||||
? "'self' 'unsafe-inline' https://code.jquery.com"
|
||||
: "'self'";
|
||||
|
||||
header("Content-Security-Policy: default-src 'self'; script-src {$scriptSources}; style-src {$styleSources}; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self'; frame-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'");
|
||||
}
|
||||
|
||||
/** Protect JSON API responses and prevent authenticated data from being cached. */
|
||||
public static function json(?bool $secureTransport = null): void
|
||||
{
|
||||
self::common($secureTransport);
|
||||
header('Cache-Control: no-store, private');
|
||||
header('Pragma: no-cache');
|
||||
header("Content-Security-Policy: default-src 'none'; frame-ancestors 'none'");
|
||||
header('Content-Type: application/json');
|
||||
}
|
||||
|
||||
/** Emit headers shared by both document and API responses. */
|
||||
private static function common(?bool $secureTransport): void
|
||||
{
|
||||
header_remove('X-Powered-By');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('X-Frame-Options: DENY');
|
||||
header('Referrer-Policy: no-referrer');
|
||||
header('Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()');
|
||||
header('Cross-Origin-Opener-Policy: same-origin');
|
||||
header('Cross-Origin-Resource-Policy: same-origin');
|
||||
$isHttps = $secureTransport ?? (
|
||||
(!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
||||
|| (isset($_SERVER['SERVER_PORT']) && (int) $_SERVER['SERVER_PORT'] === 443)
|
||||
);
|
||||
if ($isHttps) {
|
||||
header('Strict-Transport-Security: max-age=31536000');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,428 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace NeoCMS;
|
||||
|
||||
/**
|
||||
* Finds what NeoCMS could edit in a page (content containers, images, SEO tags) and adds the markers.
|
||||
*
|
||||
* Pages are never re-serialised through a DOM parser: a tokenizer locates tag offsets and apply() splices
|
||||
* text in, so every byte outside the inserted markers is preserved exactly.
|
||||
*/
|
||||
final class SiteAnalyser
|
||||
{
|
||||
private const VOID = ['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr'];
|
||||
private const SKIPPED = ['header', 'nav', 'footer', 'aside', 'form'];
|
||||
private const SKIPPED_ROLES = ['navigation', 'banner', 'contentinfo'];
|
||||
private const MIN_SECTION_TEXT = 40;
|
||||
|
||||
/** Describe a page: existing and proposed editable regions, images, local references, and SEO tags present. */
|
||||
public static function analyse(string $html, string $editableClass, bool $allNavs = false): array
|
||||
{
|
||||
$c = self::scan($html, $editableClass, true);
|
||||
return [
|
||||
'title' => $c['title'],
|
||||
'existingRegions' => $c['existing'],
|
||||
'proposed' => array_map(fn(int $i) => $c['els'][$i]['name'], $c['regions']),
|
||||
'images' => array_map(fn(array $i) => ['src' => $i['src'], 'hasAlt' => $i['hasAlt'], 'inRegion' => $i['inRegion'], 'tagged' => $i['tagged']], $c['images']),
|
||||
'refs' => $c['refs'],
|
||||
'seo' => $c['seo'],
|
||||
'navs' => array_values(array_map(
|
||||
fn(array $n) => ['menu' => $n['menu'] ?? ($allNavs ? $n['name'] : $n['slot']), 'links' => count($n['links']), 'tagged' => $n['menu'] !== null, 'sig' => md5(implode("\n", array_column($n['links'], 'url')))],
|
||||
array_filter($c['navs'], fn(array $n) => $n['menu'] !== null || ($allNavs ? $n['name'] : $n['slot']) !== null)
|
||||
)),
|
||||
];
|
||||
}
|
||||
|
||||
/** Every navigation block in a page with its byte offsets, links and any data-neo-menu name (used to replace menus in place). */
|
||||
public static function navs(string $html): array
|
||||
{
|
||||
return self::navList($html, self::elements($html));
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert the requested markers into a page.
|
||||
*
|
||||
* @param array $options Booleans: content, images, seo, menus; allNavs also names navs that are not clearly header or footer menus.
|
||||
* @return array|null ['html' => string, 'changes' => string[], 'menus' => [name => links]], or null when the page needs no change.
|
||||
*/
|
||||
public static function apply(string $html, string $editableClass, array $options): ?array
|
||||
{
|
||||
$c = self::scan($html, $editableClass, !empty($options['content']));
|
||||
$edits = [];
|
||||
$changes = [];
|
||||
|
||||
if (!empty($options['content'])) {
|
||||
foreach ($c['regions'] as $i) {
|
||||
$edit = self::classEdit($html, $c['els'][$i], $editableClass);
|
||||
if ($edit) {
|
||||
$edits[] = $edit;
|
||||
$changes[] = "Made <{$c['els'][$i]['name']}> editable";
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!empty($options['images'])) {
|
||||
foreach ($c['images'] as $img) {
|
||||
if (!$img['inRegion'] && !$img['tagged'] && $img['taggable']) {
|
||||
$edits[] = [$img['insertAt'], ' data-neo-image'];
|
||||
$changes[] = !empty($img['placeholder'])
|
||||
? 'Made image placeholder editable' . ($img['label'] !== '' ? ': ' . $img['label'] : '')
|
||||
: 'Made image editable: ' . $img['src'];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!empty($options['seo']) && ($seo = self::seoEdit($html, $c))) {
|
||||
$edits[] = $seo['edit'];
|
||||
array_push($changes, ...$seo['changes']);
|
||||
}
|
||||
$menus = [];
|
||||
if (!empty($options['menus'])) {
|
||||
$named = array_filter(array_column($c['navs'], 'menu'));
|
||||
foreach ($c['navs'] as $nav) {
|
||||
$name = !empty($options['allNavs']) ? $nav['name'] : $nav['slot'];
|
||||
// One nav per name per page, and never over a menu the page already marks by hand.
|
||||
if ($nav['menu'] === null && $name !== null && !isset($menus[$name]) && !in_array($name, $named, true)) {
|
||||
$edits[] = [$nav['insertAt'], ' data-neo-menu="' . $name . '"'];
|
||||
$changes[] = 'Marked ' . $name . ' navigation as a menu (' . count($nav['links']) . ' links)';
|
||||
$menus[$name] = $nav['links'];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!$edits) {
|
||||
return null;
|
||||
}
|
||||
|
||||
usort($edits, fn(array $a, array $b) => $b[0] <=> $a[0]);
|
||||
foreach ($edits as [$offset, $text]) {
|
||||
$html = substr($html, 0, $offset) . $text . substr($html, $offset);
|
||||
}
|
||||
return ['html' => $html, 'changes' => $changes, 'menus' => $menus];
|
||||
}
|
||||
|
||||
/** Navigation blocks: <nav> or role=navigation, classified as the primary (header) or secondary (footer) menu when recognisable. */
|
||||
private static function navList(string $html, array $els): array
|
||||
{
|
||||
$navs = [];
|
||||
foreach ($els as $k => $e) {
|
||||
if ($e['name'] !== 'nav' && strtolower((string) self::attr($e['attrs'], 'role')) !== 'navigation') {
|
||||
continue;
|
||||
}
|
||||
$links = [];
|
||||
foreach ($els as $a) {
|
||||
if ($a['name'] !== 'a' || ($href = self::attr($a['attrs'], 'href')) === null) {
|
||||
continue;
|
||||
}
|
||||
$inside = false;
|
||||
$lis = [];
|
||||
for ($p = $a['parent']; $p >= 0; $p = $els[$p]['parent']) {
|
||||
if ($p === $k) {
|
||||
$inside = true;
|
||||
break;
|
||||
}
|
||||
if ($els[$p]['name'] === 'li') {
|
||||
$lis[] = $p;
|
||||
}
|
||||
}
|
||||
$href = trim(html_entity_decode($href, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
|
||||
$label = self::text($html, $a) ?: trim((string) self::attr($a['attrs'], 'aria-label'));
|
||||
if (!$inside || $href === '' || $label === '' || stripos($href, 'javascript:') === 0) {
|
||||
continue;
|
||||
}
|
||||
// A link inside a nested list is a child of the link that owns the enclosing list item.
|
||||
$parent = '';
|
||||
if (isset($lis[1])) {
|
||||
foreach ($els as $b) {
|
||||
if ($b['name'] === 'a' && $b['parent'] === $lis[1]) {
|
||||
$parent = self::text($html, $b);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
$links[] = ['label' => $label, 'url' => $href, 'parent' => $parent];
|
||||
}
|
||||
|
||||
$ancestors = [];
|
||||
for ($p = $e['parent']; $p >= 0; $p = $els[$p]['parent']) {
|
||||
$ancestors[] = $els[$p]['name'] . ':' . strtolower((string) self::attr($els[$p]['attrs'], 'role'));
|
||||
}
|
||||
$in = fn(string $tag, string $role) => in_array($tag . ':', $ancestors, true) || in_array(':' . $role, $ancestors, true) || in_array($tag . ':' . $role, $ancestors, true);
|
||||
$label = strtolower(self::attr($e['attrs'], 'aria-label') . ' ' . self::attr($e['attrs'], 'class') . ' ' . self::attr($e['attrs'], 'id'));
|
||||
$slot = null;
|
||||
if ($links) {
|
||||
if ($in('footer', 'contentinfo') || preg_match('/\b(footer|secondary|legal)\b/', $label)) {
|
||||
$slot = 'secondary';
|
||||
} elseif ($in('header', 'banner') || preg_match('/\b(main|primary|site-nav|top)\b/', $label)) {
|
||||
$slot = 'primary';
|
||||
}
|
||||
}
|
||||
$navs[] = [
|
||||
'menu' => ($m = self::attr($e['attrs'], 'data-neo-menu')) !== null && $m !== '' ? $m : null,
|
||||
'slot' => $slot, 'name' => $slot ?? ($links ? (trim(preg_replace('/[^a-z0-9]+/', '-', strtolower(substr((string) (self::attr($e['attrs'], 'aria-label') ?: self::attr($e['attrs'], 'id')), 0, 40))), '-') ?: 'navigation') : null), 'links' => $links, 'attrs' => $e['attrs'],
|
||||
'end' => $e['end'], 'close' => $e['close'], 'insertAt' => $e['start'] + 1 + strlen($e['name']),
|
||||
];
|
||||
}
|
||||
return $navs;
|
||||
}
|
||||
|
||||
/** Tokenise a page and gather everything analyse() and apply() need. */
|
||||
private static function scan(string $html, string $class, bool $withRegions): array
|
||||
{
|
||||
$els = self::elements($html);
|
||||
$existing = 0;
|
||||
foreach ($els as $k => $e) {
|
||||
$els[$k]['editable'] = self::hasClassToken($e['attrs'], $class) || ($e['parent'] >= 0 && $els[$e['parent']]['editable']);
|
||||
$existing += self::hasClassToken($e['attrs'], $class) ? 1 : 0;
|
||||
}
|
||||
|
||||
$regions = ($withRegions && $existing === 0) ? self::chooseRegions($html, $els) : [];
|
||||
$inChosen = static function (int $k) use ($els, $regions): bool {
|
||||
for ($i = $k; $i >= 0; $i = $els[$i]['parent']) {
|
||||
if (in_array($i, $regions, true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Containers that already hold a real <img> are not placeholders.
|
||||
$hasImg = [];
|
||||
foreach ($els as $e) {
|
||||
for ($p = $e['name'] === 'img' ? $e['parent'] : -1; $p >= 0; $p = $els[$p]['parent']) {
|
||||
$hasImg[$p] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$images = [];
|
||||
$refs = [];
|
||||
$meta = [];
|
||||
$title = null;
|
||||
$h1 = '';
|
||||
$firstParagraph = '';
|
||||
foreach ($els as $k => $e) {
|
||||
$name = $e['name'];
|
||||
if ($name === 'img') {
|
||||
$src = self::attr($e['attrs'], 'src');
|
||||
$images[] = [
|
||||
'src' => (string) $src,
|
||||
'hasAlt' => self::attr($e['attrs'], 'alt') !== null,
|
||||
'tagged' => self::attr($e['attrs'], 'data-neo-image') !== null,
|
||||
'inRegion' => $e['editable'] || $inChosen($k),
|
||||
'taggable' => $src !== null && $src !== '' && !str_starts_with(strtolower(trim($src)), 'data:')
|
||||
&& !(self::attr($e['attrs'], 'width') === '1' && self::attr($e['attrs'], 'height') === '1'),
|
||||
'insertAt' => $e['start'] + 1 + strlen($name),
|
||||
'skip' => $e['skip'],
|
||||
];
|
||||
if ($src) {
|
||||
$refs[] = ['type' => 'img', 'url' => $src];
|
||||
}
|
||||
} elseif (strtolower((string) self::attr($e['attrs'], 'role')) === 'img' && !in_array($name, ['svg'], true)
|
||||
&& self::attr($e['attrs'], 'aria-hidden') !== 'true' && empty($hasImg[$k])) {
|
||||
// A styled box standing in for a photo (for example a "Your photo here" block).
|
||||
$images[] = [
|
||||
'src' => '', 'hasAlt' => true, 'placeholder' => true,
|
||||
'label' => (string) self::attr($e['attrs'], 'aria-label'),
|
||||
'tagged' => self::attr($e['attrs'], 'data-neo-image') !== null,
|
||||
'inRegion' => $e['editable'] || $inChosen($k),
|
||||
'taggable' => true,
|
||||
'insertAt' => $e['start'] + 1 + strlen($name),
|
||||
'skip' => $e['skip'],
|
||||
];
|
||||
} elseif ($name === 'link' && preg_match('/\bstylesheet\b/i', (string) self::attr($e['attrs'], 'rel')) && ($h = self::attr($e['attrs'], 'href'))) {
|
||||
$refs[] = ['type' => 'link', 'url' => $h];
|
||||
} elseif ($name === 'script' && ($s = self::attr($e['attrs'], 'src'))) {
|
||||
$refs[] = ['type' => 'script', 'url' => $s];
|
||||
} elseif ($name === 'meta') {
|
||||
$key = self::attr($e['attrs'], 'name') ?? self::attr($e['attrs'], 'property');
|
||||
if ($key !== null && trim((string) self::attr($e['attrs'], 'content')) !== '') {
|
||||
$meta[strtolower($key)] = trim(html_entity_decode((string) self::attr($e['attrs'], 'content'), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
|
||||
}
|
||||
} elseif ($name === 'title' && $title === null) {
|
||||
$title = self::text($html, $e);
|
||||
} elseif ($name === 'h1' && $h1 === '') {
|
||||
$h1 = self::text($html, $e);
|
||||
} elseif ($name === 'p' && $firstParagraph === '' && !$e['skip'] && strlen($t = self::text($html, $e)) >= 20) {
|
||||
$firstParagraph = $t;
|
||||
}
|
||||
}
|
||||
$canonical = false;
|
||||
foreach ($els as $e) {
|
||||
if ($e['name'] === 'link' && preg_match('/\bcanonical\b/i', (string) self::attr($e['attrs'], 'rel'))) {
|
||||
$canonical = true;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'navs' => self::navList($html, $els),
|
||||
'els' => $els, 'existing' => $existing, 'regions' => $regions, 'images' => $images, 'refs' => $refs,
|
||||
'title' => (string) $title, 'h1' => $h1, 'paragraph' => $firstParagraph, 'metaText' => $meta,
|
||||
'seo' => [
|
||||
'title' => trim((string) $title) !== '', 'description' => isset($meta['description']), 'canonical' => $canonical,
|
||||
'ogTitle' => isset($meta['og:title']), 'ogDescription' => isset($meta['og:description']),
|
||||
'ogImage' => isset($meta['og:image']), 'robots' => isset($meta['robots']),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/** Flat list of elements with source offsets, parent index, skipped-subtree and close-position data. */
|
||||
private static function elements(string $html): array
|
||||
{
|
||||
$pattern = '#<!--.*?-->|<(script|style|textarea|template)\b[^>]*>.*?</\1\s*>|<(/?)([a-zA-Z][a-zA-Z0-9:-]*)((?:"[^"]*"|\'[^\']*\'|[^>"\'])*)>#is';
|
||||
preg_match_all($pattern, $html, $tokens, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
|
||||
|
||||
$els = [];
|
||||
$stack = [];
|
||||
foreach ($tokens as $t) {
|
||||
// Group 3 is the tag name; comments and raw script/style blocks never set it, but script src still matters.
|
||||
if (!isset($t[3]) || $t[3][1] < 0) {
|
||||
if (!empty($t[1][0]) && preg_match('#^<script\b([^>]*)>#i', $t[0][0], $m)) {
|
||||
$els[] = ['name' => 'script', 'attrs' => $m[1], 'start' => $t[0][1], 'end' => $t[0][1] + strlen($m[0]), 'parent' => $stack ? $stack[count($stack) - 1] : -1, 'close' => $t[0][1] + strlen($t[0][0]), 'skip' => true, 'editable' => false];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
$name = strtolower($t[3][0]);
|
||||
if ($t[2][0] === '/') {
|
||||
// Close the nearest open element of this name and anything implicitly left open inside it.
|
||||
for ($j = count($stack) - 1; $j >= 0 && $els[$stack[$j]]['name'] !== $name; $j--);
|
||||
if ($j >= 0) {
|
||||
for ($x = count($stack) - 1; $x >= $j; $x--) {
|
||||
$els[$stack[$x]]['close'] = $t[0][1];
|
||||
}
|
||||
$stack = array_slice($stack, 0, $j);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
$parent = $stack ? $stack[count($stack) - 1] : -1;
|
||||
$attrs = $t[4][0];
|
||||
$skip = in_array($name, self::SKIPPED, true)
|
||||
|| in_array(strtolower((string) self::attr($attrs, 'role')), self::SKIPPED_ROLES, true)
|
||||
|| ($parent >= 0 && $els[$parent]['skip']);
|
||||
$els[] = ['name' => $name, 'attrs' => $attrs, 'start' => $t[0][1], 'end' => $t[0][1] + strlen($t[0][0]), 'parent' => $parent, 'close' => null, 'skip' => $skip, 'editable' => false];
|
||||
if (!in_array($name, self::VOID, true) && !str_ends_with(rtrim($attrs), '/')) {
|
||||
$stack[] = count($els) - 1;
|
||||
}
|
||||
}
|
||||
return $els;
|
||||
}
|
||||
|
||||
/** Pick the containers to make editable: <main>, else top-level articles, else text-bearing sections. */
|
||||
private static function chooseRegions(string $html, array $els): array
|
||||
{
|
||||
$isMain = fn(array $e) => $e['name'] === 'main' || strtolower((string) self::attr($e['attrs'], 'role')) === 'main';
|
||||
foreach ([$isMain, fn(array $e) => $e['name'] === 'article', fn(array $e) => $e['name'] === 'section' && strlen(self::text($html, $e)) >= self::MIN_SECTION_TEXT] as $match) {
|
||||
$chosen = [];
|
||||
foreach ($els as $k => $e) {
|
||||
if ($e['skip'] || !$match($e)) {
|
||||
continue;
|
||||
}
|
||||
for ($p = $e['parent']; $p >= 0; $p = $els[$p]['parent']) {
|
||||
if (in_array($p, $chosen, true)) {
|
||||
continue 2;
|
||||
}
|
||||
}
|
||||
$chosen[] = $k;
|
||||
}
|
||||
if ($chosen) {
|
||||
return $chosen;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Edit that adds the editable class to an element's opening tag, or null when it cannot be done safely. */
|
||||
private static function classEdit(string $html, array $el, string $class): ?array
|
||||
{
|
||||
$tag = substr($html, $el['start'], $el['end'] - $el['start']);
|
||||
if (preg_match('/\sclass\s*=\s*(["\'])(.*?)\1/is', $tag, $m, PREG_OFFSET_CAPTURE)) {
|
||||
$closeQuote = $m[0][1] + strlen($m[0][0]) - 1;
|
||||
return [$el['start'] + $closeQuote, trim($m[2][0]) === '' ? $class : ' ' . $class];
|
||||
}
|
||||
if (preg_match('/\sclass\s*=/i', $tag)) {
|
||||
return null; // Unquoted class value: leave for manual tagging.
|
||||
}
|
||||
return [$el['start'] + 1 + strlen($el['name']), ' class="' . $class . '"'];
|
||||
}
|
||||
|
||||
/** One combined insertion of every missing SEO tag just before </head>, with values derived from the page. */
|
||||
private static function seoEdit(string $html, array $c): ?array
|
||||
{
|
||||
if (!preg_match('#^([ \t]*)</head\s*>#im', $html, $m, PREG_OFFSET_CAPTURE)
|
||||
&& !preg_match('#</head\s*>#i', $html, $m, PREG_OFFSET_CAPTURE)) {
|
||||
return null;
|
||||
}
|
||||
$seo = $c['seo'];
|
||||
$esc = static fn(string $s) => htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
$trim = static fn(string $s, int $n) => mb_strlen($s) > $n ? rtrim(mb_substr($s, 0, $n - 3)) . '...' : $s;
|
||||
$valid = static fn(string $s) => $s !== '' && mb_check_encoding($s, 'UTF-8');
|
||||
|
||||
$title = trim($c['title']) !== '' ? trim($c['title']) : $c['h1'];
|
||||
// Prefer the page's own description over one derived from its first paragraph.
|
||||
$description = (string) ($c['metaText']['description'] ?? '') !== '' ? $c['metaText']['description'] : ($valid($c['paragraph']) ? $trim($c['paragraph'], 155) : '');
|
||||
$image = '';
|
||||
// A content image makes a better social preview than a header logo.
|
||||
foreach ([false, true] as $allowSkipped) {
|
||||
foreach ($c['images'] as $img) {
|
||||
// Open Graph needs an absolute or root-relative address; a page-relative one would be wrong.
|
||||
if ($img['taggable'] && preg_match('#^(/|https?://)#i', $img['src']) && ($allowSkipped || !$img['skip'])) {
|
||||
$image = $img['src'];
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$tags = [];
|
||||
$changes = [];
|
||||
if (!$seo['title'] && $valid($c['h1'])) {
|
||||
$tags[] = '<title>' . $esc($c['h1']) . '</title>';
|
||||
$changes[] = 'Added page title';
|
||||
}
|
||||
if (!$seo['description'] && $description !== '') {
|
||||
$tags[] = '<meta name="description" content="' . $esc($description) . '">';
|
||||
$changes[] = 'Added meta description';
|
||||
}
|
||||
if (!$seo['ogTitle'] && $valid($title)) {
|
||||
$tags[] = '<meta property="og:title" content="' . $esc($title) . '">';
|
||||
$changes[] = 'Added og:title';
|
||||
}
|
||||
if (!$seo['ogDescription'] && $description !== '') {
|
||||
$tags[] = '<meta property="og:description" content="' . $esc($description) . '">';
|
||||
$changes[] = 'Added og:description';
|
||||
}
|
||||
if (!$seo['ogImage'] && $image !== '') {
|
||||
$tags[] = '<meta property="og:image" content="' . $esc($image) . '">';
|
||||
$changes[] = 'Added og:image';
|
||||
}
|
||||
if (!$tags) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$atLineStart = isset($m[1]) && $m[1][0] !== null && $m[0][1] === $m[1][1];
|
||||
$offset = $m[0][1];
|
||||
// Match the page's own line endings so CRLF files do not end up with mixed endings.
|
||||
$nl = str_contains($html, "\r\n") ? "\r\n" : "\n";
|
||||
$body = ' ' . implode($nl . ' ', $tags) . $nl;
|
||||
$text = $atLineStart ? $body : $nl . $body;
|
||||
|
||||
return ['edit' => [$offset, $text], 'changes' => $changes];
|
||||
}
|
||||
|
||||
/** Visible text of an element (tags stripped, entities decoded, whitespace collapsed). */
|
||||
private static function text(string $html, array $el): string
|
||||
{
|
||||
$end = $el['close'] ?? $el['end'];
|
||||
$inner = substr($html, $el['end'], max(0, $end - $el['end']));
|
||||
return trim(preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags($inner), ENT_QUOTES | ENT_HTML5, 'UTF-8')) ?? '');
|
||||
}
|
||||
|
||||
private static function hasClassToken(string $attrs, string $class): bool
|
||||
{
|
||||
return in_array($class, preg_split('/\s+/', (string) self::attr($attrs, 'class')) ?: [], true);
|
||||
}
|
||||
|
||||
/** Return an attribute's value from a tag's attribute string, '' for a bare attribute, null when absent. */
|
||||
private static function attr(string $attrs, string $name): ?string
|
||||
{
|
||||
if (!preg_match('/(?:^|\s)' . preg_quote($name, '/') . '(?:\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s>]+)))?(?=\s|\/|$)/i', $attrs, $m)) {
|
||||
return null;
|
||||
}
|
||||
return (string) ($m[1] ?? '') . (string) ($m[2] ?? '') . (string) ($m[3] ?? '');
|
||||
}
|
||||
}
|
||||
@@ -1,430 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace NeoCMS;
|
||||
|
||||
/**
|
||||
* Account registry: accounts from config.local.php are authoritative and locked; accounts created in the CMS live in data/users.json.
|
||||
*
|
||||
* Only active CMS accounts and config accounts ever reach Authentication, so blocking or deleting an account also ends its sessions.
|
||||
* Password hashes and invite tokens never leave this class except as the hash handed to Authentication.
|
||||
*/
|
||||
final class UserStore
|
||||
{
|
||||
public const ROLES = ['editor', 'administrator'];
|
||||
public const INVITE_LIFETIME = 604800;
|
||||
public const MIN_PASSWORD = 12;
|
||||
public const MAX_PASSWORD = 72;
|
||||
|
||||
/** Plain-language role descriptions shown on the Users screen. */
|
||||
public const ROLE_INFO = [
|
||||
'editor' => 'Edits content and saves drafts, uploads images, publishes and schedules pages, and restores revisions or deleted pages. Cannot change the site structure or manage users.',
|
||||
'administrator' => 'Everything an editor can do, plus creating, duplicating, renaming and deleting pages, templates, navigation menus, shared content, site scan, deleting media, and managing users.',
|
||||
];
|
||||
|
||||
public function __construct(private FileStore $store, private array $config)
|
||||
{
|
||||
}
|
||||
|
||||
/** Build a store from the site configuration. */
|
||||
public static function fromConfig(array $config): self
|
||||
{
|
||||
$directory = (string) ($config['dataDirectory'] ?? (dirname(__DIR__, 2) . '/data'));
|
||||
return new self(new FileStore($directory), $config);
|
||||
}
|
||||
|
||||
/** Arguments for `new Authentication(...)`: config credentials plus active CMS accounts, roles, and session options. */
|
||||
public static function authArgs(array $config): array
|
||||
{
|
||||
[$credentials, $roles] = self::fromConfig($config)->forAuth();
|
||||
return [$credentials, $roles, $config['security'] ?? []];
|
||||
}
|
||||
|
||||
/** Map an arbitrary configured role onto the two supported roles (the old publisher role is an editor). */
|
||||
public static function normaliseRole(mixed $role): string
|
||||
{
|
||||
return $role === 'administrator' ? 'administrator' : 'editor';
|
||||
}
|
||||
|
||||
/** Credentials and roles of every account allowed to sign in. */
|
||||
public function forAuth(): array
|
||||
{
|
||||
$credentials = [];
|
||||
$roles = [];
|
||||
foreach ($this->data()['users'] as $username => $record) {
|
||||
if (($record['status'] ?? '') === 'active' && is_string($record['hash'] ?? null) && $record['hash'] !== '') {
|
||||
$credentials[$username] = $record['hash'];
|
||||
$roles[$username] = self::normaliseRole($record['role'] ?? 'editor');
|
||||
}
|
||||
}
|
||||
foreach ($this->configAccounts() as $username => $account) {
|
||||
$credentials[$username] = $account['hash'];
|
||||
$roles[$username] = $account['role'];
|
||||
}
|
||||
return [$credentials, $roles];
|
||||
}
|
||||
|
||||
/** The name and email shown for an account, or blanks. */
|
||||
public function profile(string $username): array
|
||||
{
|
||||
foreach ($this->accounts() as $account) {
|
||||
if ($account['username'] === $username) {
|
||||
return ['name' => $account['name'], 'email' => $account['email'], 'managed' => $account['managed']];
|
||||
}
|
||||
}
|
||||
return ['name' => '', 'email' => '', 'managed' => false];
|
||||
}
|
||||
|
||||
/** Every account without secrets: config accounts first (locked), then CMS accounts. */
|
||||
public function accounts(): array
|
||||
{
|
||||
$data = $this->data();
|
||||
$list = [];
|
||||
foreach ($this->configAccounts() as $username => $account) {
|
||||
$profile = $data['profiles'][$username] ?? [];
|
||||
$list[] = [
|
||||
'username' => $username, 'name' => (string) ($profile['name'] ?? ''), 'email' => (string) ($profile['email'] ?? ''),
|
||||
'role' => $account['role'], 'status' => 'active', 'managed' => true, 'created' => '', 'inviteExpired' => false,
|
||||
];
|
||||
}
|
||||
foreach ($data['users'] as $username => $record) {
|
||||
if (isset($this->configAccounts()[$username])) {
|
||||
continue;
|
||||
}
|
||||
$list[] = [
|
||||
'username' => $username, 'name' => (string) ($record['name'] ?? ''), 'email' => (string) ($record['email'] ?? ''),
|
||||
'role' => self::normaliseRole($record['role'] ?? 'editor'), 'status' => (string) ($record['status'] ?? 'active'),
|
||||
'managed' => false, 'created' => (string) ($record['created'] ?? ''),
|
||||
'inviteExpired' => ($record['status'] ?? '') === 'invited' && (int) ($record['invite']['expires'] ?? 0) < time(),
|
||||
];
|
||||
}
|
||||
return $list;
|
||||
}
|
||||
|
||||
public function isManaged(string $username): bool
|
||||
{
|
||||
return isset($this->configAccounts()[$username]);
|
||||
}
|
||||
|
||||
/** Set display name and email. Config accounts keep their credentials in config; only this profile overlay is stored. */
|
||||
public function setProfile(string $username, string $name, string $email): void
|
||||
{
|
||||
$name = $this->cleanName($name);
|
||||
$email = $this->cleanEmail($email);
|
||||
$managed = $this->isManaged($username);
|
||||
$this->store->update('users', function (array $data) use ($username, $name, $email, $managed): array {
|
||||
$data = $this->shape($data);
|
||||
$this->assertEmailFree($data, $email, $username);
|
||||
if ($managed) {
|
||||
$data['profiles'][$username] = ['name' => $name, 'email' => $email];
|
||||
} elseif (isset($data['users'][$username])) {
|
||||
$data['users'][$username]['name'] = $name;
|
||||
$data['users'][$username]['email'] = $email;
|
||||
$data['users'][$username]['updated'] = date(DATE_ATOM);
|
||||
} else {
|
||||
throw new \RuntimeException('Account not found');
|
||||
}
|
||||
return $data;
|
||||
});
|
||||
}
|
||||
|
||||
/** Create an active CMS account with a password. */
|
||||
public function create(string $username, string $name, string $email, string $role, string $password): void
|
||||
{
|
||||
$username = trim($username);
|
||||
$name = $this->cleanName($name);
|
||||
$email = $this->cleanEmail($email);
|
||||
$this->assertRole($role);
|
||||
$this->assertPassword($password, $username, $email);
|
||||
$hash = password_hash($password, PASSWORD_DEFAULT);
|
||||
$this->store->update('users', function (array $data) use ($username, $name, $email, $role, $hash): array {
|
||||
$data = $this->shape($data);
|
||||
$this->assertUsernameFree($data, $username);
|
||||
$this->assertEmailFree($data, $email, $username);
|
||||
$now = date(DATE_ATOM);
|
||||
$data['users'][$username] = ['hash' => $hash, 'role' => $role, 'name' => $name, 'email' => $email, 'status' => 'active', 'created' => $now, 'updated' => $now];
|
||||
return $data;
|
||||
});
|
||||
}
|
||||
|
||||
/** Edit a CMS account; a blank password leaves the current one. Config accounts are refused. */
|
||||
public function update(string $username, string $name, string $email, string $role, string $password = ''): void
|
||||
{
|
||||
$this->assertNotManaged($username);
|
||||
$name = $this->cleanName($name);
|
||||
$email = $this->cleanEmail($email);
|
||||
$this->assertRole($role);
|
||||
$hash = null;
|
||||
if ($password !== '') {
|
||||
$this->assertPassword($password, $username, $email);
|
||||
$hash = password_hash($password, PASSWORD_DEFAULT);
|
||||
}
|
||||
$this->store->update('users', function (array $data) use ($username, $name, $email, $role, $hash): array {
|
||||
$data = $this->shape($data);
|
||||
$record = $data['users'][$username] ?? throw new \RuntimeException('Account not found');
|
||||
$this->assertEmailFree($data, $email, $username);
|
||||
$record['name'] = $name;
|
||||
$record['email'] = $email;
|
||||
$record['role'] = $role;
|
||||
if ($hash !== null) {
|
||||
$record['hash'] = $hash;
|
||||
}
|
||||
$record['updated'] = date(DATE_ATOM);
|
||||
$data['users'][$username] = $record;
|
||||
return $data;
|
||||
});
|
||||
}
|
||||
|
||||
/** Change a CMS account's own password. Returns the new hash so the caller can refresh the session. */
|
||||
public function setPassword(string $username, string $password): string
|
||||
{
|
||||
$this->assertNotManaged($username, 'This password is managed in config.local.php');
|
||||
$profile = $this->profile($username);
|
||||
$this->assertPassword($password, $username, $profile['email']);
|
||||
$hash = password_hash($password, PASSWORD_DEFAULT);
|
||||
$this->store->update('users', function (array $data) use ($username, $hash): array {
|
||||
$data = $this->shape($data);
|
||||
if (!isset($data['users'][$username])) {
|
||||
throw new \RuntimeException('Account not found');
|
||||
}
|
||||
$data['users'][$username]['hash'] = $hash;
|
||||
$data['users'][$username]['updated'] = date(DATE_ATOM);
|
||||
return $data;
|
||||
});
|
||||
return $hash;
|
||||
}
|
||||
|
||||
/** Upgrade a stored hash after a successful login when PHP's default algorithm or cost has changed. */
|
||||
public function rehashIfNeeded(string $username, string $password): void
|
||||
{
|
||||
$record = $this->data()['users'][$username] ?? null;
|
||||
if ($record && !$this->isManaged($username) && is_string($record['hash'] ?? null) && $record['hash'] !== '' && password_needs_rehash($record['hash'], PASSWORD_DEFAULT)) {
|
||||
$hash = password_hash($password, PASSWORD_DEFAULT);
|
||||
$this->store->update('users', function (array $data) use ($username, $hash): array {
|
||||
$data = $this->shape($data);
|
||||
if (isset($data['users'][$username])) {
|
||||
$data['users'][$username]['hash'] = $hash;
|
||||
}
|
||||
return $data;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function setBlocked(string $username, bool $blocked): void
|
||||
{
|
||||
$this->assertNotManaged($username);
|
||||
$this->store->update('users', function (array $data) use ($username, $blocked): array {
|
||||
$data = $this->shape($data);
|
||||
$record = $data['users'][$username] ?? throw new \RuntimeException('Account not found');
|
||||
if ($blocked) {
|
||||
$record['status'] = 'blocked';
|
||||
} elseif (($record['status'] ?? '') === 'blocked') {
|
||||
$record['status'] = ($record['hash'] ?? '') === '' ? 'invited' : 'active';
|
||||
}
|
||||
$record['updated'] = date(DATE_ATOM);
|
||||
$data['users'][$username] = $record;
|
||||
return $data;
|
||||
});
|
||||
}
|
||||
|
||||
public function delete(string $username): void
|
||||
{
|
||||
$this->assertNotManaged($username);
|
||||
$this->store->update('users', function (array $data) use ($username): array {
|
||||
$data = $this->shape($data);
|
||||
if (!isset($data['users'][$username])) {
|
||||
throw new \RuntimeException('Account not found');
|
||||
}
|
||||
unset($data['users'][$username]);
|
||||
return $data;
|
||||
});
|
||||
}
|
||||
|
||||
/** Create (or re-issue) a pending invitation. Returns the username and the one-time token, which is stored only as a hash. */
|
||||
public function invite(string $name, string $email, string $role, string $existing = ''): array
|
||||
{
|
||||
$name = $this->cleanName($name);
|
||||
$email = $this->cleanEmail($email);
|
||||
if ($email === '') {
|
||||
throw new \RuntimeException('An email address is required to invite someone');
|
||||
}
|
||||
$this->assertRole($role);
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$tokenHash = hash('sha256', $token);
|
||||
$username = '';
|
||||
$this->store->update('users', function (array $data) use ($name, $email, $role, $existing, $tokenHash, &$username): array {
|
||||
$data = $this->shape($data);
|
||||
$now = date(DATE_ATOM);
|
||||
$invite = ['token' => $tokenHash, 'expires' => time() + self::INVITE_LIFETIME];
|
||||
if ($existing !== '') {
|
||||
$record = $data['users'][$existing] ?? null;
|
||||
if (!$record || ($record['status'] ?? '') !== 'invited') {
|
||||
throw new \RuntimeException('Only a pending invitation can be re-issued');
|
||||
}
|
||||
$this->assertEmailFree($data, $email, $existing);
|
||||
$username = $existing;
|
||||
$data['users'][$username] = array_merge($record, ['name' => $name, 'email' => $email, 'role' => $role, 'invite' => $invite, 'updated' => $now]);
|
||||
return $data;
|
||||
}
|
||||
$this->assertEmailFree($data, $email, '');
|
||||
$username = $this->generateUsername($data, $email);
|
||||
$data['users'][$username] = ['hash' => '', 'role' => $role, 'name' => $name, 'email' => $email, 'status' => 'invited', 'invite' => $invite, 'created' => $now, 'updated' => $now];
|
||||
return $data;
|
||||
});
|
||||
return [$username, $token];
|
||||
}
|
||||
|
||||
/** Redeem an invitation: the token is single use and expires. Returns the username. Every failure gives the same message. */
|
||||
public function acceptInvite(string $token, string $password): string
|
||||
{
|
||||
$failure = 'This invitation is invalid or has expired';
|
||||
$wanted = hash('sha256', $token);
|
||||
$accepted = null;
|
||||
$hash = null;
|
||||
$data = $this->data();
|
||||
foreach ($data['users'] as $username => $record) {
|
||||
if (($record['status'] ?? '') === 'invited' && is_string($record['invite']['token'] ?? null)
|
||||
&& hash_equals($record['invite']['token'], $wanted) && (int) ($record['invite']['expires'] ?? 0) >= time()) {
|
||||
$accepted = $username;
|
||||
}
|
||||
}
|
||||
if ($accepted === null || $token === '') {
|
||||
throw new \RuntimeException($failure);
|
||||
}
|
||||
$this->assertPassword($password, $accepted, (string) ($data['users'][$accepted]['email'] ?? ''));
|
||||
$hash = password_hash($password, PASSWORD_DEFAULT);
|
||||
$this->store->update('users', function (array $data) use ($accepted, $wanted, $hash, $failure): array {
|
||||
$data = $this->shape($data);
|
||||
$record = $data['users'][$accepted] ?? null;
|
||||
if (!$record || ($record['status'] ?? '') !== 'invited' || !hash_equals((string) ($record['invite']['token'] ?? ''), $wanted)) {
|
||||
throw new \RuntimeException($failure);
|
||||
}
|
||||
unset($record['invite']);
|
||||
$record['hash'] = $hash;
|
||||
$record['status'] = 'active';
|
||||
$record['updated'] = date(DATE_ATOM);
|
||||
$data['users'][$accepted] = $record;
|
||||
return $data;
|
||||
});
|
||||
return $accepted;
|
||||
}
|
||||
|
||||
/** Password policy shared by every path that sets a password. */
|
||||
public function assertPassword(string $password, string $username = '', string $email = ''): void
|
||||
{
|
||||
$length = strlen($password);
|
||||
if ($length < self::MIN_PASSWORD) {
|
||||
throw new \RuntimeException('Passwords must be at least ' . self::MIN_PASSWORD . ' characters');
|
||||
}
|
||||
if ($length > self::MAX_PASSWORD) {
|
||||
throw new \RuntimeException('Passwords can be at most ' . self::MAX_PASSWORD . ' bytes long');
|
||||
}
|
||||
$lower = strtolower($password);
|
||||
if (($username !== '' && $lower === strtolower($username)) || ($email !== '' && $lower === strtolower($email))) {
|
||||
throw new \RuntimeException('The password must differ from the login name and email address');
|
||||
}
|
||||
}
|
||||
|
||||
private function configAccounts(): array
|
||||
{
|
||||
$accounts = [];
|
||||
foreach ((array) ($this->config['authentication'] ?? []) as $username => $hash) {
|
||||
if (is_string($username) && is_string($hash) && $hash !== '') {
|
||||
$accounts[$username] = ['hash' => $hash, 'role' => self::normaliseRole(($this->config['roles'] ?? [])[$username] ?? 'editor')];
|
||||
}
|
||||
}
|
||||
return $accounts;
|
||||
}
|
||||
|
||||
private function data(): array
|
||||
{
|
||||
return $this->shape($this->store->read('users'));
|
||||
}
|
||||
|
||||
private function shape(array $data): array
|
||||
{
|
||||
$data['users'] = is_array($data['users'] ?? null) ? $data['users'] : [];
|
||||
$data['profiles'] = is_array($data['profiles'] ?? null) ? $data['profiles'] : [];
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function cleanName(string $name): string
|
||||
{
|
||||
$name = trim(preg_replace('/\s+/u', ' ', $name) ?? '');
|
||||
if ($name === '' || mb_strlen($name) > 80 || preg_match('/[\x00-\x1f\x7f<>]/u', $name)) {
|
||||
throw new \RuntimeException('Enter a display name of up to 80 characters');
|
||||
}
|
||||
return $name;
|
||||
}
|
||||
|
||||
private function cleanEmail(string $email): string
|
||||
{
|
||||
$email = strtolower(trim($email));
|
||||
if ($email !== '' && (strlen($email) > 254 || !filter_var($email, FILTER_VALIDATE_EMAIL))) {
|
||||
throw new \RuntimeException('Enter a valid email address');
|
||||
}
|
||||
return $email;
|
||||
}
|
||||
|
||||
private function assertRole(string $role): void
|
||||
{
|
||||
if (!in_array($role, self::ROLES, true)) {
|
||||
throw new \RuntimeException('Unknown role');
|
||||
}
|
||||
}
|
||||
|
||||
private function assertNotManaged(string $username, string $message = 'This account is managed in config.local.php and cannot be changed here'): void
|
||||
{
|
||||
if ($this->isManaged($username)) {
|
||||
throw new \RuntimeException($message);
|
||||
}
|
||||
}
|
||||
|
||||
private function assertUsernameFree(array $data, string $username): void
|
||||
{
|
||||
if (!preg_match('/^[a-zA-Z0-9._-]{3,32}$/', $username)) {
|
||||
throw new \RuntimeException('Login names are 3-32 letters, numbers, dots, dashes or underscores');
|
||||
}
|
||||
foreach ([$data['users'], $this->configAccounts()] as $existing) {
|
||||
foreach (array_keys($existing) as $known) {
|
||||
if (strcasecmp((string) $known, $username) === 0) {
|
||||
throw new \RuntimeException('That login name is already in use');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Emails are unique across config overlays and CMS accounts, ignoring case. */
|
||||
private function assertEmailFree(array $data, string $email, string $exceptUsername): void
|
||||
{
|
||||
if ($email === '') {
|
||||
return;
|
||||
}
|
||||
$taken = [];
|
||||
foreach ($data['profiles'] as $username => $profile) {
|
||||
$taken[$username] = strtolower((string) ($profile['email'] ?? ''));
|
||||
}
|
||||
foreach ($data['users'] as $username => $record) {
|
||||
$taken[$username] = strtolower((string) ($record['email'] ?? ''));
|
||||
}
|
||||
foreach ($taken as $username => $known) {
|
||||
if ($known === $email && (string) $username !== $exceptUsername) {
|
||||
throw new \RuntimeException('That email address is already in use');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function generateUsername(array $data, string $email): string
|
||||
{
|
||||
$base = strtolower(preg_replace('/[^a-zA-Z0-9._-]/', '', explode('@', $email)[0]) ?? '');
|
||||
$base = substr(str_pad($base, 3, 'x'), 0, 28);
|
||||
for ($i = 0; $i < 1000; $i++) {
|
||||
$candidate = $i === 0 ? $base : $base . $i;
|
||||
try {
|
||||
$this->assertUsernameFree($data, $candidate);
|
||||
return $candidate;
|
||||
} catch (\RuntimeException) {
|
||||
}
|
||||
}
|
||||
throw new \RuntimeException('Unable to generate a login name');
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,26 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en-gb">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<!-- A repeatable-content starter template for listings, teams, services, and similar pages. -->
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>New NeoCMS Listing</title>
|
||||
<meta name="description" content="Describe this listing for visitors and search engines.">
|
||||
<title>Welcome to NeoCMS</title>
|
||||
<link href="/cms/css/welcome.style.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<header class="editable">
|
||||
<h1>New listing</h1>
|
||||
<p>Edit this introduction, then duplicate the example item below as required.</p>
|
||||
<header>
|
||||
<img alt="Application Logo" class="logo" src="/cms/img/welcomeLogo.png">
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- neo-dupe adds clone-before, clone-after, and delete controls in the CMS preview. -->
|
||||
<article class="editable neo-dupe">
|
||||
<h2>Example item</h2>
|
||||
<p>Replace this content or duplicate the complete item.</p>
|
||||
</article>
|
||||
<h1>Welcome to NeoCMS</h1>
|
||||
<p>This is another example template, found in /cms/templates/ - remove me and replace me with your own site
|
||||
templates!</p>
|
||||
<p class="editable">
|
||||
This box is editable!<br/>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
|
||||
Quisque sit amet accumsan tortor. Itaque earum rerum hic tenetur a sapiente delectus.
|
||||
</p>
|
||||
</main>
|
||||
<footer>
|
||||
<p>© 2024 Andy Dixon</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
@@ -1,22 +1,26 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en-gb">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<!-- Replace these metadata values through the CMS SEO dialogue after creating the page. -->
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>New NeoCMS Page</title>
|
||||
<meta name="description" content="Describe this page for visitors and search engines.">
|
||||
<title>Welcome to NeoCMS</title>
|
||||
<link href="/cms/css/welcome.style.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<!-- The default editable class may be changed with editableClass in cms/config.php. -->
|
||||
<main class="editable">
|
||||
<h1>New page</h1>
|
||||
<p>Select this region in NeoCMS to replace the example content.</p>
|
||||
<header>
|
||||
<img alt="Application Logo" class="logo" src="/cms/img/welcomeLogo.png">
|
||||
</header>
|
||||
<main>
|
||||
<h1>Welcome to NeoCMS</h1>
|
||||
<p>This is an example template, found in /cms/templates/ - remove me and replace me with your own site
|
||||
templates!</p>
|
||||
<p class="editable">
|
||||
This box is editable!<br/>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
|
||||
Quisque sit amet accumsan tortor. Itaque earum rerum hic tenetur a sapiente delectus.
|
||||
</p>
|
||||
</main>
|
||||
|
||||
<!-- Shared keys allow one saved block to update every matching page. -->
|
||||
<footer class="editable" data-neo-shared="site-footer">
|
||||
<p>Shared site footer</p>
|
||||
<footer>
|
||||
<p>© 2024 Andy Dixon</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,77 +0,0 @@
|
||||
# TinyMCE
|
||||
|
||||
The world's #1 open source rich text editor.
|
||||
|
||||
**Using an old version of TinyMCE?** We recommend you to upgrade to TinyMCE 8 to continue receiving security updates.
|
||||
|
||||
Used and trusted by millions of developers, TinyMCE is the world’s most customizable, scalable, and flexible rich text editor. We’ve helped launch the likes of Atlassian, Medium, Evernote (and lots more that we can’t tell you), by empowering them to create exceptional content and experiences for their users.
|
||||
|
||||
With more than 350M+ downloads every year, we’re also one of the most trusted enterprise-grade open source HTML editors on the internet. There’s currently more than 100M+ products worldwide, powered by Tiny. As a high powered WYSIWYG editor, TinyMCE is built to scale, designed to innovate, and thrives on delivering results to difficult edge-cases.
|
||||
|
||||
You can access a [full featured demo of TinyMCE](https://www.tiny.cloud/docs/tinymce/8/premium-full-featured/) in the docs on the TinyMCE website.
|
||||
|
||||
<p align="center">
|
||||
<img alt="Screenshot of the TinyMCE Editor" src="https://www.tiny.cloud/storage/github-readme-images/tinymce-editor-6x.png"\>
|
||||
</p>
|
||||
|
||||
## Get started with TinyMCE
|
||||
|
||||
Getting started with the TinyMCE rich text editor is easy, and for simple configurations can be done in less than 5 minutes.
|
||||
|
||||
[TinyMCE Cloud Deployment Quick Start Guide](https://www.tiny.cloud/docs/tinymce/8/cloud-quick-start/)
|
||||
|
||||
[TinyMCE Self-hosted Deployment Guide](https://www.tiny.cloud/docs/tinymce/8/npm-projects/)
|
||||
|
||||
TinyMCE provides a range of configuration options that allow you to integrate it into your application. Start customizing with a [basic setup](https://www.tiny.cloud/docs/tinymce/8/basic-setup/).
|
||||
|
||||
Configure it for one of three modes of editing:
|
||||
|
||||
- [TinyMCE classic editing mode](https://www.tiny.cloud/docs/tinymce/8/use-tinymce-classic/).
|
||||
- [TinyMCE inline editing mode](https://www.tiny.cloud/docs/tinymce/8/use-tinymce-inline/).
|
||||
- [TinyMCE distraction-free editing mode](https://www.tiny.cloud/docs/tinymce/8/use-tinymce-distraction-free/).
|
||||
|
||||
## Features
|
||||
|
||||
### Integration
|
||||
|
||||
TinyMCE is easily integrated into your projects with the help of components such as:
|
||||
|
||||
- [tinymce-react](https://github.com/tinymce/tinymce-react)
|
||||
- [tinymce-vue](https://github.com/tinymce/tinymce-vue)
|
||||
- [tinymce-angular](https://github.com/tinymce/tinymce-angular)
|
||||
|
||||
With over 29 integrations, and 400+ APIs, see the TinyMCE docs for a full list of editor [integrations](https://www.tiny.cloud/docs/tinymce/8/integrations/).
|
||||
|
||||
### Customization
|
||||
|
||||
It is easy to [configure the UI](https://www.tiny.cloud/docs/tinymce/8/customize-ui/) of your rich text editor to match the design of your site, product or application. Due to its flexibility, you can [configure the editor](https://www.tiny.cloud/docs/tinymce/8/basic-setup/) with as much or as little functionality as you like, depending on your requirements.
|
||||
|
||||
With [50+ powerful plugins available](https://www.tiny.cloud/tinymce/features/), and content editable as the basis of TinyMCE, adding additional functionality is as simple as including a single line of code.
|
||||
|
||||
Realizing the full power of most plugins requires only a few lines more.
|
||||
|
||||
### Extensibility
|
||||
|
||||
Sometimes your editor requirements can be quite unique, and you need the freedom and flexibility to innovate. Thanks to TinyMCE being open source, you can view the source code and develop your own extensions for custom functionality to meet your own requirements.
|
||||
|
||||
The TinyMCE [API](https://www.tiny.cloud/docs/tinymce/8/apis/tinymce.root/) is exposed to make it easier for you to write custom functionality that fits within the existing framework of TinyMCE [UI components](https://www.tiny.cloud/docs/tinymce/8/custom-ui-components/).
|
||||
|
||||
### Extended Features and Support
|
||||
|
||||
For the professional software teams that require more in-depth efficiency, compliance or collaborative features built to enterprise-grade standards, please [get in touch with our team](https://www.tiny.cloud/contact/).
|
||||
|
||||
Tiny also offers dedicated SLAs and support for professional development teams.
|
||||
|
||||
## Compiling and contributing
|
||||
|
||||
In 2019 the decision was made to transition our codebase to a monorepo. For information on compiling and contributing, see: [contribution guidelines](https://github.com/tinymce/tinymce/blob/master/CONTRIBUTING.md).
|
||||
|
||||
As an open source product, we encourage and support the active development of our software.
|
||||
|
||||
## Want more information?
|
||||
|
||||
Visit the [TinyMCE website](https://tiny.cloud/) and check out the [TinyMCE documentation](https://www.tiny.cloud/docs/).
|
||||
|
||||
## License
|
||||
|
||||
License terms can be found in the license.md file.
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"name": "tinymce",
|
||||
"description": "Web based JavaScript HTML WYSIWYG editor control.",
|
||||
"license": "SEE LICENSE IN license.md",
|
||||
"keywords": [
|
||||
"wysiwyg",
|
||||
"tinymce",
|
||||
"richtext",
|
||||
"javascript",
|
||||
"html",
|
||||
"text",
|
||||
"rich editor",
|
||||
"rich text editor",
|
||||
"rte",
|
||||
"rich text",
|
||||
"contenteditable",
|
||||
"editing"
|
||||
],
|
||||
"homepage": "https://www.tiny.cloud/",
|
||||
"ignore": [
|
||||
"README.md",
|
||||
"composer.json",
|
||||
"package.json",
|
||||
".npmignore",
|
||||
"CHANGELOG.md"
|
||||
]
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
{
|
||||
"name": "tinymce/tinymce",
|
||||
"version": "8.5.1",
|
||||
"description": "Web based JavaScript HTML WYSIWYG editor control.",
|
||||
"license": [
|
||||
"SEE LICENSE IN license.md"
|
||||
],
|
||||
"keywords": [
|
||||
"wysiwyg",
|
||||
"tinymce",
|
||||
"richtext",
|
||||
"javascript",
|
||||
"html",
|
||||
"text",
|
||||
"rich editor",
|
||||
"rich text editor",
|
||||
"rte",
|
||||
"rich text",
|
||||
"contenteditable",
|
||||
"editing"
|
||||
],
|
||||
"homepage": "https://www.tiny.cloud/",
|
||||
"type": "component",
|
||||
"extra": {
|
||||
"component": {
|
||||
"scripts": [
|
||||
"tinymce.js",
|
||||
"plugins/*/plugin.js",
|
||||
"themes/*/theme.js",
|
||||
"models/*/model.js",
|
||||
"icons/*/icons.js"
|
||||
],
|
||||
"files": [
|
||||
"tinymce.min.js",
|
||||
"plugins/*/plugin.min.js",
|
||||
"themes/*/theme.min.js",
|
||||
"models/*/model.min.js",
|
||||
"skins/**",
|
||||
"icons/*/icons.min.js"
|
||||
]
|
||||
}
|
||||
},
|
||||
"archive": {
|
||||
"exclude": [
|
||||
"README.md",
|
||||
"bower.js",
|
||||
"package.json",
|
||||
".npmignore",
|
||||
"CHANGELOG.md"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
tinymce.IconManager.add('default', {
|
||||
icons: {
|
||||
'accessibility-check': '<svg width="24" height="24"><path d="M12 2a2 2 0 0 1 2 2 2 2 0 0 1-2 2 2 2 0 0 1-2-2c0-1.1.9-2 2-2Zm8 7h-5v12c0 .6-.4 1-1 1a1 1 0 0 1-1-1v-5c0-.6-.4-1-1-1a1 1 0 0 0-1 1v5c0 .6-.4 1-1 1a1 1 0 0 1-1-1V9H4a1 1 0 1 1 0-2h16c.6 0 1 .4 1 1s-.4 1-1 1Z" fill-rule="nonzero"/></svg>',
|
||||
'accordion-toggle': '<svg width="24" height="24"><path fill-rule="evenodd" clip-rule="evenodd" d="M12 15c0-.6.4-1 1-1h6c.6 0 1 .4 1 1s-.4 1-1 1h-6a1 1 0 0 1-1-1Z"/><path opacity=".2" fill-rule="evenodd" clip-rule="evenodd" d="M4 15c0-.6.4-1 1-1h6c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 0 1-1-1Z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M12 19c0-.6.4-1 1-1h6c.6 0 1 .4 1 1s-.4 1-1 1h-6a1 1 0 0 1-1-1Z"/><path opacity=".2" fill-rule="evenodd" clip-rule="evenodd" d="M4 19c0-.6.4-1 1-1h6c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 0 1-1-1Z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M12.3 7.3a1 1 0 0 1 1.4 0L16 9.6l2.3-2.3a1 1 0 1 1 1.4 1.4L16 12.4l-3.7-3.7a1 1 0 0 1 0-1.4ZM4.3 11.7a1 1 0 0 1 0-1.4L6.6 8 4.3 5.7a1 1 0 0 1 1.4-1.4L9.4 8l-3.7 3.7a1 1 0 0 1-1.4 0Z"/></svg>',
|
||||
'accordion': '<svg width="24" height="24"><rect x="12" y="7" width="10" height="2" rx="1"/><rect x="12" y="11" width="10" height="2" rx="1"/><rect x="12" y="15" width="6" height="2" rx="1"/><path fill-rule="evenodd" clip-rule="evenodd" d="M2.3 7.3a1 1 0 0 1 1.4 0L6 9.6l2.3-2.3a1 1 0 0 1 1.4 1.4L6 12.4 2.3 8.7a1 1 0 0 1 0-1.4Z"/></svg>',
|
||||
'action-next': '<svg width="24" height="24"><path fill-rule="nonzero" d="M5.7 7.3a1 1 0 0 0-1.4 1.4l7.7 7.7 7.7-7.7a1 1 0 1 0-1.4-1.4L12 13.6 5.7 7.3Z"/></svg>',
|
||||
'action-prev': '<svg width="24" height="24"><path fill-rule="nonzero" d="M18.3 15.7a1 1 0 0 0 1.4-1.4L12 6.6l-7.7 7.7a1 1 0 0 0 1.4 1.4L12 9.4l6.3 6.3Z"/></svg>',
|
||||
'add-file': '<svg height="24" width="24"><path d="M2 7h2V4h3V2H4a2 2 0 0 0-2 2zm20 0h-2V4h-3V2h3a2 2 0 0 1 2 2zm0 2h-2v6h2zm0 8h-2v3h-3v2h3a2 2 0 0 0 2-2zM2 9h2v6H2zm0 8h2v3h3v2H4a2 2 0 0 1-2-2zm7 5v-2h6v2zm6-20v2H9V2zM6 17h12l-4-5-3 3.8-2-2.6z"/><path d="M2 7h2V4h3V2H4a2 2 0 0 0-2 2zm20 0h-2V4h-3V2h3a2 2 0 0 1 2 2zm0 2h-2v6h2zm0 8h-2v3h-3v2h3a2 2 0 0 0 2-2zM2 9h2v6H2zm0 8h2v3h3v2H4a2 2 0 0 1-2-2zm7 5v-2h6v2zm6-20v2H9V2zM6 17h12l-4-5-3 3.8-2-2.6z"/><path d="M6 17h12l-4-5-3 3.8-2-2.6z"/><path d="M6 17h12l-4-5-3 3.8-2-2.6z"/><path d="M6 17h12l-4-5-3 3.8-2-2.6z"/><path d="M6 17h12l-4-5-3 3.8-2-2.6z"/></svg>',
|
||||
'addtag': '<svg width="24" height="24"><path fill-rule="evenodd" clip-rule="evenodd" d="M15 5a2 2 0 0 1 1.6.8L21 12l-4.4 6.2a2 2 0 0 1-1.6.8h-3v-2h3l3.5-5L15 7H5v3H3V7c0-1.1.9-2 2-2h10Z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M6 12a1 1 0 0 0-1 1v2H3a1 1 0 1 0 0 2h2v2a1 1 0 1 0 2 0v-2h2a1 1 0 1 0 0-2H7v-2c0-.6-.4-1-1-1Z"/></svg>',
|
||||
'adjust-length': '<svg width="24" height="24"><path d="M14 7c0 .9.5 1.8 1.5 2.2h.2c.5.3.9.7 1.1 1.3v.1l.1.1v.1h.1V14h-2V9h-2v5h-2V9H9v5H7V9H4v9h16v-6.2a2.3 2.3 0 0 0 1-1l.1-.1v-.2h.1c.2-.5.4-.8.8-1V18c0 .6-.2 1-.6 1.4a2 2 0 0 1-1.4.6H4a2 2 0 0 1-1.4-.6A2 2 0 0 1 2 18V9c0-.6.2-1 .6-1.4A2 2 0 0 1 4 7h10Z"/><path d="M18.7 4.2a.3.3 0 0 1 .6 0 4.1 4.1 0 0 0 2.5 2.5.3.3 0 0 1 0 .6l-.2.1a4.1 4.1 0 0 0-2.3 2.4c-.1.3-.5.3-.6 0a4.1 4.1 0 0 0-.6-1 4.2 4.2 0 0 0-1.7-1.4h-.2a.3.3 0 0 1 0-.7 4.1 4.1 0 0 0 2.5-2.5Z"/></svg>',
|
||||
'adjustments': '<svg width="24" height="24"><path d="M16 11a3 3 0 1 1 2.8-4H21v2h-2.2a3 3 0 0 1-2.8 2Zm0-2a1 1 0 1 1 0-2 1 1 0 0 1 0 2ZM3 9h8V7H3v2Zm5 10a3 3 0 1 0-2.8-4H3v2h2.2A3 3 0 0 0 8 19Zm0-2a1 1 0 1 0 0-2 1 1 0 0 0 0 2Zm13 0h-8v-2h8v2Z"/></svg>',
|
||||
'ai-assistant': '<svg height="24" width="24"><path d="m15.4 2.6-1.9 1.9h-4a5.5 5.5 0 1 0 0 11H13V18c2.6-2 6.8-4 7-7.6V10l1.8-1.8.2 1.8c0 5.7-7 9-11 12v-4.5H9.5a7.5 7.5 0 0 1 0-15h6z"/><path d="M8.5 8.5a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3zm7 0a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3zm3.2-6.3a.3.3 0 0 1 .6 0 4.1 4.1 0 0 0 2.5 2.5c.3 0 .3.5 0 .6l-.2.1a4.1 4.1 0 0 0-2.3 2.4c-.1.3-.5.3-.6 0a4.1 4.1 0 0 0-2.3-2.4h-.2a.3.3 0 0 1 0-.7 4.1 4.1 0 0 0 2.5-2.5z"/></svg>',
|
||||
'ai-chat-response': '<svg width="24" height="24"><path d="M9 9a7.1 7.1 0 0 0 6 6 7.1 7.1 0 0 0-6 6 7.1 7.1 0 0 0-6-6 7.1 7.1 0 0 0 6-6Zm7.5-5A4.6 4.6 0 0 0 21 8.5a4.6 4.6 0 0 0-4.5 4.5A4.6 4.6 0 0 0 12 8.5 4.6 4.6 0 0 0 16.5 4ZM6 3a5 5 0 0 0 3 3 5 5 0 0 0-3 3 5 5 0 0 0-3-3 5 5 0 0 0 3-3Z"/></svg>',
|
||||
'ai-model': '<svg height="24" width="24"><path d="M12.1 3c.5 0 .9.5.9 1v3h5a2 2 0 0 1 2 2v4h2v2h-2v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-4H2v-2h2V9c0-1.1.9-2 2-2h5V5H7.4V3H12zM6 19h12V9H6zm3-7c.6 0 1 .4 1 1v2a1 1 0 1 1-2 0v-2c0-.6.4-1 1-1zm6 0c.6 0 1 .4 1 1v2a1 1 0 1 1-2 0v-2c0-.6.4-1 1-1z"/></svg>',
|
||||
'ai-prompt': '<svg width="24" height="24"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M15 6.7a1 1 0 0 0-1.4 0l-9.9 10a1 1 0 0 0 0 1.3l2.1 2.1c.4.4 1 .4 1.4 0l10-9.9c.3-.3.3-1 0-1.4l-2.2-2Zm1.4 2.8-2-2-3 2.7 2.2 2.2 2.8-2.9Z"/><path d="m18.5 7.3-.7-1.5-1.5-.8 1.5-.7.7-1.5.7 1.5 1.5.7-1.5.8-.7 1.5ZM18.5 16.5l-.7-1.6-1.5-.7 1.5-.7.7-1.6.7 1.6 1.5.7-1.5.7-.7 1.6ZM9.7 7.3 9 5.8 7.5 5 9 4.3l.7-1.5.7 1.5L12 5l-1.5.8-.7 1.5Z"/></g><defs><clipPath id="a"><path d="M0 0h24v24H0z"/></clipPath></defs></svg>',
|
||||
'ai-review': '<svg height="24" width="24"><path d="M15.4 12.6zM15.3 12.6h.1l.3-.2a.5.5 0 0 0 0-.8 5.5 5.5 0 0 1-3.3-3.3.5.5 0 0 0-.8 0 5.5 5.5 0 0 1-3.3 3.3.5.5 0 0 0 0 .8l.3.1a5.5 5.5 0 0 1 2.7 2.6l.1.2.2.4a.5.5 0 0 0 .8 0l.1-.2v-.2l.2-.2a5.5 5.5 0 0 1 2.6-2.5zM12 11a7.3 7.3 0 0 1-1 .9l1 1a7.3 7.3 0 0 1 1-1 7.3 7.3 0 0 1-1-1z" fill-rule="evenodd"/><path d="M12 4a10 10 0 0 1 1.5.1l-.3 2A7.6 7.6 0 0 0 12 6c-1.7 0-3.2.5-4.5 1.5-1.4 1-2.6 2.4-3.4 4a1 1 0 0 0-.1.5 1 1 0 0 0 0 .3l.1.1c.8 1.7 2 3 3.4 4.1a7.5 7.5 0 0 0 5.3 1.5c1.4-.2 2.6-.7 3.7-1.5 1.2-.9 2.2-2 3-3.4a13.5 13.5 0 0 0 .4-.7 1 1 0 0 0 .1-.4 1 1 0 0 0-.1-.4 12.5 12.5 0 0 0-.7-1.3l1.6-1.1a13.7 13.7 0 0 1 .9 1.5 3 3 0 0 1 .2 2 3 3 0 0 1-.2.6 15.1 15.1 0 0 1-.4.8c-1 1.6-2.1 3-3.6 4-1.4 1-3 1.7-4.7 1.8a9.9 9.9 0 0 1-1 .1c-2 0-4-.6-5.7-1.9-1.7-1.2-3-2.8-4-4.8a3 3 0 0 1 0-2.6c1-2 2.3-3.6 4-4.8A9.5 9.5 0 0 1 12 4z"/><path d="M16.7 4.2a.3.3 0 0 1 .6 0 4.1 4.1 0 0 0 2.5 2.5.3.3 0 0 1 0 .6l-.2.1a4.1 4.1 0 0 0-2.3 2.4c-.1.3-.5.3-.6 0a4.1 4.1 0 0 0-2.3-2.4h-.2a.3.3 0 0 1 0-.7 4.1 4.1 0 0 0 2.5-2.5z"/></svg>',
|
||||
'ai-translate': '<svg height="24" width="24"><path d="M22 22h-2.1l-1-3h-5l-1 3h-2.1l4.7-12h2zm-7.4-4.8h3.6l-1.8-5.1z" fill-rule="evenodd"/><path d="M8.9 4h5.2s-.8 2.5-2.7 5.7c-.7 1.2-2 2.9-2 2.9l2.4 2.5-1 1.8-2.9-3-4.5 4.6L2 17.1l4.6-4.5-1.6-2C4.6 9.9 4 9 3.7 8h2A15.3 15.3 0 0 0 8 11.1l1.7-2.3c.7-1 1-1.9 1.3-2.8H2V4h4.9V2h2zm9.3-1.8a.3.3 0 0 1 .6 0 4.1 4.1 0 0 0 2.5 2.5.3.3 0 0 1 0 .6l-.2.1a4.1 4.1 0 0 0-2.3 2.4c-.1.3-.5.3-.6 0a4.1 4.1 0 0 0-2.3-2.4h-.2a.3.3 0 0 1 0-.7 4.1 4.1 0 0 0 2.5-2.5z"/></svg>',
|
||||
'ai': '<svg width="24" height="24"><path fill-rule="evenodd" clip-rule="evenodd" d="M5 3a3 3 0 0 0-3 3v12a3 3 0 0 0 3 3h14a3 3 0 0 0 3-3V6a3 3 0 0 0-3-3H5Zm6.8 11.5.5 1.2a68.3 68.3 0 0 0 .7 1.1l.4.1c.3 0 .5 0 .7-.3.2-.1.3-.3.3-.6l-.3-1-2.6-6.2a20.4 20.4 0 0 0-.5-1.3l-.5-.4-.7-.2c-.2 0-.5 0-.6.2-.2 0-.4.2-.5.4l-.3.6-.3.7L5.7 15l-.2.6-.1.4c0 .3 0 .5.3.7l.6.2c.3 0 .5 0 .7-.2l.4-1 .5-1.2h3.9ZM9.8 9l1.5 4h-3l1.5-4Zm5.6-.9v7.6c0 .4 0 .7.2 1l.7.2c.3 0 .6 0 .8-.3l.2-.9V8.1c0-.4 0-.7-.2-.9a1 1 0 0 0-.8-.3c-.2 0-.5.1-.7.3l-.2 1Z"/></svg>',
|
||||
'align-center': '<svg width="24" height="24"><path d="M5 5h14c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 1 1 0-2Zm3 4h8c.6 0 1 .4 1 1s-.4 1-1 1H8a1 1 0 1 1 0-2Zm0 8h8c.6 0 1 .4 1 1s-.4 1-1 1H8a1 1 0 0 1 0-2Zm-3-4h14c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 0 1 0-2Z" fill-rule="evenodd"/></svg>',
|
||||
'align-justify': '<svg width="24" height="24"><path d="M5 5h14c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 1 1 0-2Zm0 4h14c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 1 1 0-2Zm0 4h14c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 0 1 0-2Zm0 4h14c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 0 1 0-2Z" fill-rule="evenodd"/></svg>',
|
||||
'align-left': '<svg width="24" height="24"><path d="M5 5h14c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 1 1 0-2Zm0 4h8c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 1 1 0-2Zm0 8h8c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 0 1 0-2Zm0-4h14c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 0 1 0-2Z" fill-rule="evenodd"/></svg>',
|
||||
'align-none': '<svg width="24" height="24"><path d="M14.2 5 13 7H5a1 1 0 1 1 0-2h9.2Zm4 0h.8a1 1 0 0 1 0 2h-2l1.2-2Zm-6.4 4-1.2 2H5a1 1 0 0 1 0-2h6.8Zm4 0H19a1 1 0 0 1 0 2h-4.4l1.2-2Zm-6.4 4-1.2 2H5a1 1 0 0 1 0-2h4.4Zm4 0H19a1 1 0 0 1 0 2h-6.8l1.2-2ZM7 17l-1.2 2H5a1 1 0 0 1 0-2h2Zm4 0h8a1 1 0 0 1 0 2H9.8l1.2-2Zm5.2-13.5 1.3.7-9.7 16.3-1.3-.7 9.7-16.3Z" fill-rule="evenodd"/></svg>',
|
||||
'align-right': '<svg width="24" height="24"><path d="M5 5h14c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 1 1 0-2Zm6 4h8c.6 0 1 .4 1 1s-.4 1-1 1h-8a1 1 0 0 1 0-2Zm0 8h8c.6 0 1 .4 1 1s-.4 1-1 1h-8a1 1 0 0 1 0-2Zm-6-4h14c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 0 1 0-2Z" fill-rule="evenodd"/></svg>',
|
||||
'alt-text': '<svg width="24" height="24"><path d="M3 20a2 2 0 0 1-1.4-.6A2 2 0 0 1 1 18V6c0-.6.2-1 .6-1.4A2 2 0 0 1 3 4h18c.6 0 1 .2 1.4.6.4.4.6.9.6 1.4v12c0 .6-.2 1-.6 1.4a2 2 0 0 1-1.4.6H3Zm0-2h18V6H3v12Zm1.5-3H6v-1.5h1.5V15H9v-5a1 1 0 0 0-.3-.7A1 1 0 0 0 8 9H5.5a1 1 0 0 0-.7.3 1 1 0 0 0-.3.7v5ZM6 12v-1.5h1.5V12H6Z"/><path d="M11 15V9h1.3v4.5h2V15H11Zm5.8-4.5V15h1.4v-4.5h1.3V9h-4v1.5h1.3Z"/></svg>',
|
||||
'arrow-left': '<svg width="24" height="24"><path d="m5.6 13 12 6a1 1 0 0 0 1.4-1V6a1 1 0 0 0-1.4-.9l-12 6a1 1 0 0 0 0 1.8Z" fill-rule="evenodd"/></svg>',
|
||||
'arrow-right': '<svg width="24" height="24"><path d="m18.5 13-12 6A1 1 0 0 1 5 18V6a1 1 0 0 1 1.4-.9l12 6a1 1 0 0 1 0 1.8Z" fill-rule="evenodd"/></svg>',
|
||||
'attachment': '<svg height="24" width="24"><path d="M11 2c1 0 2 .5 2.8 1.3.7.8 1.1 1.9 1.1 3L15 16c0 .7-.2 1.3-.7 1.8-.4.5-1 .8-1.8.8-.6 0-1.3-.3-1.7-.8-.5-.5-.7-1.1-.7-1.8L10 6.3h2V16l.2.5.3.1.4-.1.1-.5V6.3c0-.7-.3-1.3-.7-1.7-.3-.4-.8-.6-1.3-.6s-1 .2-1.4.6c-.4.4-.6 1-.6 1.7V16c0 1 .5 2 1.1 2.8.7.7 1.6 1.1 2.4 1.1 1 0 1.8-.4 2.5-1.1.6-.7 1-1.7 1-2.8l-.1-9h2l.1 9c0 1.5-.6 3-1.6 4.1a5.3 5.3 0 0 1-3.9 1.8c-1.4 0-2.8-.6-3.8-1.8A6.2 6.2 0 0 1 7 16.1L7 6.3V6c0-1 .4-2 1.1-2.8C9 2.5 10 2 11 2z"/></svg>',
|
||||
'auto-image-enhancement': '<svg width="24" height="24"><path d="M7.7 10.6a5.8 5.8 0 0 0 5.7 5.7A5.8 5.8 0 0 0 7.7 22 5.8 5.8 0 0 0 2 16.3a5.8 5.8 0 0 0 5.7-5.7ZM15.5 2c.3 3 3.3 6 6.5 6.4-3.2.5-6.2 3.4-6.5 6.5-.3-3.4-3-6-6.4-6.5 3.3-.5 6.1-3 6.4-6.4Z"/></svg>',
|
||||
'blur': '<svg width="24" height="24"><path d="M19.3 9.3a1 1 0 0 0-.3.7c0 .3.1.5.3.7.2.2.4.3.7.3s.5-.1.7-.3a1 1 0 0 0 .3-.7 1 1 0 0 0-.3-.7A1 1 0 0 0 20 9a1 1 0 0 0-.7.3Zm-16 0a1 1 0 0 0-.3.7c0 .3.1.5.3.7.2.2.4.3.7.3s.5-.1.7-.3A1 1 0 0 0 5 10a1 1 0 0 0-.3-.7A1 1 0 0 0 4 9a1 1 0 0 0-.7.3Zm16 6a1 1 0 0 0-.3.7c0 .3.1.5.3.7.2.2.4.3.7.3s.5-.1.7-.3a1 1 0 0 0 .3-.7 1 1 0 0 0-.3-.7 1 1 0 0 0-.7-.3 1 1 0 0 0-.7.3Zm-16 0a1 1 0 0 0-.3.7c0 .3.1.5.3.7.2.2.4.3.7.3s.5-.1.7-.3A1 1 0 0 0 5 16a1 1 0 0 0-.3-.7A1 1 0 0 0 4 15a1 1 0 0 0-.7.3Zm5-11A1 1 0 0 0 8 5a1 1 0 0 0 1 1c.3 0 .5-.1.7-.3A1 1 0 0 0 10 5a1 1 0 0 0-.3-.7A1 1 0 0 0 9 4a1 1 0 0 0-.7.3Zm6 0a1 1 0 0 0-.3.7 1 1 0 0 0 1 1c.3 0 .5-.1.7-.3A1 1 0 0 0 16 5a1 1 0 0 0-.3-.7A1 1 0 0 0 15 4a1 1 0 0 0-.7.3Zm-6 16a1 1 0 0 0-.3.7c0 .3.1.5.3.7.2.2.4.3.7.3s.5-.1.7-.3a1 1 0 0 0 .3-.7 1 1 0 0 0-.3-.7A1 1 0 0 0 9 20a1 1 0 0 0-.7.3Zm6 0a1 1 0 0 0-.3.7c0 .3.1.5.3.7.2.2.4.3.7.3s.5-.1.7-.3a1 1 0 0 0 .3-.7 1 1 0 0 0-.3-.7 1 1 0 0 0-.7-.3 1 1 0 0 0-.7.3ZM7.6 8.6A2 2 0 0 0 7 10c0 .6.2 1 .6 1.4.4.4.8.6 1.4.6s1-.2 1.4-.6c.4-.4.6-.8.6-1.4s-.2-1-.6-1.4A2 2 0 0 0 9 8a2 2 0 0 0-1.4.6Zm0 6A2 2 0 0 0 7 16c0 .6.2 1 .6 1.4.4.4.8.6 1.4.6s1-.2 1.4-.6c.4-.4.6-.8.6-1.4s-.2-1-.6-1.4A2 2 0 0 0 9 14a2 2 0 0 0-1.4.6Zm6-6A2 2 0 0 0 13 10c0 .6.2 1 .6 1.4.4.4.8.6 1.4.6s1-.2 1.4-.6c.4-.4.6-.8.6-1.4s-.2-1-.6-1.4A2 2 0 0 0 15 8a2 2 0 0 0-1.4.6Zm0 6A2 2 0 0 0 13 16c0 .6.2 1 .6 1.4.4.4.8.6 1.4.6s1-.2 1.4-.6c.4-.4.6-.8.6-1.4s-.2-1-.6-1.4A2 2 0 0 0 15 14a2 2 0 0 0-1.4.6Z"/></svg>',
|
||||
'bold': '<svg width="24" height="24"><path d="M7.8 19c-.3 0-.5 0-.6-.2l-.2-.5V5.7c0-.2 0-.4.2-.5l.6-.2h5c1.5 0 2.7.3 3.5 1 .7.6 1.1 1.4 1.1 2.5a3 3 0 0 1-.6 1.9c-.4.6-1 1-1.6 1.2.4.1.9.3 1.3.6s.8.7 1 1.2c.4.4.5 1 .5 1.6 0 1.3-.4 2.3-1.3 3-.8.7-2.1 1-3.8 1H7.8Zm5-8.3c.6 0 1.2-.1 1.6-.5.4-.3.6-.7.6-1.3 0-1.1-.8-1.7-2.3-1.7H9.3v3.5h3.4Zm.5 6c.7 0 1.3-.1 1.7-.4.4-.4.6-.9.6-1.5s-.2-1-.7-1.4c-.4-.3-1-.4-2-.4H9.4v3.8h4Z" fill-rule="evenodd"/></svg>',
|
||||
'bookmark': '<svg width="24" height="24"><path d="M6 4v17l6-4 6 4V4c0-.6-.4-1-1-1H7a1 1 0 0 0-1 1Z" fill-rule="nonzero"/></svg>',
|
||||
'border-style': '<svg width="24" height="24"><g fill-rule="evenodd"><rect width="18" height="2" x="3" y="6" rx="1"/><rect width="2.8" height="2" x="3" y="16" rx="1"/><rect width="2.8" height="2" x="6.8" y="16" rx="1"/><rect width="2.8" height="2" x="10.6" y="16" rx="1"/><rect width="2.8" height="2" x="14.4" y="16" rx="1"/><rect width="2.8" height="2" x="18.2" y="16" rx="1"/><rect width="8" height="2" x="3" y="11" rx="1"/><rect width="8" height="2" x="13" y="11" rx="1"/></g></svg>',
|
||||
'border-width': '<svg width="24" height="24"><g fill-rule="evenodd"><rect width="18" height="5" x="3" y="5" rx="1"/><rect width="18" height="3.5" x="3" y="11.5" rx="1"/><rect width="18" height="2" x="3" y="17" rx="1"/></g></svg>',
|
||||
'box': '<svg width="24" height="24"><path d="M3.8 7a1 1 0 0 0-.7.6l-.1 3V13.8a3.5 3.5 0 0 0 4 2.7c.8-.2 1.4-.5 2-1l.3-.3.3.2c.7.7 1.4 1 2.4 1.1 1.2 0 2.4-.6 3-1.6.9-1.2.8-2.8 0-4a3.7 3.7 0 0 0-2-1.2c-.4-.1-1.2-.1-1.6 0a3.4 3.4 0 0 0-1.7 1l-.3.2-.2-.1c-.4-.5-1.2-1-2-1.1-.6-.2-1.5 0-2 .2H5V9c0-1.3 0-1.3-.3-1.6a1 1 0 0 0-1-.3Zm12 2.6c-.3.2-.5.4-.6.8v.6l.9 1 .7 1v.1l-.8 1-.7 1a1 1 0 0 0 0 .7 1 1 0 0 0 1.3.6c.2 0 .3-.2 1-1l.5-.7.6.8.7.8c.4.3 1 .3 1.3 0l.2-.3a1 1 0 0 0 0-.8l-.8-1.2-.7-1a26 26 0 0 0 1.5-2 .8.8 0 0 0 .1-.4c0-.4-.1-.7-.5-.9H20c-.4 0-.5 0-1.2.8l-.7.8-.6-.7-.7-.9a1 1 0 0 0-1 0Zm-9 2 .4.1c.3.1.6.5.7.8l.1.6v.6c-.6 1-2 1.2-2.7.3-.3-.3-.3-.5-.3-1v-.5c.3-.5.7-.8 1.1-1a1.5 1.5 0 0 1 .7 0Zm5.7 0c.4 0 .8.4 1 .8l.2.6-.1.7c-.5.9-1.6 1.1-2.4.6a1.4 1.4 0 0 1-.6-1.1 1.4 1.4 0 0 1 .5-1.2 1.5 1.5 0 0 1 1.4-.4Z"/></svg>',
|
||||
'brightness': '<svg width="24" height="24"><path d="M12.7 18.3a1 1 0 0 0-.7-.3 1 1 0 0 0-.7.3 1 1 0 0 0-.3.7v1a1 1 0 0 0 .3.7 1 1 0 0 0 .7.3 1 1 0 0 0 .7-.3 1 1 0 0 0 .3-.7v-1a1 1 0 0 0-.3-.7ZM11.3 5.7a1 1 0 0 0 .7.3 1 1 0 0 0 .7-.3A1 1 0 0 0 13 5V4a1 1 0 0 0-.3-.7A1 1 0 0 0 12 3a1 1 0 0 0-.7.3 1 1 0 0 0-.3.7v1a1 1 0 0 0 .3.7Zm9.4 5.6a1 1 0 0 0-.7-.3h-1a1 1 0 0 0-.7.3 1 1 0 0 0-.3.7 1 1 0 0 0 .3.7 1 1 0 0 0 .7.3h1a1 1 0 0 0 .7-.3 1 1 0 0 0 .3-.7 1 1 0 0 0-.3-.7Zm-15 1.4A1 1 0 0 0 6 12a1 1 0 0 0-.3-.7A1 1 0 0 0 5 11H4a1 1 0 0 0-.7.3 1 1 0 0 0-.3.7 1 1 0 0 0 .3.7 1 1 0 0 0 .7.3h1a1 1 0 0 0 .7-.3Zm13 4.6-.7-.8a1 1 0 0 0-.8-.3 1 1 0 0 0-.7.3 1 1 0 0 0-.3.7 1 1 0 0 0 .3.8l.7.7a1 1 0 0 0 .7.3 1 1 0 0 0 .8-.3 1 1 0 0 0 .3-.8 1 1 0 0 0-.3-.7ZM5.3 6.7l.7.8a1 1 0 0 0 .8.3 1 1 0 0 0 .7-.3 1 1 0 0 0 .3-.7 1 1 0 0 0-.3-.8l-.7-.7A1 1 0 0 0 6 5a1 1 0 0 0-.8.3 1 1 0 0 0-.3.8 1 1 0 0 0 .3.6Zm12 1a1 1 0 0 0 .7-.2l.7-.7A1 1 0 0 0 19 6a1 1 0 0 0-.3-.8 1 1 0 0 0-.8-.3 1 1 0 0 0-.7.3l-.7.7a1 1 0 0 0-.3.8 1 1 0 0 0 .3.7 1 1 0 0 0 .7.2ZM6.7 16.4a1 1 0 0 0-.8.2l-.7.7a1 1 0 0 0-.3.7 1 1 0 0 0 .3.8 1 1 0 0 0 .8.3 1 1 0 0 0 .7-.3l.7-.7a1 1 0 0 0 .3-.8 1 1 0 0 0-.3-.7 1 1 0 0 0-.7-.3Z"/><path fill-rule="evenodd" d="M13.6 8.3A4 4 0 0 0 12 8a4 4 0 0 0-1.6.3A4 4 0 0 0 9 9.5a4 4 0 0 0-.6 1A4 4 0 0 0 8 12c0 .6.1 1 .3 1.6a4 4 0 0 0 2.1 2 4 4 0 0 0 1.6.4 4 4 0 0 0 1.6-.3 4 4 0 0 0 2-2.1A4 4 0 0 0 16 12a4 4 0 0 0-.3-1.6 4 4 0 0 0-2.1-2ZM14 12a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z"/></svg>',
|
||||
'browse': '<svg width="24" height="24"><path d="M19 4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-4v-2h4V8H5v10h4v2H5a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h14Zm-8 9.4-2.3 2.3a1 1 0 1 1-1.4-1.4l4-4a1 1 0 0 1 1.4 0l4 4a1 1 0 0 1-1.4 1.4L13 13.4V20a1 1 0 0 1-2 0v-6.6Z" fill-rule="nonzero"/></svg>',
|
||||
'camera': '<svg height="24" width="24"><g clip-rule="evenodd" fill-rule="evenodd"><path d="M10 5.4a1 1 0 0 1 .8-.4h4.4a1 1 0 0 1 .8.4l1.2 1.8h2.3A2.5 2.5 0 0 1 22 9.6v8a2.5 2.5 0 0 1-2.5 2.5h-13A2.5 2.5 0 0 1 4 17.6v-8a2.5 2.5 0 0 1 2.5-2.4h2.3zM11.4 7l-1.2 1.7a1 1 0 0 1-.8.5h-3a.5.5 0 0 0-.4.4v8a.5.5 0 0 0 .5.5h13a.5.5 0 0 0 .5-.5v-8a.5.5 0 0 0-.5-.4h-2.9a1 1 0 0 1-.8-.5L14.6 7z"/><path d="M13 11.4a2 2 0 1 0 0 3.8 2 2 0 0 0 0-3.8zm-4 1.9a4 4 0 1 1 8 0 4 4 0 0 1-8 0z"/></g></svg>',
|
||||
'cancel': '<svg width="24" height="24"><path d="M12 4.6a7.4 7.4 0 1 1 0 14.8 7.4 7.4 0 0 1 0-14.8ZM12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 8L14.8 8l1 1.1-2.7 2.8 2.7 2.7-1.1 1.1-2.7-2.7-2.7 2.7-1-1.1 2.6-2.7-2.7-2.7 1-1.1 2.8 2.7Z" fill-rule="nonzero"/></svg>',
|
||||
'caption': '<svg height="24" width="24"><path d="M6 16h8v-2H6zm10 0h2v-2h-2zM4 20a2 2 0 0 1-1.5-.6A2 2 0 0 1 2 18V6c0-.6.2-1 .6-1.4A2 2 0 0 1 4 4h16c.6 0 1 .2 1.4.6.4.4.6.9.6 1.4v12c0 .6-.2 1-.6 1.4a2 2 0 0 1-1.4.6zm0-2h16V6H4z"/></svg>',
|
||||
'cell-background-color': '<svg width="24" height="24"><path d="m15.7 2 1.6 1.6-2.7 2.6 5.9 5.8c.7.7.7 1.7 0 2.4l-6.3 6.1a1.7 1.7 0 0 1-2.4 0l-6.3-6.1c-.7-.7-.7-1.7 0-2.4L15.7 2ZM18 12l-4.5-4L9 12h9ZM4 16s2 2.4 2 3.8C6 21 5.1 22 4 22s-2-1-2-2.2C2 18.4 4 16 4 16Z"/></svg>',
|
||||
'cell-border-color': '<svg width="24" height="24"><g fill-rule="evenodd"><path fill-rule="nonzero" d="M5 13v5h2v2H5a2 2 0 0 1-2-2v-5h2zm8-7V4h6a2 2 0 0 1 2 2h-8z" opacity=".2"/><path fill-rule="nonzero" d="M13 4v2H5v7H3V6c0-1.1.9-2 2-2h8zm-2.6 14.1.1-.1.1.1.2.3.2.2.2.2c.4.6.8 1.2.8 1.7 0 .8-.7 1.5-1.5 1.5S9 21.3 9 20.5c0-.5.4-1.1.8-1.7l.2-.2.2-.2.2-.3z"/><path d="m13 11-2 2H5v-2h6V6h2z"/><path fill-rule="nonzero" d="m18.4 8 1 1-1.8 1.9 4 4c.5.4.5 1.1 0 1.6l-4.3 4.2a1.2 1.2 0 0 1-1.6 0l-4.4-4.2c-.4-.5-.4-1.2 0-1.7l7-6.8Zm1.6 7-3-3-3 3h6Z"/></g></svg>',
|
||||
'change-case': '<svg width="24" height="24"><path d="M18.4 18.2v-.6c-.5.8-1.3 1.2-2.4 1.2-2.2 0-3.3-1.6-3.3-4.8 0-3.1 1-4.7 3.3-4.7 1.1 0 1.8.3 2.4 1.1v-.6c0-.5.4-.8.8-.8s.8.3.8.8v8.4c0 .5-.4.8-.8.8a.8.8 0 0 1-.8-.8zm-2-7.4c-1.3 0-1.8.9-1.8 3.2 0 2.4.5 3.3 1.7 3.3 1.3 0 1.8-.9 1.8-3.2 0-2.4-.5-3.3-1.7-3.3zM10 15.7H5.5l-.8 2.6a1 1 0 0 1-1 .7h-.2a.7.7 0 0 1-.7-1l4-12a1 1 0 0 1 2 0l4 12a.7.7 0 0 1-.8 1h-.2a1 1 0 0 1-1-.7l-.8-2.6zm-.3-1.5-2-6.5-1.9 6.5h3.9z" fill-rule="evenodd"/></svg>',
|
||||
'change-tone': '<svg height="24" width="24"><path d="M13 20h-2V8h2zm-4-3H7v-6h2zm8 0h-2v-6h2zM5 15.5H3v-3h2zm16 0h-2v-3h2zM17.7 4.2a.3.3 0 0 1 .6 0 4.1 4.1 0 0 0 2.5 2.5.3.3 0 0 1 0 .6l-.2.1a4.1 4.1 0 0 0-2.3 2.4c-.1.3-.5.3-.6 0a4.1 4.1 0 0 0-2.3-2.4h-.2a.3.3 0 0 1 0-.7 4.1 4.1 0 0 0 2.5-2.5z"/></svg>',
|
||||
'character-count': '<svg width="24" height="24"><path d="M4 11.5h16v1H4v-1Zm4.8-6.8V10H7.7V5.8h-1v-1h2ZM11 8.3V9h2v1h-3V7.7l2-1v-.9h-2v-1h3v2.4l-2 1Zm6.3-3.4V10h-3.1V9h2.1V8h-2.1V6.8h2.1v-1h-2.1v-1h3.1ZM5.8 16.4c0-.5.2-.8.5-1 .2-.2.6-.3 1.2-.3l.8.1c.2 0 .4.2.5.3l.4.4v2.8l.2.3H8.2V18.7l-.6.3H7c-.4 0-.7 0-1-.2a1 1 0 0 1-.3-.9c0-.3 0-.6.3-.8.3-.2.7-.4 1.2-.4l.6-.2h.3v-.2l-.1-.2a.8.8 0 0 0-.5-.1 1 1 0 0 0-.4 0l-.3.4h-1Zm2.3.8h-.2l-.2.1-.4.1a1 1 0 0 0-.4.2l-.2.2.1.3.5.1h.4l.4-.4v-.6Zm2-3.4h1.2v1.7l.5-.3h.5c.5 0 .9.1 1.2.5.3.4.5.8.5 1.4 0 .6-.2 1.1-.5 1.5-.3.4-.7.6-1.3.6l-.6-.1-.4-.4v.4h-1.1v-5.4Zm1.1 3.3c0 .3 0 .6.2.8a.7.7 0 0 0 1.2 0l.2-.8c0-.4 0-.6-.2-.8a.7.7 0 0 0-.6-.3l-.6.3-.2.8Zm6.1-.5c0-.2 0-.3-.2-.4a.8.8 0 0 0-.5-.2c-.3 0-.5.1-.6.3l-.2.9c0 .3 0 .6.2.8.1.2.3.3.6.3.2 0 .4 0 .5-.2l.2-.4h1.1c0 .5-.3.8-.6 1.1a2 2 0 0 1-1.3.4c-.5 0-1-.2-1.3-.6a2 2 0 0 1-.5-1.4c0-.6.1-1.1.5-1.5.3-.4.8-.5 1.4-.5.5 0 1 0 1.2.3.4.3.5.7.5 1.2h-1v-.1Z" fill-rule="evenodd"/></svg>',
|
||||
'chat-commands': '<svg height="24" width="24"><path d="M14.3 3.3h.4l-2 2H9.3a5.5 5.5 0 0 0 0 11h3.4v2.1l4.4-3h.1a5.5 5.5 0 0 0 2.5-4.3v-.8l1.7-1.7a7.5 7.5 0 0 1-3.1 8.5l-7.6 5.1v-4H9.3a7.5 7.5 0 0 1 0-15z"/><path d="M12.4 7.3a1 1 0 0 1 1.5 1.4l-.8.8a1 1 0 0 1 .8 1.7l-2.3 2.5a1 1 0 0 1-1.5-1.4l.8-.8a1 1 0 0 1-.8-1.7zm6.3-5a.3.3 0 0 1 .6 0 4.1 4.1 0 0 0 2.5 2.4.3.3 0 0 1 0 .6l-.2.1a4.1 4.1 0 0 0-2.3 2.4c-.1.3-.5.3-.6 0a4.1 4.1 0 0 0-2.3-2.4h-.2a.3.3 0 0 1 0-.7 4.1 4.1 0 0 0 2.5-2.5z"/></svg>',
|
||||
'chat-reasoning': '<svg width="16" height="16"><path d="M9.1 12.7V14H7v-1.3H9Zm.6-2.2V12H6.3v-1.4h3.4Zm1-4.6A2.5 2.5 0 0 0 9.9 4 2.7 2.7 0 0 0 8 3.3c-.7 0-1.4.3-1.9.8a2.5 2.5 0 0 0-.8 1.8c0 .4.1.8.7 1.4.4.4.8.9 1 1.6a.7.7 0 1 1-1.3.3 2 2 0 0 0-.7-1A3 3 0 0 1 4 6c0-1 .4-2 1.2-2.8A4 4 0 0 1 8 2c1 0 2 .4 2.8 1.1A3.8 3.8 0 0 1 12 6a3.1 3.1 0 0 1-1 2.3l-.7 1A.7.7 0 1 1 9 8.9c.2-.7.6-1.2 1-1.6a1.8 1.8 0 0 0 .7-1.4Z"/></svg>',
|
||||
'chat-send': '<svg width="24" height="24"><path d="M11 19a1 1 0 1 0 2 0V7.8l5.3 5.3a1 1 0 1 0 1.4-1.4L12 4l-7.7 7.7a1 1 0 1 0 1.4 1.4L11 7.8V19Z"/></svg>',
|
||||
'chat-web-search': '<svg height="16" width="16"><path d="M8 2.5a6 6 0 1 1 0 12 6 6 0 0 1 0-12zM3.4 9.2a4.7 4.7 0 0 0 3.1 3.7v-.2a8.8 8.8 0 0 1-1.1-3.5zm7.2 0a8.8 8.8 0 0 1-1 3.5l-.1.2a4.7 4.7 0 0 0 3.1-3.7zm-3.9 0A7.5 7.5 0 0 0 8 12.8a7.5 7.5 0 0 0 1.3-3.6zM6.5 4a4.7 4.7 0 0 0-3.1 3.7h2a8.8 8.8 0 0 1 1-3.5l.1-.2zm1.5.1a7.5 7.5 0 0 0-1.3 3.6h2.6A7.5 7.5 0 0 0 8 4.2zm1.6 0a8.8 8.8 0 0 1 1 3.6h2a4.7 4.7 0 0 0-3.1-3.7v.2z" fill-rule="evenodd"/></svg>',
|
||||
'checklist-rtl': '<svg width="24" height="24"><path d="M5 17h8c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 0 1 0-2zm0-6h8c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 0 1 0-2zm0-6h8c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 1 1 0-2zm14.2 11c.2-.4.6-.5.9-.3.3.2.4.6.2 1L18 20c-.2.3-.7.4-1 0l-1.3-1.3a.7.7 0 0 1 0-1c.3-.2.7-.2 1 0l.7.9 1.7-2.8zm0-6c.2-.4.6-.5.9-.3.3.2.4.6.2 1L18 14c-.2.3-.7.4-1 0l-1.3-1.3a.7.7 0 0 1 0-1c.3-.2.7-.2 1 0l.7.9 1.7-2.8zm0-6c.2-.4.6-.5.9-.3.3.2.4.6.2 1L18 8c-.2.3-.7.4-1 0l-1.3-1.3a.7.7 0 0 1 0-1c.3-.2.7-.2 1 0l.7.9 1.7-2.8z" fill-rule="evenodd"/></svg>',
|
||||
'checklist': '<svg width="24" height="24"><path d="M11 17h8c.6 0 1 .4 1 1s-.4 1-1 1h-8a1 1 0 0 1 0-2Zm0-6h8c.6 0 1 .4 1 1s-.4 1-1 1h-8a1 1 0 0 1 0-2Zm0-6h8a1 1 0 0 1 0 2h-8a1 1 0 0 1 0-2ZM7.2 16c.2-.4.6-.5.9-.3.3.2.4.6.2 1L6 20c-.2.3-.7.4-1 0l-1.3-1.3a.7.7 0 0 1 0-1c.3-.2.7-.2 1 0l.7.9 1.7-2.8Zm0-6c.2-.4.6-.5.9-.3.3.2.4.6.2 1L6 14c-.2.3-.7.4-1 0l-1.3-1.3a.7.7 0 0 1 0-1c.3-.2.7-.2 1 0l.7.9 1.7-2.8Zm0-6c.2-.4.6-.5.9-.3.3.2.4.6.2 1L6 8c-.2.3-.7.4-1 0L3.8 6.9a.7.7 0 0 1 0-1c.3-.2.7-.2 1 0l.7.9 1.7-2.8Z" fill-rule="evenodd"/></svg>',
|
||||
'checkmark-filled': '<svg width="24" height="24"><path fill-rule="evenodd" d="M12 2a10 10 0 1 1 0 20 10 10 0 0 1 0-20Zm4.8 5a1 1 0 0 0-1.4.3l-5.4 8-2-2.1a1 1 0 1 0-1.5 1.4l3 3a1 1 0 0 0 1.5-.2l6-9a1 1 0 0 0-.2-1.4Z"/></svg>',
|
||||
'checkmark': '<svg width="24" height="24"><path d="M18.2 5.4a1 1 0 0 1 1.6 1.2l-8 12a1 1 0 0 1-1.5.1l-5-5a1 1 0 1 1 1.4-1.4l4.1 4.1 7.4-11Z" fill-rule="nonzero"/></svg>',
|
||||
'chevron-down': '<svg width="10" height="10"><path d="M8.7 2.2c.3-.3.8-.3 1 0 .4.4.4.9 0 1.2L5.7 7.8c-.3.3-.9.3-1.2 0L.2 3.4a.8.8 0 0 1 0-1.2c.3-.3.8-.3 1.1 0L5 6l3.7-3.8Z" fill-rule="nonzero"/></svg>',
|
||||
'chevron-left': '<svg width="10" height="10"><path d="M7.8 1.3 4 5l3.8 3.7c.3.3.3.8 0 1-.4.4-.9.4-1.2 0L2.2 5.7a.8.8 0 0 1 0-1.2L6.6.2C7 0 7.4 0 7.8.2c.3.3.3.8 0 1.1Z" fill-rule="nonzero"/></svg>',
|
||||
'chevron-right': '<svg width="10" height="10"><path d="M2.2 1.3a.8.8 0 0 1 0-1c.4-.4.9-.4 1.2 0l4.4 4.1c.3.4.3.9 0 1.2L3.4 9.8c-.3.3-.8.3-1.2 0a.8.8 0 0 1 0-1.1L6 5 2.2 1.3Z" fill-rule="nonzero"/></svg>',
|
||||
'chevron-up': '<svg width="10" height="10"><path d="M8.7 7.8 5 4 1.3 7.8c-.3.3-.8.3-1 0a.8.8 0 0 1 0-1.2l4.1-4.4c.3-.3.9-.3 1.2 0l4.2 4.4c.3.3.3.9 0 1.2-.3.3-.8.3-1.1 0Z" fill-rule="nonzero"/></svg>',
|
||||
'close-filled': '<svg width="24" height="24"><path fill-rule="evenodd" d="M12 2a10 10 0 1 1 0 20 10 10 0 0 1 0-20Zm4.7 5.3a1 1 0 0 0-1.3 0L12 10.6 8.6 7.3a1 1 0 0 0-1.3 1.3l3.3 3.4-3.3 3.4a1 1 0 0 0 1.3 1.3l3.4-3.3 3.4 3.3a1 1 0 0 0 1.3-1.3L13.4 12l3.3-3.4a1 1 0 0 0 0-1.3Z"/></svg>',
|
||||
'close': '<svg width="24" height="24"><path d="M17.3 8.2 13.4 12l3.9 3.8a1 1 0 0 1-1.5 1.5L12 13.4l-3.8 3.9a1 1 0 0 1-1.5-1.5l3.9-3.8-3.9-3.8a1 1 0 0 1 1.5-1.5l3.8 3.9 3.8-3.9a1 1 0 0 1 1.5 1.5Z" fill-rule="evenodd"/></svg>',
|
||||
'code-sample': '<svg width="24" height="26"><path d="M7.1 11a2.8 2.8 0 0 1-.8 2 2.8 2.8 0 0 1 .8 2v1.7c0 .3.1.6.4.8.2.3.5.4.8.4.3 0 .4.2.4.4v.8c0 .2-.1.4-.4.4-.7 0-1.4-.3-2-.8-.5-.6-.8-1.3-.8-2V15c0-.3-.1-.6-.4-.8-.2-.3-.5-.4-.8-.4a.4.4 0 0 1-.4-.4v-.8c0-.2.2-.4.4-.4.3 0 .6-.1.8-.4.3-.2.4-.5.4-.8V9.3c0-.7.3-1.4.8-2 .6-.5 1.3-.8 2-.8.3 0 .4.2.4.4v.8c0 .2-.1.4-.4.4-.3 0-.6.1-.8.4-.3.2-.4.5-.4.8V11Zm9.8 0V9.3c0-.3-.1-.6-.4-.8-.2-.3-.5-.4-.8-.4a.4.4 0 0 1-.4-.4V7c0-.2.1-.4.4-.4.7 0 1.4.3 2 .8.5.6.8 1.3.8 2V11c0 .3.1.6.4.8.2.3.5.4.8.4.2 0 .4.2.4.4v.8c0 .2-.2.4-.4.4-.3 0-.6.1-.8.4-.3.2-.4.5-.4.8v1.7c0 .7-.3 1.4-.8 2-.6.5-1.3.8-2 .8a.4.4 0 0 1-.4-.4v-.8c0-.2.1-.4.4-.4.3 0 .6-.1.8-.4.3-.2.4-.5.4-.8V15a2.8 2.8 0 0 1 .8-2 2.8 2.8 0 0 1-.8-2Zm-3.3-.4c0 .4-.1.8-.5 1.1-.3.3-.7.5-1.1.5-.4 0-.8-.2-1.1-.5-.4-.3-.5-.7-.5-1.1 0-.5.1-.9.5-1.2.3-.3.7-.4 1.1-.4.4 0 .8.1 1.1.4.4.3.5.7.5 1.2ZM12 13c.4 0 .8.1 1.1.5.4.3.5.7.5 1.1 0 1-.1 1.6-.5 2a3 3 0 0 1-1.1 1c-.4.3-.8.4-1.1.4a.5.5 0 0 1-.5-.5V17a3 3 0 0 0 1-.2l.6-.6c-.6 0-1-.2-1.3-.5-.2-.3-.3-.7-.3-1 0-.5.1-1 .5-1.2.3-.4.7-.5 1.1-.5Z" fill-rule="evenodd"/></svg>',
|
||||
'collapse': '<svg width="24" height="24"><path d="M4 3a1 1 0 0 1 1 1v16a1 1 0 0 1-2 0V4a1 1 0 0 1 1-1Zm11 4.6a1 1 0 0 1 1.4 0L21 12l-4.6 4.6a1 1 0 0 1-1.4-1.4l2.2-2.2H10a1 1 0 1 1 0-2h7.2L15 9a1 1 0 0 1 0-1.4Z"/></svg>',
|
||||
'color-levels': '<svg width="24" height="24"><path d="M17.5 11.4A9 9 0 0 1 18 14c0 .5 0 1-.2 1.4 0 .4-.3.9-.5 1.3a6.2 6.2 0 0 1-3.7 3 5.7 5.7 0 0 1-3.2 0A5.9 5.9 0 0 1 7.6 18a6.2 6.2 0 0 1-1.4-2.6 6.7 6.7 0 0 1 0-2.8c0-.4.1-.9.3-1.3a13.6 13.6 0 0 1 2.3-4A20 20 0 0 1 12 4a26.4 26.4 0 0 1 3.2 3.4 18.2 18.2 0 0 1 2.3 4Zm-2 4.5c.4-.7.5-1.4.5-2a7.3 7.3 0 0 0-1-3.2c.2.6.2 1.2.2 1.9a4.5 4.5 0 0 1-1.3 3 5.3 5.3 0 0 1-2.3 1.5 4.9 4.9 0 0 1-2 .1 4.3 4.3 0 0 0 2.4.8 4 4 0 0 0 2-.6 4 4 0 0 0 1.5-1.5Z" fill-rule="evenodd"/></svg>',
|
||||
'color-picker': '<svg width="24" height="24"><path d="M12 3a9 9 0 0 0 0 18 1.5 1.5 0 0 0 1.1-2.5c-.2-.3-.4-.6-.4-1 0-.8.7-1.5 1.5-1.5H16a5 5 0 0 0 5-5c0-4.4-4-8-9-8Zm-5.5 9a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3Zm3-4a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3Zm5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3Zm3 4a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3Z" fill-rule="nonzero"/></svg>',
|
||||
'color-swatch-remove-color': '<svg width="24" height="24" class="color-swatch-remove-color"><path stroke="#000" stroke-width="2" d="M21 3 3 21" fill-rule="evenodd"/></svg>',
|
||||
'color-swatch': '<svg width="24" height="24"><rect x="3" y="3" width="18" height="18" rx="1" fill-rule="evenodd"/></svg>',
|
||||
'comment-add': '<svg width="24" height="24"><g fill-rule="nonzero"><path d="m9 19 3-2h7c.6 0 1-.4 1-1V6c0-.6-.4-1-1-1H5a1 1 0 0 0-1 1v10c0 .6.4 1 1 1h4v2Zm-2 4v-4H5a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h14a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3h-6.4L7 23Z"/><path d="M13 10h2a1 1 0 0 1 0 2h-2v2a1 1 0 0 1-2 0v-2H9a1 1 0 0 1 0-2h2V8a1 1 0 0 1 2 0v2Z"/></g></svg>',
|
||||
'comment': '<svg width="24" height="24"><path fill-rule="nonzero" d="m9 19 3-2h7c.6 0 1-.4 1-1V6c0-.6-.4-1-1-1H5a1 1 0 0 0-1 1v10c0 .6.4 1 1 1h4v2Zm-2 4v-4H5a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h14a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3h-6.4L7 23Z"/></svg>',
|
||||
'continue-writing': '<svg height="24" width="24"><path d="M19.6 14.1a1.5 1.5 0 0 1 1 .5l1 .9.3.5.1.5-.1.6a1.3 1.3 0 0 1-.3.5L16 23H13v-3l5.5-5.5.5-.3h.6zm-5.1 6.5v1h1l4-4-1-1z" fill-rule="evenodd"/><path d="M10 12.9H2v-2h8zm4-4H2V7h12zm0-4H2V3h12zm4.2-1.7c0-.3.5-.3.7 0a4.4 4.4 0 0 0 2.6 2.6.4.4 0 0 1 0 .7l-.2.1a4.4 4.4 0 0 0-2.4 2.5.4.4 0 0 1-.7 0 4.4 4.4 0 0 0-2.5-2.5h-.2a.4.4 0 0 1 0-.8 4.4 4.4 0 0 0 2.6-2.6z"/></svg>',
|
||||
'contrast': '<svg width="24" height="24"><path fill-rule="evenodd" d="M12 4a7.8 7.8 0 0 1 5.7 2.3A8 8 0 0 1 19.4 9c.4 1 .6 2 .6 3.1s-.2 2.1-.6 3.1a8 8 0 0 1-1.7 2.6 8 8 0 0 1-2.6 1.7 7.8 7.8 0 0 1-3.1.6 7.8 7.8 0 0 1-3.1-.6 8 8 0 0 1-2.6-1.7A8 8 0 0 1 4.6 15 7.8 7.8 0 0 1 4 12c0-1.1.2-2.1.6-3.1a8 8 0 0 1 1.7-2.6A8 8 0 0 1 9 4.6 7.8 7.8 0 0 1 12 4Zm-6 8a6 6 0 0 0 1.8 4.2 6 6 0 0 0 1.9 1.3 5.8 5.8 0 0 0 2.3.5V6c-.8 0-1.6.2-2.3.5a6 6 0 0 0-2 1.3 6 6 0 0 0-1.2 1.9A5.8 5.8 0 0 0 6 12Z"/></svg>',
|
||||
'copy': '<svg width="24" height="24"><path d="M16 3H6a2 2 0 0 0-2 2v11h2V5h10V3Zm1 4a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-7a2 2 0 0 1-2-2V9c0-1.2.9-2 2-2h7Zm0 12V9h-7v10h7Z" fill-rule="nonzero"/></svg>',
|
||||
'crop': '<svg width="24" height="24"><path d="M17 8v7h2c.6 0 1 .4 1 1s-.4 1-1 1h-2v2c0 .6-.4 1-1 1a1 1 0 0 1-1-1v-2H7V9H5a1 1 0 1 1 0-2h2V5c0-.6.4-1 1-1s1 .4 1 1v2h7l3-3 1 1-3 3ZM9 9v5l5-5H9Zm1 6h5v-5l-5 5Z" fill-rule="evenodd"/></svg>',
|
||||
'cut-column': '<svg width="24" height="24"><path fill-rule="evenodd" d="M7.2 4.5c.9 0 1.6.4 2.2 1A3.7 3.7 0 0 1 10.5 8v.5l1 1 4-4 1-.5a3.3 3.3 0 0 1 2 0c.4 0 .7.3 1 .5L17 8h4v13h-6V10l-1.5 1.5.5.5v4l-2.5-2.5-1 1v.5c0 .4 0 .8-.3 1.2-.2.5-.4.9-.8 1.2-.6.7-1.3 1-2.2 1-.8.2-1.5 0-2-.6l-.5-.8-.2-1c0-.4 0-.8.3-1.2A3.9 3.9 0 0 1 7 12.7c.5-.2 1-.3 1.5-.2l1-1-1-1c-.5 0-1 0-1.5-.2-.5-.1-1-.4-1.4-.9-.4-.3-.6-.7-.8-1.2L4.5 7c0-.4 0-.7.2-1 0-.3.3-.6.5-.8.5-.5 1.2-.8 2-.7Zm12.3 5h-3v10h3v-10ZM8 13.8h-.3l-.4.2a2.8 2.8 0 0 0-.7.4v.1a2.8 2.8 0 0 0-.6.8l-.1.4v.7l.2.5.5.2h.7a2.6 2.6 0 0 0 .8-.3 2.4 2.4 0 0 0 .7-.7 2.5 2.5 0 0 0 .3-.8 1.5 1.5 0 0 0 0-.8 1 1 0 0 0-.2-.4 1 1 0 0 0-.5-.2H8Zm3.5-3.7c-.4 0-.7.1-1 .4-.3.3-.4.6-.4 1s.1.7.4 1c.3.3.6.4 1 .4s.7-.1 1-.4c.3-.3.4-.6.4-1s-.1-.7-.4-1c-.3-.3-.6-.4-1-.4ZM7 5.8h-.4a1 1 0 0 0-.5.3 1 1 0 0 0-.2.5v.7a2.5 2.5 0 0 0 .3.8l.2.3h.1l.4.4.4.2.4.1h.7L9 9l.2-.4a1.6 1.6 0 0 0 0-.8 2.6 2.6 0 0 0-.3-.8A2.5 2.5 0 0 0 7.7 6l-.4-.1H7Z"/></svg>',
|
||||
'cut-row': '<svg width="24" height="24"><path fill-rule="evenodd" d="M22 3v5H9l3 3 2-2h4l-4 4 1 1h.5c.4 0 .8 0 1.2.3.5.2.9.4 1.2.8.7.6 1 1.3 1 2.2.2.8 0 1.5-.6 2l-.8.5-1 .2c-.4 0-.8 0-1.2-.3a3.9 3.9 0 0 1-2.1-2.2c-.2-.5-.3-1-.2-1.5l-1-1-1 1c0 .5 0 1-.2 1.5-.1.5-.4 1-.9 1.4-.3.4-.7.6-1.2.8l-1.2.3c-.4 0-.7 0-1-.2-.3 0-.6-.3-.8-.5-.5-.5-.8-1.2-.7-2 0-.9.4-1.6 1-2.2A3.7 3.7 0 0 1 8.6 14H9l1-1-4-4-.5-1a3.3 3.3 0 0 1 0-2c0-.4.3-.7.5-1l2 2V3h14ZM8.5 15.3h-.3a2.6 2.6 0 0 0-.8.4 2.5 2.5 0 0 0-.9 1.1l-.1.4v.7l.2.5.5.2h.7a2.5 2.5 0 0 0 .8-.3L9 18V18l.4-.4.2-.4.1-.4v-.7a1 1 0 0 0-.2-.5 1 1 0 0 0-.4-.2h-.5Zm7 0H15a1 1 0 0 0-.4.3 1 1 0 0 0-.2.5 1.5 1.5 0 0 0 0 .7v.4a2.8 2.8 0 0 0 .5.7h.1a2.8 2.8 0 0 0 .8.6l.4.1h.7l.5-.2.2-.5v-.7a2.6 2.6 0 0 0-.3-.8 2.4 2.4 0 0 0-.7-.7 2.5 2.5 0 0 0-.8-.3h-.3ZM12 11.6c-.4 0-.7.1-1 .4-.3.3-.4.6-.4 1s.1.7.4 1c.3.3.6.4 1 .4s.7-.1 1-.4c.3-.3.4-.6.4-1s-.1-.7-.4-1c-.3-.3-.6-.4-1-.4Zm8.5-7.1h-11v2h11v-2Z"/></svg>',
|
||||
'cut': '<svg width="24" height="24"><path d="M18 15c.6.7 1 1.4 1 2.3 0 .8-.2 1.5-.7 2l-.8.5-1 .2c-.4 0-.8 0-1.2-.3a3.9 3.9 0 0 1-2.1-2.2c-.2-.5-.3-1-.2-1.5l-1-1-1 1c0 .5 0 1-.2 1.5-.1.5-.4 1-.9 1.4-.3.4-.7.6-1.2.8l-1.2.3c-.4 0-.7 0-1-.2-.3 0-.6-.3-.8-.5-.5-.5-.8-1.2-.7-2 0-.9.4-1.6 1-2.2A3.7 3.7 0 0 1 8.6 14H9l1-1-4-4-.5-1a3.3 3.3 0 0 1 0-2c0-.4.3-.7.5-1l6 6 6-6 .5 1a3.3 3.3 0 0 1 0 2c0 .4-.3.7-.5 1l-4 4 1 1h.5c.4 0 .8 0 1.2.3.5.2.9.4 1.2.8Zm-8.5 2.2.1-.4v-.7a1 1 0 0 0-.2-.5 1 1 0 0 0-.4-.2 1.6 1.6 0 0 0-.8 0 2.6 2.6 0 0 0-.8.3 2.5 2.5 0 0 0-.9 1.1l-.1.4v.7l.2.5.5.2h.7a2.5 2.5 0 0 0 .8-.3 2.8 2.8 0 0 0 1-1Zm2.5-2.8c.4 0 .7-.1 1-.4.3-.3.4-.6.4-1s-.1-.7-.4-1c-.3-.3-.6-.4-1-.4s-.7.1-1 .4c-.3.3-.4.6-.4 1s.1.7.4 1c.3.3.6.4 1 .4Zm5.4 4 .2-.5v-.7a2.6 2.6 0 0 0-.3-.8 2.4 2.4 0 0 0-.7-.7 2.5 2.5 0 0 0-.8-.3 1.5 1.5 0 0 0-.8 0 1 1 0 0 0-.4.2 1 1 0 0 0-.2.5 1.5 1.5 0 0 0 0 .7v.4l.3.4.3.4a2.8 2.8 0 0 0 .8.5l.4.1h.7l.5-.2Z" fill-rule="evenodd"/></svg>',
|
||||
'document-gear-properties': '<svg height="24" width="24"><path d="M17.3 14.5a7.1 7.1 0 0 1 1.2.6l1.5-.5 1 1.8-1.2 1V18.7l1.2 1-1 1.6-1.5-.4a4.5 4.5 0 0 1-.6.3 7.1 7.1 0 0 1-.6.3L17 23h-2l-.3-1.5a7.4 7.4 0 0 1-.6-.3 4.5 4.5 0 0 1-.6-.3l-1.5.5-1-1.8 1.2-1a3.9 3.9 0 0 1 0-.6v-.7l-1.2-1 1-1.6 1.5.4a4.5 4.5 0 0 1 1.2-.6L15 13h2zM16 16a2 2 0 0 0-1.5.6A1.9 1.9 0 0 0 14 18c0 .6.2 1 .6 1.4s1 .6 1.5.6 1-.2 1.5-.6.6-.8.6-1.4-.2-1-.6-1.4A2 2 0 0 0 16 16z" fill-rule="evenodd"/><path d="M19 7.6V12h-2V9h-4V5H7v14h3v2H7a2 2 0 0 1-2-2V5c0-1.1.9-2 2-2h7.4z"/></svg>',
|
||||
'document-properties': '<svg width="24" height="24"><path d="M14.4 3H7a2 2 0 0 0-2 2v14c0 1.1.9 2 2 2h10a2 2 0 0 0 2-2V7.6L14.4 3ZM17 19H7V5h6v4h4v10Z" fill-rule="nonzero"/></svg>',
|
||||
'drag': '<svg width="24" height="24"><path d="M13 5h2v2h-2V5Zm0 4h2v2h-2V9ZM9 9h2v2H9V9Zm4 4h2v2h-2v-2Zm-4 0h2v2H9v-2Zm0 4h2v2H9v-2Zm4 0h2v2h-2v-2ZM9 5h2v2H9V5Z" fill-rule="evenodd"/></svg>',
|
||||
'dropbox': '<svg height="24" width="24"><path clip-rule="evenodd" d="m6.4 9.3-3-2a1 1 0 0 1 0-1.6l4-2.5a1 1 0 0 1 1 0L12 5.5l3.6-2.3a1 1 0 0 1 1 0l4 2.5a1 1 0 0 1 0 1.7l-3 1.9 3 1.9a1 1 0 0 1 0 1.6l-2.9 1.9V16c0 .3-.1.6-.4.8l-4.5 3a1 1 0 0 1-1.2 0l-4.4-3a1 1 0 0 1-.5-.9v-1l-3.2-2.2a1 1 0 0 1 0-1.6zm3 0L12 7.5l2.6 1.8L12 11zM8.9 15v.4l3.3 2.3 3.4-2.3v-.2L12 13l-3.1 2zm-1-10-2 1.4 2 1.3 2-1.3zm8.2 0-2 1.4 2 1.3 2-1.3zm-2 6.9 2-1.3 2 1.3-2 1.3-2-1.3zm-8.3 0 2-1.3L10 12l-2 1.3-2-1.3z" fill-rule="evenodd"/></svg>',
|
||||
'duplicate-column': '<svg width="24" height="24"><path d="M17 6v16h-7V6h7Zm-2 2h-3v12h3V8Zm-2-6v2H8v15H6V2h7Z"/></svg>',
|
||||
'duplicate-row': '<svg width="24" height="24"><path d="M22 11v7H6v-7h16Zm-2 2H8v3h12v-3Zm-1-6v2H4v5H2V7h17Z"/></svg>',
|
||||
'duplicate': '<svg width="24" height="24"><g fill-rule="nonzero"><path d="M16 3v2H6v11H4V5c0-1.1.9-2 2-2h10Zm3 8h-2V9h-7v10h9a2 2 0 0 1-2 2h-7a2 2 0 0 1-2-2V9c0-1.2.9-2 2-2h7a2 2 0 0 1 2 2v2Z"/><path d="M17 14h1a1 1 0 0 1 0 2h-1v1a1 1 0 0 1-2 0v-1h-1a1 1 0 0 1 0-2h1v-1a1 1 0 0 1 2 0v1Z"/></g></svg>',
|
||||
'edit-block': '<svg height="24" width="24"><path d="M14.7 3.2a2 2 0 0 1 2.9 0L20.4 6c.8.8.8 2.1 0 2.9l-10 10a2 2 0 0 1-1.1.6L3 20.6l1-6.2c.2-.5.3-.8.6-1.1zM6.1 14.7 5.5 18l3.4-.6-2.8-2.9zm1.4-1.5 2.9 2.9 6.2-6.3L13.8 7zm7.7-7.6L18 8.4l1-1L16 4.7z" fill-rule="evenodd"/></svg>',
|
||||
'edit-image': '<svg width="24" height="24"><path d="M18 16h2V7a2 2 0 0 0-2-2H7v2h11v9ZM6 17h15a1 1 0 0 1 0 2h-1v1a1 1 0 0 1-2 0v-1H6a2 2 0 0 1-2-2V7H3a1 1 0 1 1 0-2h1V4a1 1 0 1 1 2 0v13Zm3-5.3 1.3 2 3-4.7 3.7 6H7l2-3.3Z" fill-rule="nonzero"/></svg>',
|
||||
'embed-page': '<svg width="24" height="24"><path d="M19 6V5H5v14h2A13 13 0 0 1 19 6Zm0 1.4c-.8.8-1.6 2.4-2.2 4.6H19V7.4Zm0 5.6h-2.4c-.4 1.8-.6 3.8-.6 6h3v-6Zm-4 6c0-2.2.2-4.2.6-6H13c-.7 1.8-1.1 3.8-1.1 6h3Zm-4 0c0-2.2.4-4.2 1-6H9.6A12 12 0 0 0 8 19h3ZM4 3h16c.6 0 1 .4 1 1v16c0 .6-.4 1-1 1H4a1 1 0 0 1-1-1V4c0-.6.4-1 1-1Zm11.8 9c.4-1.9 1-3.4 1.8-4.5a9.2 9.2 0 0 0-4 4.5h2.2Zm-3.4 0a12 12 0 0 1 2.8-4 12 12 0 0 0-5 4h2.2Z" fill-rule="nonzero"/></svg>',
|
||||
'embed': '<svg width="24" height="24"><path d="M4 3h16c.6 0 1 .4 1 1v16c0 .6-.4 1-1 1H4a1 1 0 0 1-1-1V4c0-.6.4-1 1-1Zm1 2v14h14V5H5Zm4.8 2.6 5.6 4a.5.5 0 0 1 0 .8l-5.6 4A.5.5 0 0 1 9 16V8a.5.5 0 0 1 .8-.4Z" fill-rule="nonzero"/></svg>',
|
||||
'emoji': '<svg width="24" height="24"><path d="M9 11c.6 0 1-.4 1-1s-.4-1-1-1a1 1 0 0 0-1 1c0 .6.4 1 1 1Zm6 0c.6 0 1-.4 1-1s-.4-1-1-1a1 1 0 0 0-1 1c0 .6.4 1 1 1Zm-3 5.5c2.1 0 4-1.5 4.4-3.5H7.6c.5 2 2.3 3.5 4.4 3.5ZM12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16Zm0 14.5a6.5 6.5 0 1 1 0-13 6.5 6.5 0 0 1 0 13Z" fill-rule="nonzero"/></svg>',
|
||||
'evernote': '<svg width="24" height="24"><path fill-rule="evenodd" d="m9.5 3-.3.1-2.6 2.6C3.8 8.5 4 8.2 4 8.7c0 1.3.3 3 .6 4.3.5 2 1.1 3.2 2 3.7l1.2.3c1 .2 1.5.2 2.2.2 1.3 0 1.7 0 2.2-.4.3-.2.3-.4.4-1a38.4 38.4 0 0 1 .3-.4l.3.1 1.8.1h1.6v-1.8H14l-.1-.2a6.8 6.8 0 0 1-.5-1c-.2-.4-.3-.6-.5-.7a.8.8 0 0 0-.5-.1.9.9 0 0 0-.8.5l-.4 1.6-.3 1.5H9.4l-2-.3a1 1 0 0 1-.1-.2A10.1 10.1 0 0 1 6 9.7v-.2h4l.5-.5V5H12c.4.2.7.7.8 1.1a1 1 0 0 0 .2.6c.2.2.3.2 1 .3 1.2 0 2 .2 2.5.6.4.2.8.7 1 1.2.3 1.2.5 4 .3 6.4 0 1.8-.4 3.4-.8 4h-.7a8 8 0 0 1-.8 0c-.2 0-.3-.3-.2-.5a.5.5 0 0 1 .4-.3h.9v-1.8H16c-.7 0-.9 0-1.2.2-.9.4-1.4 1.2-1.4 2.1l.1.4.1.2a1 1 0 0 1 .1.2c.3.7.8 1.2 1.4 1.3l1.2.1h1.2a2 2 0 0 0 1-.8 9 9 0 0 0 1.1-4.3c.2-3 0-6.3-.4-7.7A4.3 4.3 0 0 0 16 5.3a7 7 0 0 0-1.3-.2 7.2 7.2 0 0 1-.4 0 5.5 5.5 0 0 1 0-.3c-.2-.5-.6-1-1.2-1.4a3 3 0 0 0-.5-.2c-.4-.2-.5-.2-1.8-.2a26.5 26.5 0 0 0-1.3 0Zm-.8 4v.7H7.2l.7-.8.8-.7v.7Zm7 3.5c-.4 0-.7.5-.9 1v.7h.3c.7 0 1.1.2 1.5.6l.3.2.3-.6V11a1 1 0 0 0-.6-.4 2 2 0 0 0-.8 0Z" clip-rule="evenodd"/></svg>',
|
||||
'explain': '<svg height="24" width="24"><path d="M11.6 3.1a4 4 0 0 1 3 6.7 6.8 6.8 0 0 1 1.7 1.2c1 0 2.2 0 3.5.3a1 1 0 0 1 .8.9v8a1 1 0 0 1-1.1 1c-1.5-.3-2.6-.3-3.7-.1-1 .2-2.2.6-3.7 1.4a1 1 0 0 1-.3.1 1 1 0 0 1-.6 0c-1.6-.9-2.7-1.3-3.8-1.5-1-.2-2.2-.2-3.7 0a1 1 0 0 1-1.1-1v-7.9a1 1 0 0 1 .8-1C4.7 11 5.8 11 7 11a6.8 6.8 0 0 1 1.3-1l.4-.2a4 4 0 0 1 3-6.7zm-4 10h-3V19a11.3 11.3 0 0 1 6 1v-5.7c-1.2-.6-2.2-1-3-1.2zm11 0a8.6 8.6 0 0 0-6 1.2V20l2.8-.9c1-.2 2-.2 3.2-.1zm-7-2a4.6 4.6 0 0 0-2.1.5l2.1 1 2.1-1a4.6 4.6 0 0 0-2.1-.5zm0-6a2 2 0 1 0 0 4 2 2 0 0 0 0-4z" fill-rule="evenodd"/><path d="M18.7 2.2a.3.3 0 0 1 .6 0 4.1 4.1 0 0 0 2.5 2.5.3.3 0 0 1 0 .6l-.2.1a4.1 4.1 0 0 0-2.3 2.4c-.1.3-.5.3-.6 0a4.1 4.1 0 0 0-2.3-2.4h-.2a.3.3 0 0 1 0-.7 4.1 4.1 0 0 0 2.5-2.5z"/></svg>',
|
||||
'export-pdf': '<svg width="24" height="24"><path fill-rule="evenodd" clip-rule="evenodd" d="M7 3h7.4L19 7.6V17h-2V9h-4V5H7v3H5V5c0-1.1.9-2 2-2Z"/><path d="M2.6 15.2v-1.9h1c.6 0 1-.2 1.4-.5.3-.3.5-.7.5-1.2s-.2-.9-.5-1.2a2 2 0 0 0-1.3-.4H1v5.2h1.6Zm.4-3h-.4v-1.1h.5l.6.1.2.5c0 .1 0 .3-.2.4l-.7.1Zm5.7 3 1-.1c.3 0 .5-.2.7-.4l.5-.8c.2-.3.2-.7.2-1.3v-1l-.5-.8c-.2-.3-.4-.5-.7-.6L8.7 10H6.3v5.2h2.4Zm-.4-1.1H8v-3h.4c.5 0 .8.2 1 .4l.2 1.1-.1 1-.3.3-.8.2Zm5.3 1.2V13h2v-1h-2v-1H16V10h-4v5.2h1.6Z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M15 17a1 1 0 1 0-2 0v3.1l-1.4-1a1 1 0 1 0-1.2 1.7l3.6 2.4 3.6-2.4a1 1 0 0 0-1.2-1.6l-1.4 1V17Z"/></svg>',
|
||||
'export-word': '<svg width="24" height="24"><path d="M9.5 7A1.5 1.5 0 0 1 11 8.4v7.1A1.5 1.5 0 0 1 9.6 17H2.5A1.5 1.5 0 0 1 1 15.6V8.5A1.5 1.5 0 0 1 2.4 7h7.1Zm-1 2.8-1 2.6-1-2.5v-.1a.6.6 0 0 0-1 0l-.1.1-.9 2.5-1-2.5v-.1a.6.6 0 0 0-1 .4v.1l1.5 4v.1a.6.6 0 0 0 1 0v-.1l1-2.5.9 2.5v.1a.6.6 0 0 0 1 0H8l1.6-4v-.2a.6.6 0 0 0-1.1-.4Z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M7 3h7.4L19 7.6V17h-2V9h-4V5H5c0-1.1.9-2 2-2ZM15 17a1 1 0 1 0-2 0v3.1l-1.4-1a1 1 0 1 0-1.2 1.7l3.6 2.4 3.6-2.4a1 1 0 0 0-1.2-1.6l-1.4 1V17Z"/></svg>',
|
||||
'export': '<svg width="24" height="24"><g fill-rule="nonzero"><path d="M14.4 3 18 7v1h-5V5H7v14h9a1 1 0 0 1 2 0c0 1-.8 2-1.9 2H7c-1 0-2-.8-2-1.9V5c0-1 .8-2 1.9-2h7.5Z"/><path d="M18.1 12c.5 0 .9.4.9 1 0 .5-.3 1-.8 1h-7.3c-.5 0-.9-.4-.9-1 0-.5.3-1 .8-1h7.3Z"/><path d="M16.4 9.2a1 1 0 0 1 1.4.2l2.4 3.6-2.4 3.6a1 1 0 0 1-1.7-1v-.2l1.7-2.4-1.6-2.4a1 1 0 0 1 .2-1.4Z"/></g></svg>',
|
||||
'exposure': '<svg width="24" height="24"><path d="M5 21a2 2 0 0 1-1.4-.6A2 2 0 0 1 3 19V5c0-.6.2-1 .6-1.4A2 2 0 0 1 5 3h14c.6 0 1 .2 1.4.6.4.4.6.8.6 1.4v14c0 .6-.2 1-.6 1.4a2 2 0 0 1-1.4.6H5Zm0-2h14V5L5 19Zm9.5-1v-2h-2v-1.5h2v-2H16v2h2V16h-2v2h-1.5ZM6 8.5h5V7H6v1.5Z"/></svg>',
|
||||
'fb': '<svg width="24" height="24"><path fill-rule="evenodd" d="M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-11 6.7V14H8.1v-2.5h2v-1C10 8.2 11.5 7 12.8 7H16v2.5h-2.4c-.8 0-1 .7-1 1.3v.7h3.2l-1 2.5h-2v5a7 7 0 1 0-2.8-.2Z" clip-rule="evenodd"/></svg>',
|
||||
'feedback': '<svg width="24" height="24"><path fill-rule="evenodd" d="M16 6c1 0 2 1 2 2v5c0 1-1 2-2 2h-3l-4 3v-3H8c-1 0-2-1-2-2V8c0-1 1-2 2-2h8Zm-7 5v1h4v-1H9Zm0-2v1h6V9H9Z"/></svg>',
|
||||
'fill': '<svg width="24" height="26"><path d="m16.6 12-9-9-1.4 1.4 2.4 2.4-5.2 5.1c-.5.6-.5 1.6 0 2.2L9 19.6a1.5 1.5 0 0 0 2.2 0l5.5-5.5c.5-.6.5-1.6 0-2.2ZM5.2 13 10 8.2l4.8 4.8H5.2ZM19 14.5s-2 2.2-2 3.5c0 1.1.9 2 2 2a2 2 0 0 0 2-2c0-1.3-2-3.5-2-3.5Z" fill-rule="nonzero"/></svg>',
|
||||
'fix-grammar': '<svg height="24" width="24"><path d="M22 14.5 14.4 22l-4.6-4.5 1.5-1.5 3 3 6.2-6z"/><path d="M15 15.8h-2.5l-1.2-3.5H5.6l-1.2 3.5H2L7.2 2h2.6zm-8.6-5.5h4.2l-2-5.7h-.2z" fill-rule="evenodd"/><path d="M18.1 2.2c.2-.3.6-.3.8 0a4.4 4.4 0 0 0 2.6 2.6.4.4 0 0 1 0 .7l-.2.1a4.4 4.4 0 0 0-2.4 2.5.4.4 0 0 1-.7 0 4.4 4.4 0 0 0-2.5-2.5h-.2a.4.4 0 0 1 0-.8 4.4 4.4 0 0 0 2.6-2.6z"/></svg>',
|
||||
'flickr': '<svg width="24" height="24"><path d="M8.2 15.3c1.7 0 3.1-1.4 3.1-3.1A3.2 3.2 0 0 0 8.1 9 3.2 3.2 0 0 0 5 12.2c0 1.7 1.4 3 3.2 3Zm7.6 0c1.7 0 3.2-1.4 3.2-3.1a3.2 3.2 0 0 0-6.3 0c0 1.7 1.4 3 3.1 3Z"/></svg>',
|
||||
'flip-horizontally': '<svg width="24" height="24"><path d="M14 19h2v-2h-2v2Zm4-8h2V9h-2v2ZM4 7v10c0 1.1.9 2 2 2h3v-2H6V7h3V5H6a2 2 0 0 0-2 2Zm14-2v2h2a2 2 0 0 0-2-2Zm-7 16h2V3h-2v18Zm7-6h2v-2h-2v2Zm-4-8h2V5h-2v2Zm4 12a2 2 0 0 0 2-2h-2v2Z" fill-rule="nonzero"/></svg>',
|
||||
'flip-vertically': '<svg width="24" height="24"><path d="M5 14v2h2v-2H5Zm8 4v2h2v-2h-2Zm4-14H7a2 2 0 0 0-2 2v3h2V6h10v3h2V6a2 2 0 0 0-2-2Zm2 14h-2v2a2 2 0 0 0 2-2ZM3 11v2h18v-2H3Zm6 7v2h2v-2H9Zm8-4v2h2v-2h-2ZM5 18c0 1.1.9 2 2 2v-2H5Z" fill-rule="nonzero"/></svg>',
|
||||
'folder': '<svg width="24" height="24"><path fill-rule="evenodd" d="M5.6 6a.6.6 0 0 0-.6.6v11.2a.6.6 0 0 0 .6.6h12.8a.6.6 0 0 0 .6-.6V9a.6.6 0 0 0-.6-.6h-7.2a1 1 0 0 1-.8-.4L9 6H5.6ZM3.8 4.8A2.6 2.6 0 0 1 5.6 4h4a1 1 0 0 1 .8.4l1.3 2h6.7A2.6 2.6 0 0 1 21 9v8.8a2.6 2.6 0 0 1-2.6 2.6H5.6A2.6 2.6 0 0 1 3 17.8V6.6c0-.7.3-1.4.8-1.8Z" clip-rule="evenodd"/></svg>',
|
||||
'footnote': '<svg width="24" height="24"><path d="M19 13c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 1 1 0-2h14Z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M19 4v6h-1V5h-1.5V4h2.6Z"/><path d="M12 18c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 1 1 0-2h7ZM14 8c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 0 1 0-2h9Z"/></svg>',
|
||||
'format-code': '<svg width="24" height="24"><path d="m10 22 2-7H6l9-13h2l-2 8h7L12 22h-2ZM6 2h7l-1.4 2H6V2Zm4.2 4H4v2h4.8l1.4-2Zm-2.7 4H2v2h4l1.5-2Z"/></svg>',
|
||||
'format-painter': '<svg width="24" height="24"><path d="M18 5V4c0-.5-.4-1-1-1H5a1 1 0 0 0-1 1v4c0 .6.5 1 1 1h12c.6 0 1-.4 1-1V7h1v4H9v9c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-7h8V5h-3Z" fill-rule="nonzero"/></svg>',
|
||||
'format': '<svg width="24" height="24"><path fill-rule="evenodd" d="M17 5a1 1 0 0 1 0 2h-4v11a1 1 0 0 1-2 0V7H7a1 1 0 1 1 0-2h10Z"/></svg>',
|
||||
'fullscreen': '<svg width="24" height="24"><path d="m15.3 10-1.2-1.3 2.9-3h-2.3a.9.9 0 1 1 0-1.7H19c.5 0 .9.4.9.9v4.4a.9.9 0 1 1-1.8 0V7l-2.9 3Zm0 4 3 3v-2.3a.9.9 0 1 1 1.7 0V19c0 .5-.4.9-.9.9h-4.4a.9.9 0 1 1 0-1.8H17l-3-2.9 1.3-1.2ZM10 15.4l-2.9 3h2.3a.9.9 0 1 1 0 1.7H5a.9.9 0 0 1-.9-.9v-4.4a.9.9 0 1 1 1.8 0V17l2.9-3 1.2 1.3ZM8.7 10 5.7 7v2.3a.9.9 0 0 1-1.7 0V5c0-.5.4-.9.9-.9h4.4a.9.9 0 0 1 0 1.8H7l3 2.9-1.3 1.2Z" fill-rule="nonzero"/></svg>',
|
||||
'gallery': '<svg width="24" height="24"><path fill-rule="nonzero" d="m5 15.7 2.3-2.2c.3-.3.7-.3 1 0L11 16l5.1-5c.3-.4.8-.4 1 0l2 1.9V8H5v7.7ZM5 18V19h3l1.8-1.9-2-2L5 17.9Zm14-3-2.5-2.4-6.4 6.5H19v-4ZM4 6h16c.6 0 1 .4 1 1v13c0 .6-.4 1-1 1H4a1 1 0 0 1-1-1V7c0-.6.4-1 1-1Zm6 7a2 2 0 1 1 0-4 2 2 0 0 1 0 4ZM4.5 4h15a.5.5 0 1 1 0 1h-15a.5.5 0 0 1 0-1Zm2-2h11a.5.5 0 1 1 0 1h-11a.5.5 0 0 1 0-1Z"/></svg>',
|
||||
'gamma': '<svg height="24" width="24"><path d="M11.5 16.3a.7.7 0 1 0 1.4 0V14l2.7-6a.7.7 0 1 0-1.2-.6l-2.2 5-2.3-4.9C9.6 7 8.7 7 8 7.2v1.1c.8-.2.9 0 1.2 1l2.3 4.7z"/><path d="M4 3h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zm1 2h14v14H5z" fill-rule="evenodd"/></svg>',
|
||||
'google-drive': '<svg height="24" width="24"><path clip-rule="evenodd" d="M8.7 4.5a1 1 0 0 1 .9-.5h4.8a1 1 0 0 1 .9.5l5.6 9.7a1 1 0 0 1 0 1l-2.4 4.2a1 1 0 0 1-.9.5H6.4a1 1 0 0 1-.9-.5l-2.4-4.2a1 1 0 0 1 0-1zM10.2 6l-4.5 7.7h2.5L12.7 6h-2.5zm4.2 1 4.4 7.7-1.2 2.2-4.4-7.7zM12 11.2l-1.5 2.5h3zm2.6 4.5H5.7L7 17.9h8.9l-1.3-2.2z" fill-rule="evenodd"/></svg>',
|
||||
'google-photos': '<svg width="24" height="24"><path fill-rule="evenodd" d="M12.4 3c-.8 0-1.4.7-1.4 1.4v3.2a5 5 0 0 0-8 4c0 .8.6 1.4 1.4 1.4h3.2a5 5 0 0 0-1 3 5 5 0 0 0 5 5c.8 0 1.4-.6 1.4-1.4v-3.2a5 5 0 0 0 8-4c0-.8-.7-1.4-1.4-1.4h-3.2a5 5 0 0 0-4-8Zm.6 8V5a3 3 0 0 1 2.4 3 3 3 0 0 1-2.4 3Zm-2 0H5a3 3 0 0 1 3-2.4 3 3 0 0 1 3 2.4Zm2 2a3 3 0 0 0 3 2.4 3 3 0 0 0 3-2.4h-6Zm-4.4 3a3 3 0 0 1 2.4-3v6a3 3 0 0 1-2.4-3Z" clip-rule="evenodd"/></svg>',
|
||||
'grayscale': '<svg height="24" width="24"><path d="M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zm12.4 0H19v3.6zm-2.8 0h-2.2l8.6 8.6v-2.2zm-5 0H5l14 14v-2.6z" fill-rule="evenodd"/></svg>',
|
||||
'help': '<svg width="24" height="24"><g fill-rule="evenodd"><path d="M12 5.5a6.5 6.5 0 0 0-6 9 6.3 6.3 0 0 0 1.4 2l1 1a6.3 6.3 0 0 0 3.6 1 6.5 6.5 0 0 0 6-9 6.3 6.3 0 0 0-1.4-2l-1-1a6.3 6.3 0 0 0-3.6-1ZM12 4a7.8 7.8 0 0 1 5.7 2.3A8 8 0 1 1 12 4Z"/><path d="M9.6 9.7a.7.7 0 0 1-.7-.8c0-1.1 1.5-1.8 3.2-1.8 1.8 0 3.2.8 3.2 2.4 0 1.4-.4 2.1-1.5 2.8-.2 0-.3.1-.3.2a2 2 0 0 0-.8.8.8.8 0 0 1-1.4-.6c.3-.7.8-1 1.3-1.5l.4-.2c.7-.4.8-.6.8-1.5 0-.5-.6-.9-1.7-.9-.5 0-1 .1-1.4.3-.2 0-.3.1-.3.2v-.2c0 .4-.4.8-.8.8Z" fill-rule="nonzero"/><circle cx="12" cy="16" r="1"/></g></svg>',
|
||||
'highlight-bg-color': '<svg width="24" height="24"><g fill-rule="evenodd"><path class="tox-icon-highlight-bg-color__color" d="M3 18h18v3H3z"/><path fill-rule="nonzero" d="M7.7 16.7H3l3.3-3.3-.7-.8L10.2 8l4 4.1-4 4.2c-.2.2-.6.2-.8 0l-.6-.7-1.1 1.1zm5-7.5L11 7.4l3-2.9a2 2 0 0 1 2.6 0L18 6c.7.7.7 2 0 2.7l-2.9 2.9-1.8-1.8-.5-.6"/></g></svg>',
|
||||
'highlight-key-points': '<svg height="24" width="24"><path d="M12 2c1.3 0 2.6.3 3.7.7l-.2.1a2.3 2.3 0 0 0-1.4 1.5 8 8 0 1 0 5.6 5.6 2.3 2.3 0 0 0 1.3-1v-.2h.1v-.2h.1v-.2A10 10 0 0 1 22 12 10 10 0 1 1 12 2z"/><path d="M12 11a1 1 0 0 1 1 1 1 1 0 0 1-1 1 1 1 0 0 1-1-1 1 1 0 0 1 1-1z"/><path d="M12 6.5a5.5 5.5 0 1 1 0 11 5.5 5.5 0 0 1 0-11zm0 2a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7z" fill-rule="evenodd"/><path d="M18.7 2.2a.3.3 0 0 1 .6 0 4.1 4.1 0 0 0 2.5 2.5.3.3 0 0 1 0 .6l-.2.1a4.1 4.1 0 0 0-2.3 2.4c-.1.3-.5.3-.6 0a4.1 4.1 0 0 0-2.3-2.4h-.2A.3.3 0 0 1 16 5a.4.4 0 0 1 0-.2.3.3 0 0 1 .2 0v-.1h.1a5.2 5.2 0 0 0 .2-.2h.1a4.2 4.2 0 0 0 2-2.3z"/></svg>',
|
||||
'home': '<svg width="24" height="24"><path fill-rule="nonzero" d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>',
|
||||
'horizontal-rule': '<svg width="24" height="24"><path d="M4 11h16v2H4z" fill-rule="evenodd"/></svg>',
|
||||
'huddle': '<svg height="24" width="24"><path d="M10.3 5.6V8h.8v-2l3.5 2 4.3-2.5L14.6 3l-4.3 2.6zM14.6 8v5.2l4.3-2.6V5.6L14.6 8zM6 13.3v2.5h.9v-2l3.4 2 4.3-2.5-4.3-2.6zm4.3 2.6V21l4.3-2.6v-5.1l-4.3 2.6z"/></svg>',
|
||||
'image-decorative': '<svg width="24" height="24"><path d="M12 10c.3 0 .5-.1.7-.3A1 1 0 0 0 13 9a1 1 0 0 0-.3-.7A1 1 0 0 0 12 8a1 1 0 0 0-.7.3 1 1 0 0 0-.3.7 1 1 0 0 0 1 1Zm0 6a3 3 0 0 1-1.8-.5A3 3 0 0 1 9.1 14a2 2 0 0 0-.2 0 3 3 0 0 1-2.5-1 3 3 0 0 1-.9-2.2A3 3 0 0 1 6.2 9a3.4 3.4 0 0 1-.5-1 3 3 0 0 1-.2-1 3 3 0 0 1 1-2.2A3 3 0 0 1 8.8 4H9a3 3 0 0 1 1.1-1.4A3 3 0 0 1 12 2a3 3 0 0 1 1.8.5c.5.4.9.9 1.1 1.5h.2a3 3 0 0 1 2.5.8 3 3 0 0 1 .9 2.3c0 .3 0 .7-.2 1a2.8 2.8 0 0 1-.5.9l.5 1 .2 1a3 3 0 0 1-1 2.2 3 3 0 0 1-2.4.9 1.8 1.8 0 0 0-.2 0 3 3 0 0 1-1.1 1.4 3 3 0 0 1-1.8.5Zm0 7a8.7 8.7 0 0 1 .7-3.5 9.2 9.2 0 0 1 2-2.8 9.2 9.2 0 0 1 2.8-2A8.6 8.6 0 0 1 21 14a8.6 8.6 0 0 1-.7 3.5 9.2 9.2 0 0 1-2 2.8 9.2 9.2 0 0 1-2.8 2 8.6 8.6 0 0 1-3.5.7Zm2.5-2.5a6.8 6.8 0 0 0 2.4-1.5 6.8 6.8 0 0 0 1.7-2.6c-1 .4-1.8 1-2.6 1.7a6.8 6.8 0 0 0-1.5 2.4ZM12 23a8.6 8.6 0 0 0-.7-3.5 9.2 9.2 0 0 0-2-2.9 9.2 9.2 0 0 0-2.8-1.9A8.6 8.6 0 0 0 3 14a8.7 8.7 0 0 0 .7 3.5 9.2 9.2 0 0 0 2 2.9 9.2 9.2 0 0 0 2.8 1.9 8.6 8.6 0 0 0 3.5.7Zm-2.5-2.5A6.8 6.8 0 0 1 7.2 19a6.8 6.8 0 0 1-1.6-2.6c.9.4 1.7 1 2.5 1.7a6.8 6.8 0 0 1 1.6 2.4Zm5.9-8.4c.3 0 .5-.1.8-.4.2-.2.3-.5.3-.8 0-.2 0-.4-.2-.6a1.3 1.3 0 0 0-.5-.4l-.8-.4a3 3 0 0 1-.2.5 3.8 3.8 0 0 1-.2.5 2.5 2.5 0 0 1-.3.4 4 4 0 0 1-.4.4l.8.6a.8.8 0 0 0 .3.1h.4ZM15 8.5l.8-.4.5-.4.2-.6a1.1 1.1 0 0 0-.3-.8 1 1 0 0 0-.8-.4 1.1 1.1 0 0 0-.7.2l-.8.6.4.4.3.4a3.9 3.9 0 0 1 .4 1Zm-4-2.3a2.7 2.7 0 0 1 1-.2 2.7 2.7 0 0 1 1 .2l.2-1.1c0-.3-.1-.6-.4-.8A1.2 1.2 0 0 0 12 4c-.3 0-.6.1-.8.3-.3.2-.4.5-.3.8v1.1Zm1 7.8c.3 0 .6-.1.8-.3.3-.2.4-.5.3-.8v-1.1a2.7 2.7 0 0 1-1.1.2 2.7 2.7 0 0 1-1-.2l-.2 1.1c0 .3.1.6.4.8.2.2.5.3.8.3ZM9 8.5a3 3 0 0 1 .4-1l.3-.4.4-.4-.8-.6A.9.9 0 0 0 9 6a1.2 1.2 0 0 0-.3 0c-.4 0-.6 0-.9.3a1.1 1.1 0 0 0-.3.8c0 .2 0 .4.2.6l.5.4.9.4Zm-.3 3.6H9l.3-.2.8-.6a5.5 5.5 0 0 1-.4-.4 2.1 2.1 0 0 1-.3-.4 3.8 3.8 0 0 1-.2-.5 3 3 0 0 1-.1-.5l-1 .4-.4.4a1.1 1.1 0 0 0-.2.6c0 .3.2.6.4.8.2.2.4.4.7.4Z"/></svg>',
|
||||
'image-enhancements': '<svg height="24" width="24"><path d="M5.3 21a2 2 0 0 1-1.5-.6 2 2 0 0 1-.6-1.4V5c0-.6.2-1 .6-1.4A2 2 0 0 1 5.2 3H13v2H5.2v14h14v-8h2v8c0 .6-.1 1-.5 1.4a2 2 0 0 1-1.4.6z"/><path d="M11 9a4 4 0 0 0 4 4 4 4 0 0 0-4 4 4 4 0 0 0-4-4 4 4 0 0 0 4-4zm5.5-6c.2 2.1 2.2 4.1 4.5 4.5-2.3.4-4.3 2.4-4.5 4.5A5.2 5.2 0 0 0 12 7.5 5.2 5.2 0 0 0 16.5 3z"/></svg>',
|
||||
'image-options': '<svg width="24" height="24"><path d="M6 10a2 2 0 0 0-2 2c0 1.1.9 2 2 2a2 2 0 0 0 2-2 2 2 0 0 0-2-2Zm12 0a2 2 0 0 0-2 2c0 1.1.9 2 2 2a2 2 0 0 0 2-2 2 2 0 0 0-2-2Zm-6 0a2 2 0 0 0-2 2c0 1.1.9 2 2 2a2 2 0 0 0 2-2 2 2 0 0 0-2-2Z" fill-rule="nonzero"/></svg>',
|
||||
'image': '<svg width="24" height="24"><path d="m5 15.7 3.3-3.2c.3-.3.7-.3 1 0L12 15l4.1-4c.3-.4.8-.4 1 0l2 1.9V5H5v10.7ZM5 18V19h3l2.8-2.9-2-2L5 17.9Zm14-3-2.5-2.4-6.4 6.5H19v-4ZM4 3h16c.6 0 1 .4 1 1v16c0 .6-.4 1-1 1H4a1 1 0 0 1-1-1V4c0-.6.4-1 1-1Zm6 8a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z" fill-rule="nonzero"/></svg>',
|
||||
'import-word': '<svg width="24" height="24"><path fill-rule="evenodd" clip-rule="evenodd" d="M7 3h7.4L19 7.6V15h-2V9h-4V5H5c0-1.1.9-2 2-2Z"/><path d="M9.5 7A1.5 1.5 0 0 1 11 8.4v7.1A1.5 1.5 0 0 1 9.6 17H2.5A1.5 1.5 0 0 1 1 15.6V8.5A1.5 1.5 0 0 1 2.4 7h7.1Zm-1 2.8-1 2.6-1-2.5v-.1a.6.6 0 0 0-1 0l-.1.1-.9 2.5-1-2.5v-.1a.6.6 0 0 0-1 .4v.1l1.5 4v.1a.6.6 0 0 0 1 0v-.1l1-2.5.9 2.5v.1a.6.6 0 0 0 1 0H8l1.6-4v-.2a.6.6 0 0 0-1.1-.4Z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M11.4 18.2a1 1 0 0 0 1.2 1.6l1.4-1V22a1 1 0 1 0 2 0v-3.1l1.4 1a1 1 0 0 0 1.2-1.7L15 15.8l-3.6 2.4Z"/></svg>',
|
||||
'improve-writing': '<svg height="24" width="24"><path d="M14.8 4.1c-1 0-2.2.5-3 1.3L6.5 11a.8.8 0 0 0-.2.6V16l7.9-8.3.7.8.6.7-4.4 4.6h4.7l2-2.2a4.5 4.5 0 0 0 1-1.7l2 .6a6.5 6.5 0 0 1-1.6 2.5L14 18.7a2.7 2.7 0 0 1-1 .7 2.6 2.6 0 0 1-1 .2H5.8L3.4 22 2 20.5l2.4-2.4a2 2 0 0 1 0-.5v-6c0-.8.2-1.5.7-2L10.5 4A6 6 0 0 1 14.8 2v2zM7.6 17.5H12a.8.8 0 0 0 .5-.2l1.3-1.4H9.2z" fill-rule="evenodd"/><path d="M18.8 2.2a.3.3 0 0 1 .6 0 4 4 0 0 0 2.4 2.5c.3.1.3.5 0 .7h-.2a4.1 4.1 0 0 0-2.2 2.4.3.3 0 0 1-.6 0 4.1 4.1 0 0 0-2.2-2.4h-.2c-.3-.2-.3-.6 0-.7 1.1-.4 2-1.3 2.4-2.5z"/></svg>',
|
||||
'indent': '<svg width="24" height="24"><path d="M7 5h12c.6 0 1 .4 1 1s-.4 1-1 1H7a1 1 0 1 1 0-2Zm5 4h7c.6 0 1 .4 1 1s-.4 1-1 1h-7a1 1 0 0 1 0-2Zm0 4h7c.6 0 1 .4 1 1s-.4 1-1 1h-7a1 1 0 0 1 0-2Zm-5 4h12a1 1 0 0 1 0 2H7a1 1 0 0 1 0-2Zm-2.6-3.8L6.2 12l-1.8-1.2a1 1 0 0 1 1.2-1.6l3 2a1 1 0 0 1 0 1.6l-3 2a1 1 0 1 1-1.2-1.6Z" fill-rule="evenodd"/></svg>',
|
||||
'info': '<svg width="24" height="24"><path d="M12 4a7.8 7.8 0 0 1 5.7 2.3A8 8 0 1 1 12 4Zm-1 3v2h2V7h-2Zm3 10v-1h-1v-5h-3v1h1v4h-1v1h4Z" fill-rule="evenodd"/></svg>',
|
||||
'insert-character': '<svg width="24" height="24"><path d="M15 18h4l1-2v4h-6v-3.3l1.4-1a6 6 0 0 0 1.8-2.9 6.3 6.3 0 0 0-.1-4.1 5.8 5.8 0 0 0-3-3.2c-.6-.3-1.3-.5-2.1-.5a5.1 5.1 0 0 0-3.9 1.8 6.3 6.3 0 0 0-1.3 6 6.2 6.2 0 0 0 1.8 3l1.4.9V20H4v-4l1 2h4v-.5l-2-1L5.4 15A6.5 6.5 0 0 1 4 11c0-1 .2-1.9.6-2.7A7 7 0 0 1 6.3 6C7.1 5.4 8 5 9 4.5c1-.3 2-.5 3.1-.5a8.8 8.8 0 0 1 5.7 2 7 7 0 0 1 1.7 2.3 6 6 0 0 1 .2 4.8c-.2.7-.6 1.3-1 1.9a7.6 7.6 0 0 1-3.6 2.5v.5Z" fill-rule="evenodd"/></svg>',
|
||||
'insert-time': '<svg width="24" height="24"><g fill-rule="nonzero"><path d="M12 19a7 7 0 1 0 0-14 7 7 0 0 0 0 14Zm0 2a9 9 0 1 1 0-18 9 9 0 0 1 0 18Z"/><path d="M16 12h-3V7c0-.6-.4-1-1-1a1 1 0 0 0-1 1v7h5c.6 0 1-.4 1-1s-.4-1-1-1Z"/></g></svg>',
|
||||
'instagram': '<svg height="24" width="24"><path clip-rule="evenodd" d="M10.1 9a4 4 0 1 1 4.4 6.6A4 4 0 0 1 10.1 9zm2.2 1.3a2 2 0 1 0 0 4 2 2 0 0 0 0-4z" fill-rule="evenodd"/><path d="M16 9.3a1.3 1.3 0 1 0 0-2.6 1.3 1.3 0 0 0 0 2.6z"/><path clip-rule="evenodd" d="M3 6a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3zm3-1a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1z" fill-rule="evenodd"/></svg>',
|
||||
'invert': '<svg height="24" width="24"><path d="M5 4 3.6 5.4l3.9 4C6.6 10.7 6 12.3 6 14a6 6 0 0 0 10.3 4.2l1.9 1.8 1.4-1.4z" fill-rule="evenodd"/><path d="M13.4 8.4c.9 1 1.6 2.1 2 3.2l2.6 2.6V14c0-5-6-10-6-10s-1 .8-2.1 2l1.4 1.5.7-.7 1.4 1.6z"/></svg>',
|
||||
'italic': '<svg width="24" height="24"><path d="m16.7 4.7-.1.9h-.3c-.6 0-1 0-1.4.3-.3.3-.4.6-.5 1.1l-2.1 9.8v.6c0 .5.4.8 1.4.8h.2l-.2.8H8l.2-.8h.2c1.1 0 1.8-.5 2-1.5l2-9.8.1-.5c0-.6-.4-.8-1.4-.8h-.3l.2-.9h5.8Z" fill-rule="evenodd"/></svg>',
|
||||
'language': '<svg width="24" height="24"><path d="M12 3a9 9 0 1 1 0 18 9 9 0 0 1 0-18Zm4.3 13.3c-.5 1-1.2 2-2 2.9a7.5 7.5 0 0 0 3.2-2.1l-.2-.2a6 6 0 0 0-1-.6Zm-8.6 0c-.5.2-.9.5-1.2.8.9 1 2 1.7 3.2 2a10 10 0 0 1-2-2.8Zm3.6-.8c-.8 0-1.6.1-2.2.3.5 1 1.2 1.9 2.1 2.7Zm1.5 0v3c.9-.8 1.6-1.7 2.1-2.7-.6-.2-1.4-.3-2.1-.3Zm-6-2.7H4.5c.2 1 .5 2.1 1 3h.3l1.3-1a10 10 0 0 1-.3-2Zm12.7 0h-2.3c0 .7-.1 1.4-.3 2l1.6 1.1c.5-1 .9-2 1-3.1Zm-3.8 0h-3V14c1 0 2 .1 2.7.4.2-.5.3-1 .3-1.6Zm-4.4 0h-3l.3 1.6c.8-.3 1.7-.4 2.7-.4v-1.3Zm-5.5-5c-.7 1-1.1 2.2-1.3 3.5h2.3c0-1 .2-1.8.5-2.6l-1.5-1Zm2.9 1.4v.1c-.2.6-.4 1.3-.4 2h3V9.4c-1 0-1.8-.1-2.6-.3Zm6.6 0h-.1l-2.4.3v1.8h3l-.5-2.1Zm3-1.4-.3.1-1.3.8c.3.8.5 1.6.5 2.6h2.3a7.5 7.5 0 0 0-1.3-3.5Zm-9 0 2 .2V5.5a9 9 0 0 0-2 2.2Zm3.5-2.3V8c.6 0 1.3 0 1.9-.2a9 9 0 0 0-2-2.3Zm-3-.7h-.1c-1.1.4-2.1 1-3 1.8l1.2.7a10 10 0 0 1 1.9-2.5Zm4.4 0 .1.1a10 10 0 0 1 1.8 2.4l1.1-.7a7.5 7.5 0 0 0-3-1.8Z"/></svg>',
|
||||
'line-height': '<svg width="24" height="24"><path d="M21 5a1 1 0 0 1 .1 2H13a1 1 0 0 1-.1-2H21zm0 4a1 1 0 0 1 .1 2H13a1 1 0 0 1-.1-2H21zm0 4a1 1 0 0 1 .1 2H13a1 1 0 0 1-.1-2H21zm0 4a1 1 0 0 1 .1 2H13a1 1 0 0 1-.1-2H21zM7 3.6l3.7 3.7a1 1 0 0 1-1.3 1.5h-.1L8 7.3v9.2l1.3-1.3a1 1 0 0 1 1.3 0h.1c.4.4.4 1 0 1.3v.1L7 20.4l-3.7-3.7a1 1 0 0 1 1.3-1.5h.1L6 16.7V7.4L4.7 8.7a1 1 0 0 1-1.3 0h-.1a1 1 0 0 1 0-1.3v-.1L7 3.6z"/></svg>',
|
||||
'line': '<svg width="24" height="24"><path d="m15 9-8 8H4v-3l8-8 3 3Zm1-1-3-3 1-1h1c-.2 0 0 0 0 0l2 2s0 .2 0 0v1l-1 1ZM4 18h16v2H4v-2Z" fill-rule="evenodd"/></svg>',
|
||||
'link': '<svg width="24" height="24"><path d="M6.2 12.3a1 1 0 0 1 1.4 1.4l-2 2a2 2 0 1 0 2.6 2.8l4.8-4.8a1 1 0 0 0 0-1.4 1 1 0 1 1 1.4-1.3 2.9 2.9 0 0 1 0 4L9.6 20a3.9 3.9 0 0 1-5.5-5.5l2-2Zm11.6-.6a1 1 0 0 1-1.4-1.4l2-2a2 2 0 1 0-2.6-2.8L11 10.3a1 1 0 0 0 0 1.4A1 1 0 1 1 9.6 13a2.9 2.9 0 0 1 0-4L14.4 4a3.9 3.9 0 0 1 5.5 5.5l-2 2Z" fill-rule="nonzero"/></svg>',
|
||||
'list-bull-circle': '<svg height="48" width="48"><path d="M8 14a3 3 0 1 0 6 0 3 3 0 0 0-6 0zm5 0a2 2 0 1 1-4 0 2 2 0 0 1 4 0zM8 24a3 3 0 1 0 6 0 3 3 0 0 0-6 0zm5 0a2 2 0 1 1-4 0 2 2 0 0 1 4 0zM8 34a3 3 0 1 0 6 0 3 3 0 0 0-6 0zm5 0a2 2 0 1 1-4 0 2 2 0 0 1 4 0z" fill-rule="evenodd"/><path d="M18 12h22v4H18zm0 10h22v4H18zm0 10h22v4H18z" opacity=".2"/></svg>',
|
||||
'list-bull-default': '<svg height="48" width="48"><path d="M14 14a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/><path d="M12 24a3 3 0 1 0 6 0 3 3 0 0 0-6 0zm5 0a2 2 0 1 1-4 0 2 2 0 0 1 4 0z" fill-rule="evenodd"/><path d="M16 31h6v6h-6z"/><path d="M16 31h6v6h-6z"/><path d="M18 12h22v4H18zm4 10h18v4H22zm4 10h14v4H26z" opacity=".2"/></svg>',
|
||||
'list-bull-disc': '<svg height="48" width="48"><path d="M14 14a3 3 0 1 1-6 0 3 3 0 0 1 6 0zm0 10a3 3 0 1 1-6 0 3 3 0 0 1 6 0zm0 10a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/><path d="M18 12h22v4H18zm0 10h22v4H18zm0 10h22v4H18z" opacity=".2"/></svg>',
|
||||
'list-bull-square': '<svg height="48" width="48"><path d="M8 21h6v6H8zm0 10h6v6H8zm0-20h6v6H8z"/><path d="M18 12h22v4H18zm0 10h22v4H18zm0 10h22v4H18z" opacity=".2"/></svg>',
|
||||
'list-num-default-rtl': '<svg width="48" height="48"><g fill-rule="evenodd"><path opacity=".2" d="M8 12h22v4H8zM8 22h22v4H8zM8 32h22v4H8z"/><path d="M37.4 17v-4.8h-.1l-1.5 1v-1.1l1.6-1.1h1.2v6zM33.3 17.1c-.5 0-.8-.3-.8-.7 0-.4.3-.7.8-.7.4 0 .7.3.7.7 0 .4-.3.7-.7.7zm1.7 5.7c0-1.2 1-2 2.2-2 1.3 0 2.1.8 2.1 1.8 0 .7-.3 1.2-1.3 2.2l-1.2 1v.2h2.6v1h-4.3v-.9l2-1.9c.8-.8 1-1.1 1-1.5 0-.5-.4-.8-1-.8-.5 0-.9.3-.9.9H35zm-1.7 4.3c-.5 0-.8-.3-.8-.7 0-.4.3-.7.8-.7.4 0 .7.3.7.7 0 .4-.3.7-.7.7zm3.2 7.3v-1h.7c.6 0 1-.3 1-.8 0-.4-.4-.7-1-.7s-1 .3-1 .8H35c0-1.1 1-1.8 2.2-1.8 1.2 0 2.1.6 2.1 1.6 0 .7-.4 1.2-1 1.3v.1c.7.1 1.3.7 1.3 1.4 0 1-1 1.9-2.4 1.9-1.3 0-2.2-.8-2.3-2h1.2c0 .6.5 1 1.1 1 .6 0 1-.4 1-1 0-.5-.3-.8-1-.8h-.7zm-3.3 2.7c-.4 0-.7-.3-.7-.7 0-.4.3-.7.7-.7.5 0 .8.3.8.7 0 .4-.3.7-.8.7z"/></g></svg>',
|
||||
'list-num-default': '<svg width="48" height="48"><g fill-rule="evenodd"><path opacity=".2" d="M18 12h22v4H18zM18 22h22v4H18zM18 32h22v4H18z"/><path d="M10 17v-4.8l-1.5 1v-1.1l1.6-1h1.2V17h-1.2Zm3.6.1c-.4 0-.7-.3-.7-.7 0-.4.3-.7.7-.7.5 0 .7.3.7.7 0 .4-.2.7-.7.7Zm-5 5.7c0-1.2.8-2 2.1-2s2.1.8 2.1 1.8c0 .7-.3 1.2-1.4 2.2l-1.1 1v.2h2.6v1H8.6v-.9l2-1.9c.8-.8 1-1.1 1-1.5 0-.5-.4-.8-1-.8-.5 0-.9.3-.9.9H8.5Zm6.3 4.3c-.5 0-.7-.3-.7-.7 0-.4.2-.7.7-.7.4 0 .7.3.7.7 0 .4-.3.7-.7.7ZM10 34.4v-1h.7c.6 0 1-.3 1-.8 0-.4-.4-.7-1-.7s-1 .3-1 .8H8.6c0-1.1 1-1.8 2.2-1.8 1.3 0 2.1.6 2.1 1.6 0 .7-.4 1.2-1 1.3v.1c.8.1 1.3.7 1.3 1.4 0 1-1 1.9-2.4 1.9-1.3 0-2.2-.8-2.3-2h1.2c0 .6.5 1 1.1 1 .7 0 1-.4 1-1 0-.5-.3-.8-1-.8h-.7Zm4.7 2.7c-.4 0-.7-.3-.7-.7 0-.4.3-.7.7-.7.5 0 .8.3.8.7 0 .4-.3.7-.8.7Z"/></g></svg>',
|
||||
'list-num-lower-alpha-rtl': '<svg width="48" height="48"><g fill-rule="evenodd"><path opacity=".2" d="M8 12h22v4H8zM8 22h22v4H8zM8 32h22v4H8z"/><path d="M36.5 16c-.9 0-1.5-.5-1.5-1.3s.6-1.3 1.8-1.4h1v-.4c0-.4-.2-.6-.7-.6-.4 0-.7.1-.8.4h-1.1c0-.8.8-1.4 2-1.4S39 12 39 13V16h-1.2v-.6c-.3.4-.8.7-1.4.7Zm.4-.8c.6 0 1-.4 1-.9V14h-1c-.5.1-.7.3-.7.6 0 .4.3.6.7.6ZM33.1 16.1c-.4 0-.7-.3-.7-.7 0-.4.3-.7.7-.7.5 0 .8.3.8.7 0 .4-.3.7-.8.7ZM37.7 26c-.7 0-1.2-.2-1.5-.7v.7H35v-6.3h1.2v2.5c.3-.5.8-.9 1.5-.9 1.1 0 1.8 1 1.8 2.4 0 1.5-.7 2.4-1.8 2.4Zm-.5-3.6c-.6 0-1 .5-1 1.3s.4 1.4 1 1.4c.7 0 1-.6 1-1.4 0-.8-.3-1.3-1-1.3ZM33.2 26.1c-.4 0-.7-.3-.7-.7 0-.4.3-.7.7-.7.5 0 .8.3.8.7 0 .4-.3.7-.8.7zm6 7h-1c-.1-.5-.4-.8-1-.8s-1 .5-1 1.4c0 1 .4 1.4 1 1.4.5 0 .9-.2 1-.7h1c0 1-.8 1.7-2 1.7-1.4 0-2.2-.9-2.2-2.4s.8-2.4 2.2-2.4c1.2 0 2 .7 2 1.7zm-6.1 3c-.5 0-.7-.3-.7-.7 0-.4.2-.7.7-.7.4 0 .7.3.7.7 0 .4-.3.7-.7.7z"/></g></svg>',
|
||||
'list-num-lower-alpha': '<svg width="48" height="48"><g fill-rule="evenodd"><path opacity=".2" d="M18 12h22v4H18zM18 22h22v4H18zM18 32h22v4H18z"/><path d="M10.3 15.2c.5 0 1-.4 1-.9V14h-1c-.5.1-.8.3-.8.6 0 .4.3.6.8.6Zm-.4.9c-1 0-1.5-.6-1.5-1.4 0-.8.6-1.3 1.7-1.4h1.1v-.4c0-.4-.2-.6-.7-.6-.5 0-.8.1-.9.4h-1c0-.8.8-1.4 2-1.4 1.1 0 1.8.6 1.8 1.6V16h-1.1v-.6h-.1c-.2.4-.7.7-1.3.7Zm4.6 0c-.5 0-.7-.3-.7-.7 0-.4.2-.7.7-.7.4 0 .7.3.7.7 0 .4-.3.7-.7.7Zm-3.2 10c-.6 0-1.2-.3-1.4-.8v.7H8.5v-6.3H10v2.5c.3-.5.8-.9 1.4-.9 1.2 0 1.9 1 1.9 2.4 0 1.5-.7 2.4-1.9 2.4Zm-.4-3.7c-.7 0-1 .5-1 1.3s.3 1.4 1 1.4c.6 0 1-.6 1-1.4 0-.8-.4-1.3-1-1.3Zm4 3.7c-.5 0-.7-.3-.7-.7 0-.4.2-.7.7-.7.4 0 .7.3.7.7 0 .4-.3.7-.7.7Zm-2.2 7h-1.2c0-.5-.4-.8-.9-.8-.6 0-1 .5-1 1.4 0 1 .4 1.4 1 1.4.5 0 .8-.2 1-.7h1c0 1-.8 1.7-2 1.7-1.4 0-2.2-.9-2.2-2.4s.8-2.4 2.2-2.4c1.2 0 2 .7 2 1.7Zm1.8 3c-.5 0-.8-.3-.8-.7 0-.4.3-.7.8-.7.4 0 .7.3.7.7 0 .4-.3.7-.7.7Z"/></g></svg>',
|
||||
'list-num-lower-greek-rtl': '<svg width="48" height="48"><g fill-rule="evenodd"><path opacity=".2" d="M8 12h22v4H8zM8 22h22v4H8zM8 32h22v4H8z"/><path d="M37.4 16c-1.2 0-2-.8-2-2.3 0-1.5.8-2.4 2-2.4.6 0 1 .4 1.3 1v-.9H40v3.2c0 .4.1.5.4.5h.2v.9h-.6c-.6 0-1-.2-1-.7h-.2c-.2.4-.7.8-1.3.8Zm.3-1c.6 0 1-.5 1-1.3s-.4-1.3-1-1.3-1 .5-1 1.3.4 1.4 1 1.4ZM33.3 16.1c-.5 0-.8-.3-.8-.7 0-.4.3-.7.8-.7.4 0 .7.3.7.7 0 .4-.3.7-.7.7ZM36 21.9c0-1.5.8-2.3 2.1-2.3 1.2 0 2 .6 2 1.6 0 .6-.3 1-.9 1.3.9.3 1.3.8 1.3 1.7 0 1.2-.7 1.9-1.8 1.9-.6 0-1.1-.3-1.4-.8v2.2H36V22Zm1.8 1.2v-1h.3c.5 0 .9-.2.9-.7 0-.5-.3-.8-.9-.8-.5 0-.8.3-.8 1v2.2c0 .8.4 1.3 1 1.3s1-.4 1-1-.4-1-1.2-1h-.3ZM33.3 26.1c-.5 0-.8-.3-.8-.7 0-.4.3-.7.8-.7.4 0 .7.3.7.7 0 .4-.3.7-.7.7ZM37.1 34.6 34.8 30h1.4l1.7 3.5 1.7-3.5h1.1l-2.2 4.6v.1c.5.8.7 1.4.7 1.8 0 .4-.2.8-.4 1-.2.2-.6.3-1 .3-.9 0-1.3-.4-1.3-1.2 0-.5.2-1 .5-1.7l.1-.2Zm.7 1a2 2 0 0 0-.4.9c0 .3.1.4.4.4.3 0 .4-.1.4-.4 0-.2-.1-.6-.4-1ZM33.3 36.1c-.5 0-.8-.3-.8-.7 0-.4.3-.7.8-.7.4 0 .7.3.7.7 0 .4-.3.7-.7.7Z"/></g></svg>',
|
||||
'list-num-lower-greek': '<svg width="48" height="48"><g fill-rule="evenodd"><path opacity=".2" d="M18 12h22v4H18zM18 22h22v4H18zM18 32h22v4H18z"/><path d="M10.5 15c.7 0 1-.5 1-1.3s-.3-1.3-1-1.3c-.5 0-.9.5-.9 1.3s.4 1.4 1 1.4Zm-.3 1c-1.1 0-1.8-.8-1.8-2.3 0-1.5.7-2.4 1.8-2.4.7 0 1.1.4 1.3 1h.1v-.9h1.2v3.2c0 .4.1.5.4.5h.2v.9h-.6c-.6 0-1-.2-1.1-.7h-.1c-.2.4-.7.8-1.4.8Zm5 .1c-.5 0-.8-.3-.8-.7 0-.4.3-.7.7-.7.5 0 .8.3.8.7 0 .4-.3.7-.8.7Zm-4.9 7v-1h.3c.6 0 1-.2 1-.7 0-.5-.4-.8-1-.8-.5 0-.8.3-.8 1v2.2c0 .8.4 1.3 1.1 1.3.6 0 1-.4 1-1s-.5-1-1.3-1h-.3ZM8.6 22c0-1.5.7-2.3 2-2.3 1.2 0 2 .6 2 1.6 0 .6-.3 1-.8 1.3.8.3 1.3.8 1.3 1.7 0 1.2-.8 1.9-1.9 1.9-.6 0-1.1-.3-1.3-.8v2.2H8.5V22Zm6.2 4.2c-.4 0-.7-.3-.7-.7 0-.4.3-.7.7-.7.5 0 .7.3.7.7 0 .4-.2.7-.7.7Zm-4.5 8.5L8 30h1.4l1.7 3.5 1.7-3.5h1.1l-2.2 4.6v.1c.5.8.7 1.4.7 1.8 0 .4-.1.8-.4 1-.2.2-.6.3-1 .3-.9 0-1.3-.4-1.3-1.2 0-.5.2-1 .5-1.7l.1-.2Zm.7 1a2 2 0 0 0-.4.9c0 .3.1.4.4.4.3 0 .4-.1.4-.4 0-.2-.1-.6-.4-1Zm4.5.5c-.5 0-.8-.3-.8-.7 0-.4.3-.7.8-.7.4 0 .7.3.7.7 0 .4-.3.7-.7.7Z"/></g></svg>',
|
||||
'list-num-lower-roman-rtl': '<svg width="48" height="48"><g fill-rule="evenodd"><path opacity=".2" d="M8 12h22v4H8zM8 22h22v4H8zM8 32h22v4H8z"/><path d="M32.9 16v-1.2h-1.3V16H33Zm0 10v-1.2h-1.3V26H33Zm0 10v-1.2h-1.3V36H33Z"/><path fill-rule="nonzero" d="M36 21h-1.5v5H36zM36 31h-1.5v5H36zM39 21h-1.5v5H39zM39 31h-1.5v5H39zM42 31h-1.5v5H42zM36 11h-1.5v5H36zM36 19h-1.5v1H36zM36 29h-1.5v1H36zM39 19h-1.5v1H39zM39 29h-1.5v1H39zM42 29h-1.5v1H42zM36 9h-1.5v1H36z"/></g></svg>',
|
||||
'list-num-lower-roman': '<svg width="48" height="48"><g fill-rule="evenodd"><path opacity=".2" d="M18 12h22v4H18zM18 22h22v4H18zM18 32h22v4H18z"/><path d="M15.1 16v-1.2h1.3V16H15Zm0 10v-1.2h1.3V26H15Zm0 10v-1.2h1.3V36H15Z"/><path fill-rule="nonzero" d="M12 21h1.5v5H12zM12 31h1.5v5H12zM9 21h1.5v5H9zM9 31h1.5v5H9zM6 31h1.5v5H6zM12 11h1.5v5H12zM12 19h1.5v1H12zM12 29h1.5v1H12zM9 19h1.5v1H9zM9 29h1.5v1H9zM6 29h1.5v1H6zM12 9h1.5v1H12z"/></g></svg>',
|
||||
'list-num-upper-alpha-rtl': '<svg width="48" height="48"><g fill-rule="evenodd"><path opacity=".2" d="M8 12h22v4H8zM8 22h22v4H8zM8 32h22v4H8z"/><path d="m39.3 17-.5-1.4h-2l-.5 1.4H35l2-6h1.6l2 6h-1.3Zm-1.6-4.7-.7 2.3h1.6l-.8-2.3ZM33.4 17c-.4 0-.7-.3-.7-.7 0-.4.3-.7.7-.7.5 0 .7.3.7.7 0 .4-.2.7-.7.7Zm4.7 9.9h-2.7v-6H38c1.2 0 1.9.6 1.9 1.5 0 .6-.5 1.2-1 1.3.7.1 1.3.7 1.3 1.5 0 1-.8 1.7-2 1.7Zm-1.4-5v1.5h1c.6 0 1-.3 1-.8 0-.4-.4-.7-1-.7h-1Zm0 4h1.1c.7 0 1.1-.3 1.1-.8 0-.6-.4-.9-1.1-.9h-1.1V26ZM33 27.1c-.5 0-.8-.3-.8-.7 0-.4.3-.7.8-.7.4 0 .7.3.7.7 0 .4-.3.7-.7.7Zm4.9 10c-1.8 0-2.8-1.1-2.8-3.1s1-3.1 2.8-3.1c1.4 0 2.5.9 2.6 2.2h-1.3c0-.7-.6-1.1-1.3-1.1-1 0-1.6.7-1.6 2s.6 2 1.6 2c.7 0 1.2-.4 1.4-1h1.2c-.1 1.3-1.2 2.2-2.6 2.2Zm-4.5 0c-.5 0-.8-.3-.8-.7 0-.4.3-.7.8-.7.4 0 .7.3.7.7 0 .4-.3.7-.7.7Z"/></g></svg>',
|
||||
'list-num-upper-alpha': '<svg width="48" height="48"><g fill-rule="evenodd"><path opacity=".2" d="M18 12h22v4H18zM18 22h22v4H18zM18 32h22v4H18z"/><path d="m12.6 17-.5-1.4h-2L9.5 17H8.3l2-6H12l2 6h-1.3ZM11 12.3l-.7 2.3h1.6l-.8-2.3Zm4.7 4.8c-.4 0-.7-.3-.7-.7 0-.4.3-.7.7-.7.5 0 .7.3.7.7 0 .4-.2.7-.7.7ZM11.4 27H8.7v-6h2.6c1.2 0 1.9.6 1.9 1.5 0 .6-.5 1.2-1 1.3.7.1 1.3.7 1.3 1.5 0 1-.8 1.7-2 1.7ZM10 22v1.5h1c.6 0 1-.3 1-.8 0-.4-.4-.7-1-.7h-1Zm0 4H11c.7 0 1.1-.3 1.1-.8 0-.6-.4-.9-1.1-.9H10V26Zm5.4 1.1c-.5 0-.8-.3-.8-.7 0-.4.3-.7.8-.7.4 0 .7.3.7.7 0 .4-.3.7-.7.7Zm-4.1 10c-1.8 0-2.8-1.1-2.8-3.1s1-3.1 2.8-3.1c1.4 0 2.5.9 2.6 2.2h-1.3c0-.7-.6-1.1-1.3-1.1-1 0-1.6.7-1.6 2s.6 2 1.6 2c.7 0 1.2-.4 1.4-1h1.2c-.1 1.3-1.2 2.2-2.6 2.2Zm4.5 0c-.5 0-.8-.3-.8-.7 0-.4.3-.7.8-.7.4 0 .7.3.7.7 0 .4-.3.7-.7.7Z"/></g></svg>',
|
||||
'list-num-upper-roman-rtl': '<svg width="48" height="48"><g fill-rule="evenodd"><path opacity=".2" d="M8 12h22v4H8zM8 22h22v4H8zM8 32h22v4H8z"/><path d="M31.6 17v-1.2H33V17h-1.3Zm0 10v-1.2H33V27h-1.3Zm0 10v-1.2H33V37h-1.3Z"/><path fill-rule="nonzero" d="M34.5 20H36v7h-1.5zM34.5 30H36v7h-1.5zM37.5 20H39v7h-1.5zM37.5 30H39v7h-1.5zM40.5 30H42v7h-1.5zM34.5 10H36v7h-1.5z"/></g></svg>',
|
||||
'list-num-upper-roman': '<svg width="48" height="48"><g fill-rule="evenodd"><path opacity=".2" d="M18 12h22v4H18zM18 22h22v4H18zM18 32h22v4H18z"/><path d="M15.1 17v-1.2h1.3V17H15Zm0 10v-1.2h1.3V27H15Zm0 10v-1.2h1.3V37H15Z"/><path fill-rule="nonzero" d="M12 20h1.5v7H12zM12 30h1.5v7H12zM9 20h1.5v7H9zM9 30h1.5v7H9zM6 30h1.5v7H6zM12 10h1.5v7H12z"/></g></svg>',
|
||||
'lock': '<svg width="24" height="24"><path d="M16.3 11c.2 0 .3 0 .5.2l.2.6v7.4c0 .3 0 .4-.2.6l-.6.2H7.8c-.3 0-.4 0-.6-.2a.7.7 0 0 1-.2-.6v-7.4c0-.3 0-.4.2-.6l.5-.2H8V8c0-.8.3-1.5.9-2.1.6-.6 1.3-.9 2.1-.9h2c.8 0 1.5.3 2.1.9.6.6.9 1.3.9 2.1v3h.3ZM10 8v3h4V8a1 1 0 0 0-.3-.7A1 1 0 0 0 13 7h-2a1 1 0 0 0-.7.3 1 1 0 0 0-.3.7Z" fill-rule="evenodd"/></svg>',
|
||||
'ltr': '<svg width="24" height="24"><path d="M11 5h7a1 1 0 0 1 0 2h-1v11a1 1 0 0 1-2 0V7h-2v11a1 1 0 0 1-2 0v-6c-.5 0-1 0-1.4-.3A3.4 3.4 0 0 1 7.8 10a3.3 3.3 0 0 1 0-2.8 3.4 3.4 0 0 1 1.8-1.8L11 5ZM4.4 16.2 6.2 15l-1.8-1.2a1 1 0 0 1 1.2-1.6l3 2a1 1 0 0 1 0 1.6l-3 2a1 1 0 1 1-1.2-1.6Z" fill-rule="evenodd"/></svg>',
|
||||
'math-equation': '<svg width="24" height="24"><path fill-rule="evenodd" clip-rule="evenodd" d="M9 4.8c.1-.5.5-.8 1-.8h10a1 1 0 1 1 0 2h-9.2L8.3 19.2a1 1 0 0 1-1.7.4l-3.4-4.2a1 1 0 0 1 1.6-1.2l2 2.5L9 4.8Zm9.7 5.5c.4.4.4 1 0 1.4L17 13.5l1.8 1.8a1 1 0 1 1-1.4 1.4L15.5 15l-1.8 1.8a1 1 0 0 1-1.4-1.4l1.8-1.8-1.8-1.8a1 1 0 0 1 1.4-1.4l1.8 1.8 1.8-1.8a1 1 0 0 1 1.4 0Z"/></svg>',
|
||||
'mentions': '<svg height="24" width="24"><path d="M12 21a8.8 8.8 0 0 1-3.5-.7 9 9 0 0 1-2.9-2 9 9 0 0 1-1.9-2.8A8.8 8.8 0 0 1 3 12c0-1.3.2-2.4.7-3.5a9 9 0 0 1 4.8-4.8A8.8 8.8 0 0 1 12 3c1.3 0 2.4.2 3.5.7a9.1 9.1 0 0 1 4.8 4.8A8.7 8.7 0 0 1 21 12v1.4a3 3 0 0 1-.9 2.2 3 3 0 0 1-2.2.9c-.5 0-1-.1-1.5-.4a3.8 3.8 0 0 1-1.1-1 4.8 4.8 0 0 1-1.5 1 4.3 4.3 0 0 1-1.8.4c-1.2 0-2.3-.4-3.2-1.3-.9-.9-1.3-2-1.3-3.2s.4-2.3 1.3-3.2c.9-.9 2-1.3 3.2-1.3s2.3.4 3.2 1.3c.9.9 1.3 2 1.3 3.2v1.4c0 .4.1.7.4 1 .3.3.6.4 1 .4s.7-.1 1-.4c.3-.3.4-.6.4-1V12c0-2-.7-3.8-2.1-5.2S14 4.7 12 4.7s-3.8.7-5.2 2.1S4.7 10 4.7 12s.7 3.8 2.1 5.2 3.2 2.1 5.2 2.1h4.5V21zm0-6.2c.8 0 1.4-.3 2-.8a2.7 2.7 0 0 0 .8-2c0-.8-.3-1.4-.8-2a2.7 2.7 0 0 0-2-.8c-.8 0-1.4.3-2 .8a2.7 2.7 0 0 0-.8 2c0 .8.3 1.4.8 2a2.7 2.7 0 0 0 2 .8z"/></svg>',
|
||||
'minus': '<svg width="24" height="24"><path d="M19 11a1 1 0 0 1 .1 2H5a1 1 0 0 1-.1-2H19Z"/></svg>',
|
||||
'more-drawer': '<svg width="24" height="24"><path d="M6 10a2 2 0 0 0-2 2c0 1.1.9 2 2 2a2 2 0 0 0 2-2 2 2 0 0 0-2-2Zm12 0a2 2 0 0 0-2 2c0 1.1.9 2 2 2a2 2 0 0 0 2-2 2 2 0 0 0-2-2Zm-6 0a2 2 0 0 0-2 2c0 1.1.9 2 2 2a2 2 0 0 0 2-2 2 2 0 0 0-2-2Z" fill-rule="nonzero"/></svg>',
|
||||
'new-chat': '<svg height="24" width="24"><path d="M12 5H6v13.7h12v-7.9h2v7.9a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h6z"/><path d="M17 3.3a1 1 0 0 1 1.3 0l1.4 1.3a1 1 0 0 1 0 1.4l-5.2 5a1 1 0 0 1-.5.3l-3 .6.5-3a1 1 0 0 1 .3-.5L17 3.3z"/></svg>',
|
||||
'new-document': '<svg width="24" height="24"><path d="M14.4 3H7a2 2 0 0 0-2 2v14c0 1.1.9 2 2 2h10a2 2 0 0 0 2-2V7.6L14.4 3ZM17 19H7V5h6v4h4v10Z" fill-rule="nonzero"/></svg>',
|
||||
'new-tab': '<svg width="24" height="24"><path d="m15 13 2-2v8H5V7h8l-2 2H7v8h8v-4Zm4-8v5.5l-2-2-5.6 5.5H10v-1.4L15.5 7l-2-2H19Z" fill-rule="evenodd"/></svg>',
|
||||
'non-breaking': '<svg width="24" height="24"><path d="M11 11H8a1 1 0 1 1 0-2h3V6c0-.6.4-1 1-1s1 .4 1 1v3h3c.6 0 1 .4 1 1s-.4 1-1 1h-3v3c0 .6-.4 1-1 1a1 1 0 0 1-1-1v-3Zm10 4v5H3v-5c0-.6.4-1 1-1s1 .4 1 1v3h14v-3c0-.6.4-1 1-1s1 .4 1 1Z" fill-rule="evenodd"/></svg>',
|
||||
'notice': '<svg width="24" height="24"><path d="M15.5 4 20 8.5v7L15.5 20h-7L4 15.5v-7L8.5 4h7ZM13 17v-2h-2v2h2Zm0-4V7h-2v6h2Z" fill-rule="evenodd" clip-rule="evenodd"/></svg>',
|
||||
'onedrive': '<svg height="24" width="24"><path d="M4 13.7h1zm16 .7h-1zm-11.7-4A4.4 4.4 0 0 1 12.1 8V6a6.4 6.4 0 0 0-5.6 3.4l1.8 1zM12.1 8c2 0 3.6 1.4 4 2.8l2-.5A6.3 6.3 0 0 0 12 6v2zm8.1 6.8-9.8-5.5-1 1.8 9.8 5.5 1-1.8zm-9.8-5.5A5 5 0 0 0 3 13.7h2A3 3 0 0 1 9.5 11l1-1.8zM3 13.7a5 5 0 0 0 1 2.8l1.5-1.1a3 3 0 0 1-.5-1.7zm15.8 1.6a2.3 2.3 0 0 1-2 1.4v2a4.3 4.3 0 0 0 3.9-2.6zm-2 1.4H8v2h8.7zm-8.8 0a3 3 0 0 1-2.5-1.3L4 16.5a5 5 0 0 0 4 2.2v-2zm12.7-.6c.2-.6.3-1.1.3-1.7h-2c0 .3 0 .6-.2.9zm.3-1.7c0-3-3.1-5.1-6-4l.8 1.9a2.3 2.3 0 0 1 3.2 2h2zm-6-4L4.4 15l.8 1.9 10.7-4.6-.7-1.8z"/></svg>',
|
||||
'ordered-list-rtl': '<svg width="24" height="24"><path d="M6 17h8a1 1 0 0 1 0 2H6a1 1 0 0 1 0-2Zm0-6h8a1 1 0 0 1 0 2H6a1 1 0 0 1 0-2Zm0-6h8a1 1 0 0 1 0 2H6a1 1 0 1 1 0-2Zm13-1v3.5a.5.5 0 1 1-1 0V5h-.5a.5.5 0 1 1 0-1H19Zm-1 8.8.2.2h1.3a.5.5 0 1 1 0 1h-1.6a1 1 0 0 1-.9-1V13c0-.4.3-.8.6-1l1.2-.4.2-.3a.2.2 0 0 0-.2-.2h-1.3a.5.5 0 0 1-.5-.5c0-.3.2-.5.5-.5h1.6c.5 0 .9.4.9 1v.1c0 .4-.3.8-.6 1l-1.2.4-.2.3Zm2 4.2v2c0 .6-.4 1-1 1h-1.5a.5.5 0 0 1 0-1h1.2a.3.3 0 1 0 0-.6h-1.3a.4.4 0 1 1 0-.8h1.3a.3.3 0 0 0 0-.6h-1.2a.5.5 0 1 1 0-1H19c.6 0 1 .4 1 1Z" fill-rule="evenodd"/></svg>',
|
||||
'ordered-list': '<svg width="24" height="24"><path d="M10 17h8c.6 0 1 .4 1 1s-.4 1-1 1h-8a1 1 0 0 1 0-2Zm0-6h8c.6 0 1 .4 1 1s-.4 1-1 1h-8a1 1 0 0 1 0-2Zm0-6h8c.6 0 1 .4 1 1s-.4 1-1 1h-8a1 1 0 1 1 0-2ZM6 4v3.5c0 .3-.2.5-.5.5a.5.5 0 0 1-.5-.5V5h-.5a.5.5 0 0 1 0-1H6Zm-1 8.8.2.2h1.3c.3 0 .5.2.5.5s-.2.5-.5.5H4.9a1 1 0 0 1-.9-1V13c0-.4.3-.8.6-1l1.2-.4.2-.3a.2.2 0 0 0-.2-.2H4.5a.5.5 0 0 1-.5-.5c0-.3.2-.5.5-.5h1.6c.5 0 .9.4.9 1v.1c0 .4-.3.8-.6 1l-1.2.4-.2.3ZM7 17v2c0 .6-.4 1-1 1H4.5a.5.5 0 0 1 0-1h1.2c.2 0 .3-.1.3-.3 0-.2-.1-.3-.3-.3H4.4a.4.4 0 1 1 0-.8h1.3c.2 0 .3-.1.3-.3 0-.2-.1-.3-.3-.3H4.5a.5.5 0 1 1 0-1H6c.6 0 1 .4 1 1Z" fill-rule="evenodd"/></svg>',
|
||||
'orientation': '<svg width="24" height="24"><path d="M7.3 6.4 1 13l6.4 6.5 6.5-6.5-6.5-6.5ZM3.7 13l3.6-3.7L11 13l-3.7 3.7-3.6-3.7ZM12 6l2.8 2.7c.3.3.3.8 0 1-.3.4-.9.4-1.2 0L9.2 5.7a.8.8 0 0 1 0-1.2L13.6.2c.3-.3.9-.3 1.2 0 .3.3.3.8 0 1.1L12 4h1a9 9 0 1 1-4.3 16.9l1.5-1.5A7 7 0 1 0 13 6h-1Z" fill-rule="nonzero"/></svg>',
|
||||
'other-actions': '<svg width="24" height="24"><path d="M8.5 13.5c.6 0 1 .2 1.4.6.4.4.6.8.6 1.4V19c0 .6-.2 1-.6 1.4a2 2 0 0 1-1.4.6H5a2 2 0 0 1-1.4-.6A2 2 0 0 1 3 19v-3.5c0-.6.2-1 .6-1.4a2 2 0 0 1 1.4-.6h3.5Zm10.5 0c.6 0 1 .2 1.4.6.4.4.6.8.6 1.4V19c0 .6-.2 1-.6 1.4a2 2 0 0 1-1.4.6h-3.5a2 2 0 0 1-1.4-.6 2 2 0 0 1-.6-1.4v-3.5c0-.6.2-1 .6-1.4a2 2 0 0 1 1.4-.6H19ZM5 19h3.5v-3.5H5V19Zm10.5 0H19v-3.5h-3.5V19Zm1-15.7a.5.5 0 0 1 1 0 5.5 5.5 0 0 0 3.2 3.3.5.5 0 0 1 0 .8l-.3.1a5.5 5.5 0 0 0-3 3.2.5.5 0 0 1-.8 0 5.5 5.5 0 0 0-3-3.2h-.3a.5.5 0 0 1 0-1 5.5 5.5 0 0 0 3.3-3.2Zm-8-.3c.6 0 1 .2 1.4.6.4.4.6.8.6 1.4v3.5c0 .6-.2 1-.6 1.4a2 2 0 0 1-1.4.6H5a2 2 0 0 1-1.4-.6A2 2 0 0 1 3 8.5V5c0-.6.2-1 .6-1.4A2 2 0 0 1 5 3h3.5ZM5 8.5h3.5V5H5v3.5Z"/></svg>',
|
||||
'outdent': '<svg width="24" height="24"><path d="M7 5h12c.6 0 1 .4 1 1s-.4 1-1 1H7a1 1 0 1 1 0-2Zm5 4h7c.6 0 1 .4 1 1s-.4 1-1 1h-7a1 1 0 0 1 0-2Zm0 4h7c.6 0 1 .4 1 1s-.4 1-1 1h-7a1 1 0 0 1 0-2Zm-5 4h12a1 1 0 0 1 0 2H7a1 1 0 0 1 0-2Zm1.6-3.8a1 1 0 0 1-1.2 1.6l-3-2a1 1 0 0 1 0-1.6l3-2a1 1 0 0 1 1.2 1.6L6.8 12l1.8 1.2Z" fill-rule="evenodd"/></svg>',
|
||||
'page-break': '<svg width="24" height="24"><g fill-rule="evenodd"><path d="M5 11c.6 0 1 .4 1 1s-.4 1-1 1a1 1 0 0 1 0-2Zm3 0h1c.6 0 1 .4 1 1s-.4 1-1 1H8a1 1 0 0 1 0-2Zm4 0c.6 0 1 .4 1 1s-.4 1-1 1a1 1 0 0 1 0-2Zm3 0h1c.6 0 1 .4 1 1s-.4 1-1 1h-1a1 1 0 0 1 0-2Zm4 0c.6 0 1 .4 1 1s-.4 1-1 1a1 1 0 0 1 0-2ZM7 3v5h10V3c0-.6.4-1 1-1s1 .4 1 1v7H5V3c0-.6.4-1 1-1s1 .4 1 1ZM6 22a1 1 0 0 1-1-1v-7h14v7c0 .6-.4 1-1 1a1 1 0 0 1-1-1v-5H7v5c0 .6-.4 1-1 1Z"/></g></svg>',
|
||||
'paragraph': '<svg width="24" height="24"><path fill-rule="evenodd" d="M10 5h7a1 1 0 0 1 0 2h-1v11a1 1 0 0 1-2 0V7h-2v11a1 1 0 0 1-2 0v-6c-.5 0-1 0-1.4-.3A3.4 3.4 0 0 1 6.8 10a3.3 3.3 0 0 1 0-2.8 3.4 3.4 0 0 1 1.8-1.8L10 5Z"/></svg>',
|
||||
'paste-column-after': '<svg width="24" height="24"><path fill-rule="evenodd" d="M12 1a3 3 0 0 1 2.8 2H18c1 0 2 .8 2 1.9V7h-2V5h-2v1c0 .6-.4 1-1 1H9a1 1 0 0 1-1-1V5H6v13h7v2H6c-1 0-2-.8-2-1.9V5c0-1 .8-2 1.9-2H9.2A3 3 0 0 1 12 1Zm8 7v12h-6V8h6Zm-1.5 1.5h-3v9h3v-9ZM12 3a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z"/></svg>',
|
||||
'paste-column-before': '<svg width="24" height="24"><path fill-rule="evenodd" d="M12 1a3 3 0 0 1 2.8 2H18c1 0 2 .8 2 1.9V18c0 1-.8 2-1.9 2H11v-2h7V5h-2v1c0 .6-.4 1-1 1H9a1 1 0 0 1-1-1V5H6v2H4V5c0-1 .8-2 1.9-2H9.2A3 3 0 0 1 12 1Zm-2 7v12H4V8h6ZM8.5 9.5h-3v9h3v-9ZM12 3a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z"/></svg>',
|
||||
'paste-row-after': '<svg width="24" height="24"><path fill-rule="evenodd" d="M12 1a3 3 0 0 1 2.8 2H18c1 0 2 .8 2 1.9V11h-2V5h-2v1c0 .6-.4 1-1 1H9a1 1 0 0 1-1-1V5H6v13h14c0 1-.8 2-1.9 2H6c-1 0-2-.8-2-1.9V5c0-1 .8-2 1.9-2H9.2A3 3 0 0 1 12 1Zm10 11v5H8v-5h14Zm-1.5 1.5h-11v2h11v-2ZM12 3a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z"/></svg>',
|
||||
'paste-row-before': '<svg width="24" height="24"><path fill-rule="evenodd" d="M12 1a3 3 0 0 1 2.8 2H18c1 0 2 .8 2 1.9V7h-2V5h-2v1c0 .6-.4 1-1 1H9a1 1 0 0 1-1-1V5H6v13h12v-4h2v4c0 1-.8 2-1.9 2H6c-1 0-2-.8-2-1.9V5c0-1 .8-2 1.9-2H9.2A3 3 0 0 1 12 1Zm10 7v5H8V8h14Zm-1.5 1.5h-11v2h11v-2ZM12 3a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z"/></svg>',
|
||||
'paste-text': '<svg width="24" height="24"><path d="M18 9V5h-2v1c0 .6-.4 1-1 1H9a1 1 0 0 1-1-1V5H6v13h3V9h9ZM9 20H6a2 2 0 0 1-2-2V5c0-1.1.9-2 2-2h3.2A3 3 0 0 1 12 1a3 3 0 0 1 2.8 2H18a2 2 0 0 1 2 2v4h1v12H9v-1Zm1.5-9.5v9h9v-9h-9ZM12 3a1 1 0 0 0-1 1c0 .5.4 1 1 1s1-.5 1-1-.4-1-1-1Zm0 9h6v2h-.5l-.5-1h-1v4h.8v1h-3.6v-1h.8v-4h-1l-.5 1H12v-2Z" fill-rule="nonzero"/></svg>',
|
||||
'paste': '<svg width="24" height="24"><path d="M18 9V5h-2v1c0 .6-.4 1-1 1H9a1 1 0 0 1-1-1V5H6v13h3V9h9ZM9 20H6a2 2 0 0 1-2-2V5c0-1.1.9-2 2-2h3.2A3 3 0 0 1 12 1a3 3 0 0 1 2.8 2H18a2 2 0 0 1 2 2v4h1v12H9v-1Zm1.5-9.5v9h9v-9h-9ZM12 3a1 1 0 0 0-1 1c0 .5.4 1 1 1s1-.5 1-1-.4-1-1-1Z" fill-rule="nonzero"/></svg>',
|
||||
'permanent-pen': '<svg width="24" height="24"><path d="M10.5 17.5 8 20H3v-3l3.5-3.5a2 2 0 0 1 0-3L14 3l1 1-7.3 7.3a1 1 0 0 0 0 1.4l3.6 3.6c.4.4 1 .4 1.4 0L20 9l1 1-7.6 7.6a2 2 0 0 1-2.8 0l-.1-.1Z" fill-rule="nonzero"/></svg>',
|
||||
'photo-filter': '<svg height="24" width="24"><path d="M4.4 21c-.3 0-.6-.2-.9-.5-.3-.3-.4-.6-.5-.9L19.6 3c.3 0 .6.3.9.5l.5.9zM3 14.7v-2.8L11.9 3h2.8zM3 7V5c0-.6.2-1 .6-1.4A2 2 0 0 1 5 3h2zm14 14 4-4v2c0 .6-.2 1-.6 1.4a2 2 0 0 1-1.4.6zm-7.7 0L21 9.3v2.8L12 21z"/></svg>',
|
||||
'pin': '<svg width="24" height="24"><path fill-rule="evenodd" d="M18 3a1 1 0 0 1 1 1v3.6a1 1 0 0 1-1 1h-1.6l2.5 6A1 1 0 0 1 18 16h-5v5h-2v-5H6a1 1 0 0 1-1-1.3l2.6-6H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h12ZM7 6.7h2A1 1 0 0 1 10 8l-2.6 6h9.2L14 8a1 1 0 0 1 .9-1.3h2V5H7v1.6Z"/></svg>',
|
||||
'plus': '<svg width="24" height="24"><path d="M12 4c.5 0 1 .4 1 .9V11h6a1 1 0 0 1 .1 2H13v6a1 1 0 0 1-2 .1V13H5a1 1 0 0 1-.1-2H11V5c0-.6.4-1 1-1Z"/></svg>',
|
||||
'preferences': '<svg width="24" height="24"><path d="m20.1 13.5-1.9.2a5.8 5.8 0 0 1-.6 1.5l1.2 1.5c.4.4.3 1 0 1.4l-.7.7a1 1 0 0 1-1.4 0l-1.5-1.2a6.2 6.2 0 0 1-1.5.6l-.2 1.9c0 .5-.5.9-1 .9h-1a1 1 0 0 1-1-.9l-.2-1.9a5.8 5.8 0 0 1-1.5-.6l-1.5 1.2a1 1 0 0 1-1.4 0l-.7-.7a1 1 0 0 1 0-1.4l1.2-1.5a6.2 6.2 0 0 1-.6-1.5l-1.9-.2a1 1 0 0 1-.9-1v-1c0-.5.4-1 .9-1l1.9-.2a5.8 5.8 0 0 1 .6-1.5L5.2 7.3a1 1 0 0 1 0-1.4l.7-.7a1 1 0 0 1 1.4 0l1.5 1.2a6.2 6.2 0 0 1 1.5-.6l.2-1.9c0-.5.5-.9 1-.9h1c.5 0 1 .4 1 .9l.2 1.9a5.8 5.8 0 0 1 1.5.6l1.5-1.2a1 1 0 0 1 1.4 0l.7.7c.3.4.4 1 0 1.4l-1.2 1.5a6.2 6.2 0 0 1 .6 1.5l1.9.2c.5 0 .9.5.9 1v1c0 .5-.4 1-.9 1ZM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" fill-rule="evenodd"/></svg>',
|
||||
'preview': '<svg width="24" height="24"><path d="M3.5 12.5c.5.8 1.1 1.6 1.8 2.3 2 2 4.2 3.2 6.7 3.2s4.7-1.2 6.7-3.2a16.2 16.2 0 0 0 2.1-2.8 15.7 15.7 0 0 0-2.1-2.8c-2-2-4.2-3.2-6.7-3.2a9.3 9.3 0 0 0-6.7 3.2A16.2 16.2 0 0 0 3.2 12c0 .2.2.3.3.5Zm-2.4-1 .7-1.2L4 7.8C6.2 5.4 8.9 4 12 4c3 0 5.8 1.4 8.1 3.8a18.2 18.2 0 0 1 2.8 3.7v1l-.7 1.2-2.1 2.5c-2.3 2.4-5 3.8-8.1 3.8-3 0-5.8-1.4-8.1-3.8a18.2 18.2 0 0 1-2.8-3.7 1 1 0 0 1 0-1Zm12-3.3a2 2 0 1 0 2.7 2.6 4 4 0 1 1-2.6-2.6Z" fill-rule="nonzero"/></svg>',
|
||||
'print': '<svg width="24" height="24"><path d="M18 8H6a3 3 0 0 0-3 3v6h2v3h14v-3h2v-6a3 3 0 0 0-3-3Zm-1 10H7v-4h10v4Zm.5-5c-.8 0-1.5-.7-1.5-1.5s.7-1.5 1.5-1.5 1.5.7 1.5 1.5-.7 1.5-1.5 1.5Zm.5-8H6v2h12V5Z" fill-rule="nonzero"/></svg>',
|
||||
'quote': '<svg width="24" height="24"><path d="M7.5 17h.9c.4 0 .7-.2.9-.6L11 13V8c0-.6-.4-1-1-1H6a1 1 0 0 0-1 1v4c0 .6.4 1 1 1h2l-1.3 2.7a1 1 0 0 0 .8 1.3Zm8 0h.9c.4 0 .7-.2.9-.6L19 13V8c0-.6-.4-1-1-1h-4a1 1 0 0 0-1 1v4c0 .6.4 1 1 1h2l-1.3 2.7a1 1 0 0 0 .8 1.3Z" fill-rule="nonzero"/></svg>',
|
||||
'reasoning': '<svg height="24" width="24"><path d="M14.3 22h-3.6v-2h3.6zM15.3 18.4H9.7v-2h5.6zM12.8 2c1.6 0 3.1.7 4.3 1.9C18.3 5 19 6.7 19 8.4c0 1.4-.5 2.8-1.7 3.9l-.2.2a3 3 0 0 0-.9 1.5 1 1 0 1 1-2-.4c.3-1.2 1-2 1.7-2.8.7-.6 1.1-1.5 1.1-2.4 0-1.2-.5-2.3-1.3-3.1a4.5 4.5 0 0 0-3-1.3h-.2c-1.2 0-2.3.5-3.2 1.3C8.5 6 8 7.3 8 8.4c0 .7.1 1.5 1 2.4.8.7 1.5 1.6 1.7 2.8a1 1 0 0 1-2 .4c0-.6-.4-1.2-1-1.7A5 5 0 0 1 6 8.4c0-1.7.7-3.3 2-4.5A6.6 6.6 0 0 1 12.4 2z"/></svg>',
|
||||
'redo': '<svg width="24" height="24"><path d="M17.6 10H12c-2.8 0-4.4 1.4-4.9 3.5-.4 2 .3 4 1.4 4.6a1 1 0 1 1-1 1.8c-2-1.2-2.9-4.1-2.3-6.8.6-3 3-5.1 6.8-5.1h5.6l-3.3-3.3a1 1 0 1 1 1.4-1.4l5 5a1 1 0 0 1 0 1.4l-5 5a1 1 0 0 1-1.4-1.4l3.3-3.3Z" fill-rule="nonzero"/></svg>',
|
||||
'reload': '<svg width="24" height="24"><g fill-rule="nonzero"><path d="m5 22.1-1.2-4.7v-.2a1 1 0 0 1 1-1l5 .4a1 1 0 1 1-.2 2l-2.2-.2a7.8 7.8 0 0 0 8.4.2 7.5 7.5 0 0 0 3.5-6.4 1 1 0 1 1 2 0 9.5 9.5 0 0 1-4.5 8 9.9 9.9 0 0 1-10.2 0l.4 1.4a1 1 0 1 1-2 .5ZM13.6 7.4c0-.5.5-1 1-.9l2.8.2a8 8 0 0 0-9.5-1 7.5 7.5 0 0 0-3.6 7 1 1 0 0 1-2 0 9.5 9.5 0 0 1 4.5-8.6 10 10 0 0 1 10.9.3l-.3-1a1 1 0 0 1 2-.5l1.1 4.8a1 1 0 0 1-1 1.2l-5-.4a1 1 0 0 1-.9-1Z"/></g></svg>',
|
||||
'remove-formatting': '<svg width="24" height="24"><path d="M13.2 6a1 1 0 0 1 0 .2l-2.6 10a1 1 0 0 1-1 .8h-.2a.8.8 0 0 1-.8-1l2.6-10H8a1 1 0 1 1 0-2h9a1 1 0 0 1 0 2h-3.8ZM5 18h7a1 1 0 0 1 0 2H5a1 1 0 0 1 0-2Zm13 1.5L16.5 18 15 19.5a.7.7 0 0 1-1-1l1.5-1.5-1.5-1.5a.7.7 0 0 1 1-1l1.5 1.5 1.5-1.5a.7.7 0 0 1 1 1L17.5 17l1.5 1.5a.7.7 0 0 1-1 1Z" fill-rule="evenodd"/></svg>',
|
||||
'remove': '<svg width="24" height="24"><path d="M16 7h3a1 1 0 0 1 0 2h-1v9a3 3 0 0 1-3 3H9a3 3 0 0 1-3-3V9H5a1 1 0 1 1 0-2h3V6a3 3 0 0 1 3-3h2a3 3 0 0 1 3 3v1Zm-2 0V6c0-.6-.4-1-1-1h-2a1 1 0 0 0-1 1v1h4Zm2 2H8v9c0 .6.4 1 1 1h6c.6 0 1-.4 1-1V9Zm-7 3a1 1 0 0 1 2 0v4a1 1 0 0 1-2 0v-4Zm4 0a1 1 0 0 1 2 0v4a1 1 0 0 1-2 0v-4Z" fill-rule="nonzero"/></svg>',
|
||||
'resize-handle': '<svg width="10" height="10"><g fill-rule="nonzero"><path d="M8.1 1.1A.5.5 0 1 1 9 2l-7 7A.5.5 0 1 1 1 8l7-7ZM8.1 5.1A.5.5 0 1 1 9 6l-3 3A.5.5 0 1 1 5 8l3-3Z"/></g></svg>',
|
||||
'resize': '<svg width="24" height="24"><path d="M4 5c0-.3.1-.5.3-.7.2-.2.4-.3.7-.3h6c.3 0 .5.1.7.3.2.2.3.4.3.7 0 .3-.1.5-.3.7a1 1 0 0 1-.7.3H7.4L18 16.6V13c0-.3.1-.5.3-.7.2-.2.4-.3.7-.3.3 0 .5.1.7.3.2.2.3.4.3.7v6c0 .3-.1.5-.3.7a1 1 0 0 1-.7.3h-6a1 1 0 0 1-.7-.3 1 1 0 0 1-.3-.7c0-.3.1-.5.3-.7.2-.2.4-.3.7-.3h3.6L6 7.4V11c0 .3-.1.5-.3.7a1 1 0 0 1-.7.3 1 1 0 0 1-.7-.3A1 1 0 0 1 4 11V5Z" fill-rule="evenodd"/></svg>',
|
||||
'restore-draft': '<svg width="24" height="24"><g fill-rule="evenodd"><path d="M17 13c0 .6-.4 1-1 1h-4V8c0-.6.4-1 1-1s1 .4 1 1v4h2c.6 0 1 .4 1 1Z"/><path d="M4.7 10H9a1 1 0 0 1 0 2H3a1 1 0 0 1-1-1V5a1 1 0 1 1 2 0v3l2.5-2.4a9.2 9.2 0 0 1 10.8-1.5A9 9 0 0 1 13.4 21c-2.4.1-4.7-.7-6.5-2.2a1 1 0 1 1 1.3-1.5 7.2 7.2 0 0 0 11.6-3.7 7 7 0 0 0-3.5-7.7A7.2 7.2 0 0 0 8 7L4.7 10Z" fill-rule="nonzero"/></g></svg>',
|
||||
'revert-changes': '<svg height="24" width="24"><path d="m8.9 18.8.4 3.2H13v-2h-2v-2.6a4.6 4.6 0 0 1-1.6-.6 5.9 5.9 0 0 1-1.3-1l-2.5 1-1-1.7 2.2-1.6a4 4 0 0 1-.2-.7 5.9 5.9 0 0 1 0-.8v-.8l.2-.8-2.2-1.6 1-1.7 2.5 1a6 6 0 0 1 1.2-.9l1.4-.6.4-2.6h2l.3 2.6A5.6 5.6 0 0 1 16 8.2l2.5-1 1 1.6-2.2 1.7.2.7v.8a5 5 0 0 1 0 1h2a2.6 2.6 0 0 0 0-.5V11.3l2.6-2-2.8-4.7-3 1.3a8.2 8.2 0 0 0-.5-.4 3.8 3.8 0 0 0-.6-.3L14.8 2H9.3l-.4 3.2a5 5 0 0 0-1.2.7l-3-1.3L2 9.4l2.6 2V12.6l-2.6 2 2.7 4.7 3-1.3a8 8 0 0 0 1.2.7z"/><path d="m14.5 20.3 2.1-2-2.1-2.2 1.4-1.4 2.1 2.1 2.1-2 1.4 1.3-2 2.1 2 2.1-1.4 1.4-2.1-2-2.1 2zM12 14a2 2 0 1 0 0-4 2 2 0 0 0 0 4z"/></svg>',
|
||||
'revision-history': '<svg width="24" height="24"><g fill-rule="evenodd"><path d="M17 13c0 .6-.4 1-1 1h-4V8c0-.6.4-1 1-1s1 .4 1 1v4h2c.6 0 1 .4 1 1Z"/><path d="M4.7 10H9a1 1 0 0 1 0 2H3a1 1 0 0 1-1-1V5a1 1 0 1 1 2 0v3l2.5-2.4a9.2 9.2 0 0 1 10.8-1.5A9 9 0 0 1 13.4 21c-2.4.1-4.7-.7-6.5-2.2a1 1 0 1 1 1.3-1.5 7.2 7.2 0 0 0 11.6-3.7 7 7 0 0 0-3.5-7.7A7.2 7.2 0 0 0 8 7L4.7 10Z" fill-rule="nonzero"/></g></svg>',
|
||||
'rotate-left': '<svg width="24" height="24"><path d="M4.7 10H9a1 1 0 0 1 0 2H3a1 1 0 0 1-1-1V5a1 1 0 1 1 2 0v3l2.5-2.4a9.2 9.2 0 0 1 10.8-1.5A9 9 0 0 1 13.4 21c-2.4.1-4.7-.7-6.5-2.2a1 1 0 1 1 1.3-1.5 7.2 7.2 0 0 0 11.6-3.7 7 7 0 0 0-3.5-7.7A7.2 7.2 0 0 0 8 7L4.7 10Z" fill-rule="nonzero"/></svg>',
|
||||
'rotate-right': '<svg width="24" height="24"><path d="M20 8V5a1 1 0 0 1 2 0v6c0 .6-.4 1-1 1h-6a1 1 0 0 1 0-2h4.3L16 7A7.2 7.2 0 0 0 7.7 6a7 7 0 0 0 3 13.1c1.9.1 3.7-.5 5-1.7a1 1 0 0 1 1.4 1.5A9.2 9.2 0 0 1 2.2 14c-.9-3.9 1-8 4.5-9.9 3.5-1.9 8-1.3 10.8 1.5L20 8Z" fill-rule="nonzero"/></svg>',
|
||||
'rtl': '<svg width="24" height="24"><path d="M8 5h8v2h-2v12h-2V7h-2v12H8v-7c-.5 0-1 0-1.4-.3A3.4 3.4 0 0 1 4.8 10a3.3 3.3 0 0 1 0-2.8 3.4 3.4 0 0 1 1.8-1.8L8 5Zm12 11.2a1 1 0 1 1-1 1.6l-3-2a1 1 0 0 1 0-1.6l3-2a1 1 0 1 1 1 1.6L18.4 15l1.8 1.2Z" fill-rule="evenodd"/></svg>',
|
||||
'saturation': '<svg width="24" height="24"><path fill-rule="evenodd" d="M12 20.5a6 6 0 1 1-5.9-10.2 6 6 0 1 1 11.8 0A6 6 0 1 1 12 20.5Zm-1.1-7.7a6 6 0 0 0-.2.3 4.3 4.3 0 0 1-2-1.3c.9.1 1.6.5 2.2 1Zm2.4.3a6 6 0 0 0-.2-.3 4.3 4.3 0 0 1 2.2-1 4.3 4.3 0 0 1-2 1.3ZM12.2 15a6.3 6.3 0 0 1-.4 0 4.3 4.3 0 0 0 .2 2.5 4.3 4.3 0 0 0 .2-2.5Zm.9 4.2a6 6 0 0 0 .8-4.5 6 6 0 0 0 3.4-2.8 4.3 4.3 0 0 1-1.3 8.4 4.3 4.3 0 0 1-2.9-1.1Zm-2.2 0a4.3 4.3 0 1 1-4.2-7.3 6 6 0 0 0 3.4 2.8 6 6 0 0 0 .8 4.5Zm-3-9.2a4.3 4.3 0 1 1 8.3 0 6 6 0 0 0-4.2 1.5A6 6 0 0 0 7.8 10Z"/></svg>',
|
||||
'save': '<svg width="24" height="24"><path d="M5 16h14a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-2c0-1.1.9-2 2-2Zm0 2v2h14v-2H5Zm10 0h2v2h-2v-2Zm-4-6.4L8.7 9.3a1 1 0 1 0-1.4 1.4l4 4c.4.4 1 .4 1.4 0l4-4a1 1 0 1 0-1.4-1.4L13 11.6V4a1 1 0 0 0-2 0v7.6Z" fill-rule="nonzero"/></svg>',
|
||||
'search': '<svg width="24" height="24"><path d="M16 17.3a8 8 0 1 1 1.4-1.4l4.3 4.4a1 1 0 0 1-1.4 1.4l-4.4-4.3Zm-5-.3a6 6 0 1 0 0-12 6 6 0 0 0 0 12Z" fill-rule="nonzero"/></svg>',
|
||||
'select-all': '<svg width="24" height="24"><path d="M3 5h2V3a2 2 0 0 0-2 2Zm0 8h2v-2H3v2Zm4 8h2v-2H7v2ZM3 9h2V7H3v2Zm10-6h-2v2h2V3Zm6 0v2h2a2 2 0 0 0-2-2ZM5 21v-2H3c0 1.1.9 2 2 2Zm-2-4h2v-2H3v2ZM9 3H7v2h2V3Zm2 18h2v-2h-2v2Zm8-8h2v-2h-2v2Zm0 8a2 2 0 0 0 2-2h-2v2Zm0-12h2V7h-2v2Zm0 8h2v-2h-2v2Zm-4 4h2v-2h-2v2Zm0-16h2V3h-2v2ZM7 17h10V7H7v10Zm2-8h6v6H9V9Z" fill-rule="nonzero"/></svg>',
|
||||
'selected': '<svg width="24" height="24"><path fill-rule="nonzero" d="M6 4h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2Zm3.6 10.9L7 12.3a.7.7 0 0 0-1 1L9.6 17 18 8.6a.7.7 0 0 0 0-1 .7.7 0 0 0-1 0l-7.4 7.3Z"/></svg>',
|
||||
'send': '<svg width="24" height="24"><path fill-rule="evenodd" clip-rule="evenodd" d="m13.3 22 7-18.3-18.3 7L9 15l4.3 7ZM18 6.8l-.7-.7L9.4 14l.7.7L18 6.8Z"/></svg>',
|
||||
'settings': '<svg width="24" height="24"><path d="M11 6h8c.6 0 1 .4 1 1s-.4 1-1 1h-8v.3c0 .2 0 .3-.2.5l-.6.2H7.8c-.3 0-.4 0-.6-.2a.7.7 0 0 1-.2-.6V8H5a1 1 0 1 1 0-2h2v-.3c0-.2 0-.3.2-.5l.5-.2h2.5c.3 0 .4 0 .6.2l.2.5V6ZM8 8h2V6H8v2Zm9 2.8v.2h2c.6 0 1 .4 1 1s-.4 1-1 1h-2v.3c0 .2 0 .3-.2.5l-.6.2h-2.4c-.3 0-.4 0-.6-.2a.7.7 0 0 1-.2-.6V13H5a1 1 0 0 1 0-2h8v-.3c0-.2 0-.3.2-.5l.6-.2h2.4c.3 0 .4 0 .6.2l.2.6ZM14 13h2v-2h-2v2Zm-3 2.8v.2h8c.6 0 1 .4 1 1s-.4 1-1 1h-8v.3c0 .2 0 .3-.2.5l-.6.2H7.8c-.3 0-.4 0-.6-.2a.7.7 0 0 1-.2-.6V18H5a1 1 0 0 1 0-2h2v-.3c0-.2 0-.3.2-.5l.5-.2h2.5c.3 0 .4 0 .6.2l.2.6ZM8 18h2v-2H8v2Z" fill-rule="evenodd"/></svg>',
|
||||
'sharpen': '<svg width="24" height="24"><path fill-rule="evenodd" d="m16 6 4 4-8 9-8-9 4-4h8Zm-4 10.2 5.5-6.2-.1-.1H12v-.3h5.1l-.2-.2H12V9h4.6l-.2-.2H12v-.3h4.1l-.2-.2H12V8h3.6l-.2-.2H8.7L6.5 10l.1.1H12v.3H6.9l.2.2H12v.3H7.3l.2.2H12v.3H7.7l.3.2h4v.3H8.2l.2.2H12v.3H8.6l.3.2H12v.3H9l.3.2H12v.3H9.5l.2.2H12v.3h-2l.2.2H12v.3h-1.6l.2.2H12v.3h-1.1l.2.2h.9v.3h-.7l.2.2h.5v.3h-.3l.3.2Z"/></svg>',
|
||||
'source-close': '<svg width="16" height="16"><path fill-rule="evenodd" d="M11.8 5 9 8l3 3a.6.6 0 0 1-1 .8L8 9l-3 3a.6.6 0 0 1-.8-1L7 8l-3-3a.6.6 0 0 1 1-.8L8 7l3-3a.6.6 0 0 1 .8 1Z"/></svg>',
|
||||
'source-file': '<svg width="16" height="16"><path fill-rule="evenodd" d="M4.3 2H10L13 5v7.7c0 .7-.6 1.3-1.3 1.3H4.3A1.3 1.3 0 0 1 3 12.7V3.3C3 2.6 3.6 2 4.3 2Zm0 10.7h7.4V6H9V3.3H4.3v9.4Z"/></svg>',
|
||||
'source-image': '<svg height="16" width="16"><path d="M2.7 2h10.6c.4 0 .7.3.7.7v10.6a.7.7 0 0 1-.7.7H2.7a.7.7 0 0 1-.7-.7V2.7c0-.4.3-.7.7-.7zm.6 8.5 2.2-2.2a.5.5 0 0 1 .7 0L8 10l2.8-2.7a.5.5 0 0 1 .7 0l1.3 1.3V3.3H3.3v7.2zm0 1.4v.8h2l2-2-1.4-1.3zM11 8.4l1.7 1.6v2.7h-6L11 8.4z" fill-rule="evenodd"/><path d="M8.7 6.3a1 1 0 1 1-2 0 1 1 0 0 1 2 0z"/></svg>',
|
||||
'source-link': '<svg height="16" width="16"><g fill-rule="evenodd"><path d="M3.5 12.6a2.7 2.7 0 0 1 0-3.8l.5-.6a.7.7 0 1 1 1 1l-.6.5a1.3 1.3 0 1 0 1.9 2L7.9 10a2.1 2.1 0 0 0 .6-2 .7.7 0 0 1 1.3-.3 3.4 3.4 0 0 1-1 3.2l-1.6 1.6a2.7 2.7 0 0 1-3.8 0z"/><path d="M12.6 3.5a2.7 2.7 0 0 1 0 3.7l-.6.6a.7.7 0 1 1-1-1l.6-.5a1.3 1.3 0 1 0-1.9-2L8.1 6a2.1 2.1 0 0 0-.6 2 .7.7 0 1 1-1.3.3 3.4 3.4 0 0 1 1-3.2l1.6-1.7a2.7 2.7 0 0 1 3.8 0z"/></g></svg>',
|
||||
'source-selection': '<svg width="16" height="16"><path d="M3.2 12.8h.7V14H2.6a.6.6 0 0 1-.6-.6v-1.3h1.2v.7ZM7.5 14H4.9v-1.2h2.6V14Zm5.9-4.8a.6.6 0 1 1 0 1.2h-2.2l2.6 2.6a.6.6 0 1 1-.8.8l-2.6-2.6v2.2a.6.6 0 1 1-1.2 0V9.2h4.2ZM3.2 11.1H2V8.5h1.2v2.6Zm0-3.6H2V4.9h1.2v2.6Zm10.8 0h-1.2V4.9H14v2.6ZM3.9 3.2h-.7v.7H2V2.6a.6.6 0 0 1 .6-.6h1.3v1.2ZM13.4 2a.6.6 0 0 1 .6.6v1.3h-1.2v-.7h-.7V2h1.3Zm-6 1.2H5V2h2.6v1.2Zm3.7 0H8.5V2h2.6v1.2Z"/></svg>',
|
||||
'sourcecode': '<svg width="24" height="24"><g fill-rule="nonzero"><path d="M9.8 15.7c.3.3.3.8 0 1-.3.4-.9.4-1.2 0l-4.4-4.1a.8.8 0 0 1 0-1.2l4.4-4.2c.3-.3.9-.3 1.2 0 .3.3.3.8 0 1.1L6 12l3.8 3.7ZM14.2 15.7c-.3.3-.3.8 0 1 .4.4.9.4 1.2 0l4.4-4.1c.3-.3.3-.9 0-1.2l-4.4-4.2a.8.8 0 0 0-1.2 0c-.3.3-.3.8 0 1.1L18 12l-3.8 3.7Z"/></g></svg>',
|
||||
'spell-check': '<svg width="24" height="24"><path d="M6 8v3H5V5c0-.3.1-.5.3-.7.2-.2.4-.3.7-.3h2c.3 0 .5.1.7.3.2.2.3.4.3.7v6H8V8H6Zm0-3v2h2V5H6Zm13 0h-3v5h3v1h-3a1 1 0 0 1-.7-.3 1 1 0 0 1-.3-.7V5c0-.3.1-.5.3-.7.2-.2.4-.3.7-.3h3v1Zm-5 1.5-.1.7c-.1.2-.3.3-.6.3.3 0 .5.1.6.3l.1.7V10c0 .3-.1.5-.3.7a1 1 0 0 1-.7.3h-3V4h3c.3 0 .5.1.7.3.2.2.3.4.3.7v1.5ZM13 10V8h-2v2h2Zm0-3V5h-2v2h2Zm3 5 1 1-6.5 7L7 15.5l1.3-1 2.2 2.2L16 12Z" fill-rule="evenodd"/></svg>',
|
||||
'stop': '<svg height="24" width="24"><path d="M19 17a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>',
|
||||
'strike-through': '<svg width="24" height="24"><g fill-rule="evenodd"><path d="M15.6 8.5c-.5-.7-1-1.1-1.3-1.3-.6-.4-1.3-.6-2-.6-2.7 0-2.8 1.7-2.8 2.1 0 1.6 1.8 2 3.2 2.3 4.4.9 4.6 2.8 4.6 3.9 0 1.4-.7 4.1-5 4.1A6.2 6.2 0 0 1 7 16.4l1.5-1.1c.4.6 1.6 2 3.7 2 1.6 0 2.5-.4 3-1.2.4-.8.3-2-.8-2.6-.7-.4-1.6-.7-2.9-1-1-.2-3.9-.8-3.9-3.6C7.6 6 10.3 5 12.4 5c2.9 0 4.2 1.6 4.7 2.4l-1.5 1.1Z"/><path d="M5 11h14a1 1 0 0 1 0 2H5a1 1 0 0 1 0-2Z" fill-rule="nonzero"/></g></svg>',
|
||||
'subscript': '<svg width="24" height="24"><path d="m10.4 10 4.6 4.6-1.4 1.4L9 11.4 4.4 16 3 14.6 7.6 10 3 5.4 4.4 4 9 8.6 13.6 4 15 5.4 10.4 10ZM21 19h-5v-1l1-.8 1.7-1.6c.3-.4.5-.8.5-1.2 0-.3 0-.6-.2-.7-.2-.2-.5-.3-.9-.3a2 2 0 0 0-.8.2l-.7.3-.4-1.1 1-.6 1.2-.2c.8 0 1.4.3 1.8.7.4.4.6.9.6 1.5s-.2 1.1-.5 1.6a8 8 0 0 1-1.3 1.3l-.6.6h2.6V19Z" fill-rule="nonzero"/></svg>',
|
||||
'suggestededits-badge': '<svg height="24" width="24"><path d="M11 3H7a2 2 0 0 0-2 2v14c0 1.1.9 2 2 2h10a2 2 0 0 0 2-2V9.7a6 6 0 0 1-2 .3v9H7V5h4a6 6 0 0 1 0-2z"/><path d="M15 13v-2h-4v2zm-3 2H9v2h3zm-1-8H9v2h2zm4 10v-2h-2v2z"/><path d="M21 4a4 4 0 1 1-8 0 4 4 0 0 1 8 0z" class="tox-icon--badge"/></svg>',
|
||||
'suggestededits': '<svg width="24" height="24"><path d="M7 3h7.4L19 7.6V19a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V5c0-1.1.9-2 2-2Zm0 16h10V9h-3V5H7v14Z" clip-rule="evenodd"/><path d="M9 7h2v2H9V7Zm2 4h4v2h-4v-2Zm-2 4h3v2H9v-2Z"/><path d="M9 7h2v2H9V7Zm4 8h2v2h-2v-2Z"/></svg>',
|
||||
'summarize': '<svg width="24" height="24"><path fill-rule="evenodd" d="M14 3c.3 0 .6 0 .8.2-1.3 1-1 3.3.7 4h.2l.3.2A2 2 0 0 1 14 9h-4a2 2 0 0 1-2-2H6v13h12V9.8a2.4 2.4 0 0 0 2 0V21a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h3a2 2 0 0 1 2-2h4Zm-4 4h4V5h-4v2Z"/><path d="M15 16a1 1 0 1 1 0 2H9a1 1 0 1 1 0-2h6Zm0-4a1 1 0 1 1 0 2H9a1 1 0 1 1 0-2h6Zm3.7-9.8a.3.3 0 0 1 .6 0 4.1 4.1 0 0 0 2.5 2.5.3.3 0 0 1 0 .6l-.2.1a4.1 4.1 0 0 0-2.1 2v.1l-.2.3V8a.3.3 0 0 1-.1 0H19a.3.3 0 0 1 0 .1.4.4 0 0 1-.2 0 .3.3 0 0 1-.1 0v-.1a.3.3 0 0 1-.1 0v-.1a4.1 4.1 0 0 0-2.3-2.4h-.2a.3.3 0 0 1 0-.7 4.1 4.1 0 0 0 2.5-2.5Z"/></svg>',
|
||||
'superscript': '<svg width="24" height="24"><path d="M15 9.4 10.4 14l4.6 4.6-1.4 1.4L9 15.4 4.4 20 3 18.6 7.6 14 3 9.4 4.4 8 9 12.6 13.6 8 15 9.4Zm5.9 1.6h-5v-1l1-.8 1.7-1.6c.3-.5.5-.9.5-1.3 0-.3 0-.5-.2-.7-.2-.2-.5-.3-.9-.3l-.8.2-.7.4-.4-1.2c.2-.2.5-.4 1-.5.3-.2.8-.2 1.2-.2.8 0 1.4.2 1.8.6.4.4.6 1 .6 1.6 0 .5-.2 1-.5 1.5l-1.3 1.4-.6.5h2.6V11Z" fill-rule="nonzero"/></svg>',
|
||||
'table-caption': '<svg width="24" height="24"><g fill-rule="nonzero"><rect width="12" height="2" x="3" y="4" rx="1"/><path d="M19 8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-8c0-1.1.9-2 2-2h14ZM5 15v3h6v-3H5Zm14 0h-6v3h6v-3Zm0-5h-6v3h6v-3ZM5 13h6v-3H5v3Z"/></g></svg>',
|
||||
'table-cell-classes': '<svg width="24" height="24"><g fill-rule="evenodd"><path fill-rule="nonzero" d="M13 4v9H3V6c0-1.1.9-2 2-2h8Zm-2 2H5v5h6V6Z"/><path fill-rule="nonzero" d="M13 4h6a2 2 0 0 1 2 2v7h-8v-2h6V6h-6V4Z" opacity=".2"/><path d="m18 20-2.6 1.6.7-3-2.4-2 3.1-.2 1.2-2.9 1.2 2.9 3.1.2-2.4 2 .7 3z"/><path fill-rule="nonzero" d="M3 13v5c0 1.1.9 2 2 2h8v-7h-2v5H5v-5H3Z" opacity=".2"/></g></svg>',
|
||||
'table-cell-properties': '<svg width="24" height="24"><path fill-rule="nonzero" d="M19 4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h14Zm-8 9H5v5h6v-5Zm8 0h-6v5h6v-5Zm-8-7H5v5h6V6Z"/></svg>',
|
||||
'table-cell-select-all': '<svg width="24" height="24"><g fill-rule="evenodd"><path fill-rule="nonzero" d="M19 4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h14Zm0 2H5v12h14V6Z"/><path d="M13 6v5h6v2h-6v5h-2v-5H5v-2h6V6h2Z" opacity=".2"/></g></svg>',
|
||||
'table-cell-select-inner': '<svg width="24" height="24"><g fill-rule="evenodd"><path fill-rule="nonzero" d="M19 4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h14Zm0 2H5v12h14V6Z" opacity=".2"/><path d="M13 6v5h6v2h-6v5h-2v-5H5v-2h6V6h2Z"/></g></svg>',
|
||||
'table-classes': '<svg width="24" height="24"><g fill-rule="evenodd"><path fill-rule="nonzero" d="M19 4a2 2 0 0 1 2 2v7h-8v7H5a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h14Zm-8 9H5v5h6v-5Zm8-7h-6v5h6V6Zm-8 0H5v5h6V6Z"/><path d="m18 20-2.6 1.6.7-3-2.4-2 3.1-.2 1.2-2.9 1.2 2.9 3.1.2-2.4 2 .7 3z"/></g></svg>',
|
||||
'table-delete-column': '<svg width="24" height="24"><path fill-rule="nonzero" d="M19 4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h14Zm-4 4h-2V6h-2v2H9V6H5v12h4v-2h2v2h2v-2h2v2h4V6h-4v2Zm.3.5 1 1.2-3 2.3 3 2.3-1 1.2L12 13l-3.3 2.6-1-1.2 3-2.3-3-2.3 1-1.2L12 11l3.3-2.5Z"/></svg>',
|
||||
'table-delete-row': '<svg width="24" height="24"><path fill-rule="nonzero" d="M19 4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h14Zm0 2H5v3h2.5v2H5v2h2.5v2H5v3h14v-3h-2.5v-2H19v-2h-2.5V9H19V6Zm-4.7 1.8 1.2 1L13 12l2.6 3.3-1.2 1-2.3-3-2.3 3-1.2-1L11 12 8.5 8.7l1.2-1 2.3 3 2.3-3Z"/></svg>',
|
||||
'table-delete-table': '<svg width="24" height="24"><g fill-rule="nonzero"><path d="M19 4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h14ZM5 6v12h14V6H5Z"/><path d="m14.4 8.6 1.1 1-2.4 2.4 2.4 2.4-1.1 1.1-2.4-2.4-2.4 2.4-1-1.1 2.3-2.4-2.3-2.4 1-1 2.4 2.3z"/></g></svg>',
|
||||
'table-insert-column-after': '<svg width="24" height="24"><path fill-rule="nonzero" d="M20 4c.6 0 1 .4 1 1v2a1 1 0 0 1-2 0V6h-8v12h8v-1a1 1 0 0 1 2 0v2c0 .5-.4 1-.9 1H5a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h15ZM9 13H5v5h4v-5Zm7-5c.5 0 1 .4 1 .9V11h2a1 1 0 0 1 .1 2H17v2a1 1 0 0 1-2 .1V13h-2a1 1 0 0 1-.1-2H15V9c0-.6.4-1 1-1ZM9 6H5v5h4V6Z"/></svg>',
|
||||
'table-insert-column-before': '<svg width="24" height="24"><path fill-rule="nonzero" d="M19 4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a1 1 0 0 1-1-1v-2a1 1 0 0 1 2 0v1h8V6H5v1a1 1 0 1 1-2 0V5c0-.6.4-1 1-1h15Zm0 9h-4v5h4v-5ZM8 8c.5 0 1 .4 1 .9V11h2a1 1 0 0 1 .1 2H9v2a1 1 0 0 1-2 .1V13H5a1 1 0 0 1-.1-2H7V9c0-.6.4-1 1-1Zm11-2h-4v5h4V6Z"/></svg>',
|
||||
'table-insert-row-above': '<svg width="24" height="24"><path fill-rule="nonzero" d="M6 4a1 1 0 1 1 0 2H5v6h14V6h-1a1 1 0 0 1 0-2h2c.6 0 1 .4 1 1v13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5c0-.6.4-1 1-1h2Zm5 10H5v4h6v-4Zm8 0h-6v4h6v-4ZM12 3c.5 0 1 .4 1 .9V6h2a1 1 0 0 1 0 2h-2v2a1 1 0 0 1-2 .1V8H9a1 1 0 0 1 0-2h2V4c0-.6.4-1 1-1Z"/></svg>',
|
||||
'table-insert-row-after': '<svg width="24" height="24"><path fill-rule="nonzero" d="M12 13c.5 0 1 .4 1 .9V16h2a1 1 0 0 1 .1 2H13v2a1 1 0 0 1-2 .1V18H9a1 1 0 0 1-.1-2H11v-2c0-.6.4-1 1-1Zm6 7a1 1 0 0 1 0-2h1v-6H5v6h1a1 1 0 0 1 0 2H4a1 1 0 0 1-1-1V6c0-1.1.9-2 2-2h14a2 2 0 0 1 2 2v13c0 .5-.4 1-.9 1H18ZM11 6H5v4h6V6Zm8 0h-6v4h6V6Z"/></svg>',
|
||||
'table-left-header': '<svg width="24" height="24"><path d="M19 4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h14Zm0 9h-4v5h4v-5Zm-6 0H9v5h4v-5Zm0-7H9v5h4V6Zm6 0h-4v5h4V6Z"/></svg>',
|
||||
'table-merge-cells': '<svg width="24" height="24"><path fill-rule="nonzero" d="M19 4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h14ZM5 15.5V18h3v-2.5H5Zm14-5h-9V18h9v-7.5ZM19 6h-4v2.5h4V6ZM8 6H5v2.5h3V6Zm5 0h-3v2.5h3V6Zm-8 7.5h3v-3H5v3Z"/></svg>',
|
||||
'table-row-numbering-rtl': '<svg width="24" height="24"><path d="M6 4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2H6Zm0 12h8v3H6v-3Zm11 0c.6 0 1 .4 1 1v1a1 1 0 0 1-2 0v-1c0-.6.4-1 1-1ZM6 11h8v3H6v-3Zm11 0c.6 0 1 .4 1 1v1a1 1 0 0 1-2 0v-1c0-.6.4-1 1-1ZM6 6h8v3H6V6Zm11 0c.6 0 1 .4 1 1v1a1 1 0 1 1-2 0V7c0-.6.4-1 1-1Z"/></svg>',
|
||||
'table-row-numbering': '<svg width="24" height="24"><path d="M18 4a2 2 0 0 1 2 2v13a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h12Zm0 12h-8v3h8v-3ZM7 16a1 1 0 0 0-1 1v1a1 1 0 0 0 2 0v-1c0-.6-.4-1-1-1Zm11-5h-8v3h8v-3ZM7 11a1 1 0 0 0-1 1v1a1 1 0 0 0 2 0v-1c0-.6-.4-1-1-1Zm11-5h-8v3h8V6ZM7 6a1 1 0 0 0-1 1v1a1 1 0 1 0 2 0V7c0-.6-.4-1-1-1Z"/></svg>',
|
||||
'table-row-properties': '<svg width="24" height="24"><path fill-rule="nonzero" d="M19 4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h14ZM5 15v3h6v-3H5Zm14 0h-6v3h6v-3Zm0-9h-6v3h6V6ZM5 9h6V6H5v3Z"/></svg>',
|
||||
'table-split-cells': '<svg width="24" height="24"><path fill-rule="nonzero" d="M19 4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h14ZM8 15.5H5V18h3v-2.5Zm11-5h-9V18h9v-7.5Zm-2.5 1 1 1-2 2 2 2-1 1-2-2-2 2-1-1 2-2-2-2 1-1 2 2 2-2Zm-8.5-1H5v3h3v-3ZM19 6h-4v2.5h4V6ZM8 6H5v2.5h3V6Zm5 0h-3v2.5h3V6Z"/></svg>',
|
||||
'table-top-header': '<svg width="24" height="24"><path d="M19 4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h14Zm-8 11H5v3h6v-3Zm8 0h-6v3h6v-3Zm0-5h-6v3h6v-3ZM5 13h6v-3H5v3Z"/></svg>',
|
||||
'table': '<svg width="24" height="24"><path fill-rule="nonzero" d="M19 4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h14ZM5 14v4h6v-4H5Zm14 0h-6v4h6v-4Zm0-6h-6v4h6V8ZM5 12h6V8H5v4Z"/></svg>',
|
||||
'template-add': '<svg width="24" height="24"><path fill-rule="evenodd" clip-rule="evenodd" d="M9 12v4H5a2 2 0 0 0-2 2v3h9.3a6 6 0 0 1-.3-2H5v-1h7a6 6 0 0 1 .8-2H11v-5l-.8-.6a3 3 0 1 1 3.6 0l-.8.6v4.7a6 6 0 0 1 2-1.9V12a5 5 0 1 0-6 0Z"/><path d="M18 15c.5 0 1 .4 1 .9V18h2a1 1 0 0 1 .1 2H19v2a1 1 0 0 1-2 .1V20h-2a1 1 0 0 1-.1-2H17v-2c0-.6.4-1 1-1Z"/></svg>',
|
||||
'template': '<svg width="24" height="24"><path d="M19 19v-1H5v1h14ZM9 16v-4a5 5 0 1 1 6 0v4h4a2 2 0 0 1 2 2v3H3v-3c0-1.1.9-2 2-2h4Zm4 0v-5l.8-.6a3 3 0 1 0-3.6 0l.8.6v5h2Z" fill-rule="nonzero"/></svg>',
|
||||
'temporary-placeholder': '<svg width="24" height="24"><g fill-rule="evenodd"><path d="M9 7.6V6h2.5V4.5a.5.5 0 1 1 1 0V6H15v1.6a8 8 0 1 1-6 0Zm-2.6 5.3a.5.5 0 0 0 .3.6c.3 0 .6 0 .6-.3l.1-.2a5 5 0 0 1 3.3-2.8c.3-.1.4-.4.4-.6-.1-.3-.4-.5-.6-.4a6 6 0 0 0-4.1 3.7Z"/><circle cx="14" cy="4" r="1"/><circle cx="12" cy="2" r="1"/><circle cx="10" cy="4" r="1"/></g></svg>',
|
||||
'text-color': '<svg width="24" height="24"><g fill-rule="evenodd"><path class="tox-icon-text-color__color" d="M3 18h18v3H3z"/><path d="M8.7 16h-.8a.5.5 0 0 1-.5-.6l2.7-9c.1-.3.3-.4.5-.4h2.8c.2 0 .4.1.5.4l2.7 9a.5.5 0 0 1-.5.6h-.8a.5.5 0 0 1-.4-.4l-.7-2.2c0-.3-.3-.4-.5-.4h-3.4c-.2 0-.4.1-.5.4l-.7 2.2c0 .3-.2.4-.4.4Zm2.6-7.6-.6 2a.5.5 0 0 0 .5.6h1.6a.5.5 0 0 0 .5-.6l-.6-2c0-.3-.3-.4-.5-.4h-.4c-.2 0-.4.1-.5.4Z"/></g></svg>',
|
||||
'text-size-decrease': '<svg width="24" height="24"><path fill-rule="evenodd" clip-rule="evenodd" d="M14 5a1 1 0 1 1 0 2h-4v11a1 1 0 1 1-2 0V7H4a1 1 0 0 1 0-2h10ZM14 12a1 1 0 1 0 0 2h6a1 1 0 1 0 0-2h-6Z"/></svg>',
|
||||
'text-size-increase': '<svg width="24" height="24"><path fill-rule="evenodd" clip-rule="evenodd" d="M14 5a1 1 0 1 1 0 2h-4v11a1 1 0 1 1-2 0V7H4a1 1 0 0 1 0-2h10ZM17 9a1 1 0 0 0-1 1v2h-2a1 1 0 1 0 0 2h2v2a1 1 0 1 0 2 0v-2h2a1 1 0 1 0 0-2h-2v-2c0-.6-.4-1-1-1Z"/></svg>',
|
||||
'toc': '<svg width="24" height="24"><path d="M5 5c.6 0 1 .4 1 1s-.4 1-1 1a1 1 0 1 1 0-2Zm3 0h11c.6 0 1 .4 1 1s-.4 1-1 1H8a1 1 0 1 1 0-2Zm-3 8c.6 0 1 .4 1 1s-.4 1-1 1a1 1 0 0 1 0-2Zm3 0h11c.6 0 1 .4 1 1s-.4 1-1 1H8a1 1 0 0 1 0-2Zm0-4c.6 0 1 .4 1 1s-.4 1-1 1a1 1 0 1 1 0-2Zm3 0h8c.6 0 1 .4 1 1s-.4 1-1 1h-8a1 1 0 0 1 0-2Zm-3 8c.6 0 1 .4 1 1s-.4 1-1 1a1 1 0 0 1 0-2Zm3 0h8c.6 0 1 .4 1 1s-.4 1-1 1h-8a1 1 0 0 1 0-2Z" fill-rule="evenodd"/></svg>',
|
||||
'transform-image': '<svg height="24" width="24"><path d="M3 21v-6h2V9H3V3h6v2h6V3h6v6h-2v6h2v6h-6v-2H9v2zm6-4h6v-2h2V9h-2V7H9v2H7v6h2zM5 7h2V5H5zm12 0h2V5h-2zm0 12h2v-2h-2zM5 19h2v-2H5z"/></svg>',
|
||||
'translate': '<svg width="24" height="24"><path d="m12.7 14.3-.3.7-.4.7-2.2-2.2-3.1 3c-.3.4-.8.4-1 0a.7.7 0 0 1 0-1l3.1-3A12.4 12.4 0 0 1 6.7 9H8a10.1 10.1 0 0 0 1.7 2.4c.5-.5 1-1.1 1.4-1.8l.9-2H4.7a.7.7 0 1 1 0-1.5h4.4v-.7c0-.4.3-.8.7-.8.4 0 .7.4.7.8v.7H15c.4 0 .8.3.8.7 0 .4-.4.8-.8.8h-1.4a12.3 12.3 0 0 1-1 2.4 13.5 13.5 0 0 1-1.7 2.3l1.9 1.8Zm4.3-3 2.7 7.3a.5.5 0 0 1-.4.7 1 1 0 0 1-1-.7l-.6-1.5h-3.4l-.6 1.5a1 1 0 0 1-1 .7.5.5 0 0 1-.4-.7l2.7-7.4a1 1 0 0 1 2 0Zm-2.2 4.4h2.4L16 12.5l-1.2 3.2Z" fill-rule="evenodd"/></svg>',
|
||||
'typography': '<svg width="24" height="24"><path fill-rule="evenodd" clip-rule="evenodd" d="M17 5a1 1 0 1 1 0 2h-4v11a1 1 0 1 1-2 0V7H7a1 1 0 0 1 0-2h10Z"/><path d="m17.5 14 .8-1.7 1.7-.8-1.7-.8-.8-1.7-.8 1.7-1.7.8 1.7.8.8 1.7ZM7 14l1 2 2 1-2 1-1 2-1-2-2-1 2-1 1-2Z"/></svg>',
|
||||
'underline': '<svg width="24" height="24"><path d="M16 5c.6 0 1 .4 1 1v5.5a4 4 0 0 1-.4 1.8l-1 1.4a5.3 5.3 0 0 1-5.5 1 5 5 0 0 1-1.6-1c-.5-.4-.8-.9-1.1-1.4a4 4 0 0 1-.4-1.8V6c0-.6.4-1 1-1s1 .4 1 1v5.5c0 .3 0 .6.2 1l.6.7a3.3 3.3 0 0 0 2.2.8 3.4 3.4 0 0 0 2.2-.8c.3-.2.4-.5.6-.8l.2-.9V6c0-.6.4-1 1-1ZM8 17h8c.6 0 1 .4 1 1s-.4 1-1 1H8a1 1 0 0 1 0-2Z" fill-rule="evenodd"/></svg>',
|
||||
'undo': '<svg width="24" height="24"><path d="M6.4 8H12c3.7 0 6.2 2 6.8 5.1.6 2.7-.4 5.6-2.3 6.8a1 1 0 0 1-1-1.8c1.1-.6 1.8-2.7 1.4-4.6-.5-2.1-2.1-3.5-4.9-3.5H6.4l3.3 3.3a1 1 0 1 1-1.4 1.4l-5-5a1 1 0 0 1 0-1.4l5-5a1 1 0 0 1 1.4 1.4L6.4 8Z" fill-rule="nonzero"/></svg>',
|
||||
'unlink': '<svg width="24" height="24"><path d="M6.2 12.3a1 1 0 0 1 1.4 1.4l-2 2a2 2 0 1 0 2.6 2.8l4.8-4.8a1 1 0 0 0 0-1.4 1 1 0 1 1 1.4-1.3 2.9 2.9 0 0 1 0 4L9.6 20a3.9 3.9 0 0 1-5.5-5.5l2-2Zm11.6-.6a1 1 0 0 1-1.4-1.4l2.1-2a2 2 0 1 0-2.7-2.8L11 10.3a1 1 0 0 0 0 1.4A1 1 0 1 1 9.6 13a2.9 2.9 0 0 1 0-4L14.4 4a3.9 3.9 0 0 1 5.5 5.5l-2 2ZM7.6 6.3a.8.8 0 0 1-1 1.1L3.3 4.2a.7.7 0 1 1 1-1l3.2 3.1ZM5.1 8.6a.8.8 0 0 1 0 1.5H3a.8.8 0 0 1 0-1.5H5Zm5-3.5a.8.8 0 0 1-1.5 0V3a.8.8 0 0 1 1.5 0V5Zm6 11.8a.8.8 0 0 1 1-1l3.2 3.2a.8.8 0 0 1-1 1L16 17Zm-2.2 2a.8.8 0 0 1 1.5 0V21a.8.8 0 0 1-1.5 0V19Zm5-3.5a.7.7 0 1 1 0-1.5H21a.8.8 0 0 1 0 1.5H19Z" fill-rule="nonzero"/></svg>',
|
||||
'unlock': '<svg width="24" height="24"><path d="M16 5c.8 0 1.5.3 2.1.9.6.6.9 1.3.9 2.1v3h-2V8a1 1 0 0 0-.3-.7A1 1 0 0 0 16 7h-2a1 1 0 0 0-.7.3 1 1 0 0 0-.3.7v3h.3c.2 0 .3 0 .5.2l.2.6v7.4c0 .3 0 .4-.2.6l-.6.2H4.8c-.3 0-.4 0-.6-.2a.7.7 0 0 1-.2-.6v-7.4c0-.3 0-.4.2-.6l.5-.2H11V8c0-.8.3-1.5.9-2.1.6-.6 1.3-.9 2.1-.9h2Z" fill-rule="evenodd"/></svg>',
|
||||
'unordered-list': '<svg width="24" height="24"><path d="M11 5h8c.6 0 1 .4 1 1s-.4 1-1 1h-8a1 1 0 0 1 0-2Zm0 6h8c.6 0 1 .4 1 1s-.4 1-1 1h-8a1 1 0 0 1 0-2Zm0 6h8c.6 0 1 .4 1 1s-.4 1-1 1h-8a1 1 0 0 1 0-2ZM4.5 6c0-.4.1-.8.4-1 .3-.4.7-.5 1.1-.5.4 0 .8.1 1 .4.4.3.5.7.5 1.1 0 .4-.1.8-.4 1-.3.4-.7.5-1.1.5-.4 0-.8-.1-1-.4-.4-.3-.5-.7-.5-1.1Zm0 6c0-.4.1-.8.4-1 .3-.4.7-.5 1.1-.5.4 0 .8.1 1 .4.4.3.5.7.5 1.1 0 .4-.1.8-.4 1-.3.4-.7.5-1.1.5-.4 0-.8-.1-1-.4-.4-.3-.5-.7-.5-1.1Zm0 6c0-.4.1-.8.4-1 .3-.4.7-.5 1.1-.5.4 0 .8.1 1 .4.4.3.5.7.5 1.1 0 .4-.1.8-.4 1-.3.4-.7.5-1.1.5-.4 0-.8-.1-1-.4-.4-.3-.5-.7-.5-1.1Z" fill-rule="evenodd"/></svg>',
|
||||
'unpin': '<svg width="24" height="24"><path fill-rule="evenodd" d="M21 19.6 19.7 21l-5-5H13v5h-2v-5H6a1 1 0 0 1-1-1.4l2.4-5.8-.1-.2H6a1 1 0 0 1-1-1V6.5l-2-2L4.4 3 21 19.6ZM7.5 14h5.1L9 10.4 7.5 14Z"/><path d="M18 3a1 1 0 0 1 1 1v3.6a1 1 0 0 1-1 1h-1.5l2.4 6a1 1 0 0 1 0 .2L15.4 11l-1.2-3a1 1 0 0 1 .9-1.4h2V5H9.2l-2-2H18Z"/></svg>',
|
||||
'unselected': '<svg width="24" height="24"><path fill-rule="nonzero" d="M6 4h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2Zm0 1a1 1 0 0 0-1 1v12c0 .6.4 1 1 1h12c.6 0 1-.4 1-1V6c0-.6-.4-1-1-1H6Z"/></svg>',
|
||||
'upload-from-device': '<svg width="24" height="24"><path d="M11 18.7c0 .6.4 1 1 1s1-.4 1-1v-3l1.5.8a1 1 0 0 0 1.3-.2 1 1 0 0 0-.3-1.4L12 12.6 8.5 15a1 1 0 0 0-.3 1.4c.3.4.9.5 1.3.2l1.5-.9v3.1Z"/><path d="M9.6 4a1 1 0 0 1 .8.4l1.3 2h6.7A2.6 2.6 0 0 1 21 8.8v8.2a2.6 2.6 0 0 1-2.6 2.6H16v-2h2.4a.6.6 0 0 0 .6-.6V9a.6.6 0 0 0-.6-.6h-7.2a1 1 0 0 1-.8-.4L9 5.9H5.6a.6.6 0 0 0-.6.6v10.6a.6.6 0 0 0 .6.6H8v2H5.6A2.6 2.6 0 0 1 3 17.1V6.5A2.6 2.6 0 0 1 5.6 4h4Z"/></svg>',
|
||||
'upload-from-link': '<svg height="24" width="24"><path d="M11.9 5.1a4 4 0 0 1 5.5 5.6l-.8.8a1 1 0 0 1-1.4-1.4l.8-.8a2 2 0 0 0-2.7-2.7l-2.4 2.3a3 3 0 0 0-.8 3 1 1 0 0 1-2 .4 5 5 0 0 1 1.4-4.8zm8.7 10.5a1 1 0 0 1-1.2 1.5l-1.4-1V19a1 1 0 0 1-2 0v-3l-1.4 1.1a1 1 0 0 1-1.2-1.5l3.6-2.9 3.6 2.9z"/><path d="M9.7 18.9A4 4 0 0 1 4 13.3l.8-.8A1 1 0 0 1 6.3 14l-.7.8a2 2 0 0 0 0 2.7c.7.8 2.1.9 2.9.1L11 15c.8-.8.7-1.8.5-2.8a1 1 0 0 1 1.9-.5 5 5 0 0 1-1.3 4.8z"/></svg>',
|
||||
'upload': '<svg width="24" height="24"><path d="M18 19v-2a1 1 0 0 1 2 0v3c0 .6-.4 1-1 1H5a1 1 0 0 1-1-1v-3a1 1 0 0 1 2 0v2h12ZM11 6.4 8.7 8.7a1 1 0 0 1-1.4-1.4l4-4a1 1 0 0 1 1.4 0l4 4a1 1 0 1 1-1.4 1.4L13 6.4V16a1 1 0 0 1-2 0V6.4Z" fill-rule="nonzero"/></svg>',
|
||||
'user': '<svg width="24" height="24"><path d="M12 24a12 12 0 1 1 0-24 12 12 0 0 1 0 24Zm-8.7-5.3a11 11 0 0 0 17.4 0C19.4 16.3 14.6 15 12 15c-2.6 0-7.4 1.3-8.7 3.7ZM12 13c2.2 0 4-2 4-4.5S14.2 4 12 4 8 6 8 8.5 9.8 13 12 13Z" fill-rule="nonzero"/></svg>',
|
||||
'vertical-align': '<svg width="24" height="24"><g fill-rule="nonzero"><rect width="18" height="2" x="3" y="11" rx="1"/><path d="M12 2c.6 0 1 .4 1 1v4l2-1.3a1 1 0 0 1 1.2 1.5l-.1.1-4.1 3-4-3a1 1 0 0 1 1-1.7l2 1.5V3c0-.6.4-1 1-1zm0 11.8 4 2.9a1 1 0 0 1-1 1.7l-2-1.5V21c0 .5-.4 1-.9 1H12a1 1 0 0 1-1-1v-4l-2 1.3a1 1 0 0 1-1.2-.1l-.1-.1a1 1 0 0 1 .1-1.3l.1-.1 4.1-3z"/></g></svg>',
|
||||
'vibrance': '<svg height="24" width="24"><path d="M12 20 22 4H2zm2.6-8 1.3-2H8l1.3 2h5.2zm-1.2 2h-2.8l1.4 2.2zm5-8L17 8H7L5.6 6h12.8z" fill-rule="evenodd"/></svg>',
|
||||
'visualblocks': '<svg width="24" height="24"><path d="M9 19v2H7v-2h2Zm-4 0v2a2 2 0 0 1-2-2h2Zm8 0v2h-2v-2h2Zm8 0a2 2 0 0 1-2 2v-2h2Zm-4 0v2h-2v-2h2ZM15 7a1 1 0 0 1 0 2v7a1 1 0 0 1-2 0V9h-1v7a1 1 0 0 1-2 0v-4a2.5 2.5 0 0 1-.2-5H15ZM5 15v2H3v-2h2Zm16 0v2h-2v-2h2ZM5 11v2H3v-2h2Zm16 0v2h-2v-2h2ZM5 7v2H3V7h2Zm16 0v2h-2V7h2ZM5 3v2H3c0-1.1.9-2 2-2Zm8 0v2h-2V3h2Zm6 0a2 2 0 0 1 2 2h-2V3ZM9 3v2H7V3h2Zm8 0v2h-2V3h2Z" fill-rule="evenodd"/></svg>',
|
||||
'visualchars': '<svg width="24" height="24"><path d="M10 5h7a1 1 0 0 1 0 2h-1v11a1 1 0 0 1-2 0V7h-2v11a1 1 0 0 1-2 0v-6c-.5 0-1 0-1.4-.3A3.4 3.4 0 0 1 6.8 10a3.3 3.3 0 0 1 0-2.8 3.4 3.4 0 0 1 1.8-1.8L10 5Z" fill-rule="evenodd"/></svg>',
|
||||
'vk': '<svg width="24" height="24"><path fill-rule="evenodd" d="M14.8 17.4a2 2 0 0 1-1.3.7 8 8 0 0 1-7.7-3.6c-1.4-2-2.2-4.4-2.6-5.7L3 8.6A2 2 0 0 1 5 6h2.5c.4 0 .8.1 1.2.4A2 2 0 0 1 9.8 6h3.5a2 2 0 0 1 1.4.6 2 2 0 0 1 1.5-.6h2.4a2 2 0 0 1 2 2.5c-.5 1.5-1.2 2.6-1.8 3.6a12.5 12.5 0 0 1 2 3.2A2 2 0 0 1 19 18h-2.6a2 2 0 0 1-1.3-.5 6.9 6.9 0 0 1-.3-.2Zm.5-2.5-1.2-1a3 3 0 0 0-.8-.2v2.4h-1.7a5.6 5.6 0 0 1-.3 0c-3-.7-4.7-3.7-5.6-6.1A38.1 38.1 0 0 1 5 8.1V8h2.5a80.5 80.5 0 0 1 1.2 2.5c.6 1.2 1.2 2 2 2.1V9l-.9-1h3.5v4.1a5 5 0 0 0 .4-.3c.7-.7 1.2-1.4 1.6-2.2l.2-.3.7-1.3h2.4a8.5 8.5 0 0 1-.8 2l-1.3 2-.3.3a10.5 10.5 0 0 1 2.8 3.8h-2.6l-.8-.9a20.6 20.6 0 0 0-.3-.3Z" clip-rule="evenodd"/></svg>',
|
||||
'warmth': '<svg height="24" width="24"><path d="M12 21.1c-1.4 0-2.6-.5-3.6-1.5s-1.5-2.1-1.5-3.5c0-.8.2-1.6.6-2.2A5 5 0 0 1 8.9 12v-6c0-.8.3-1.5 1-2.1a2.9 2.9 0 0 1 2-.9c.9 0 1.6.3 2.2.9.5.6.8 1.3.8 2.1v6a5 5 0 0 1 1.5 1.8A4.9 4.9 0 0 1 17 16c0 1.4-.5 2.6-1.4 3.5s-2.2 1.5-3.6 1.5zm-3-5h6c0-.5-.2-1-.4-1.4a3 3 0 0 0-.9-1l-.8-.6v-7a1 1 0 0 0-.3-.7A1 1 0 0 0 12 5a1 1 0 0 0-.7.3 1 1 0 0 0-.3.7v7l-.8.6a2.9 2.9 0 0 0-.9 1 3 3 0 0 0-.3 1.4z"/></svg>',
|
||||
'warning': '<svg width="24" height="24"><path d="M19.8 18.3c.2.5.3.9 0 1.2-.1.3-.5.5-1 .5H5.2c-.5 0-.9-.2-1-.5-.3-.3-.2-.7 0-1.2L11 4.7l.5-.5.5-.2c.2 0 .3 0 .5.2.2 0 .3.3.5.5l6.8 13.6ZM12 18c.3 0 .5-.1.7-.3.2-.2.3-.4.3-.7a1 1 0 0 0-.3-.7 1 1 0 0 0-.7-.3 1 1 0 0 0-.7.3 1 1 0 0 0-.3.7c0 .3.1.5.3.7.2.2.4.3.7.3Zm.7-3 .3-4a1 1 0 0 0-.3-.7 1 1 0 0 0-.7-.3 1 1 0 0 0-.7.3 1 1 0 0 0-.3.7l.3 4h1.4Z" fill-rule="evenodd"/></svg>',
|
||||
'web-search': '<svg width="24" height="24"><path fill-rule="evenodd" d="M12 3a9 9 0 1 1 0 18 9 9 0 0 1 0-18ZM5 13a7 7 0 0 0 4.8 5.6l-.2-.2A13.3 13.3 0 0 1 8 13H5Zm11 0a13.3 13.3 0 0 1-1.6 5.3c0 .2-.1.2-.2.3A7 7 0 0 0 19 13h-3Zm-6 0a11.3 11.3 0 0 0 1.4 4.4l.6 1 .6-1A11.3 11.3 0 0 0 14 13h-4Zm-.2-7.6A7 7 0 0 0 5 11h3a13.3 13.3 0 0 1 1.5-5.3l.2-.3Zm2.2.2a11.3 11.3 0 0 0-2 5.4h4a11.3 11.3 0 0 0-2-5.4Zm2.4 0A13.3 13.3 0 0 1 16 11h3a7 7 0 0 0-4.8-5.6l.2.3Z"/></svg>',
|
||||
'zoom-in': '<svg width="24" height="24"><path d="M16 17.3a8 8 0 1 1 1.4-1.4l4.3 4.4a1 1 0 0 1-1.4 1.4l-4.4-4.3Zm-5-.3a6 6 0 1 0 0-12 6 6 0 0 0 0 12Zm-1-9a1 1 0 0 1 2 0v6a1 1 0 0 1-2 0V8Zm-2 4a1 1 0 0 1 0-2h6a1 1 0 0 1 0 2H8Z" fill-rule="nonzero"/></svg>',
|
||||
'zoom-out': '<svg width="24" height="24"><path d="M16 17.3a8 8 0 1 1 1.4-1.4l4.3 4.4a1 1 0 0 1-1.4 1.4l-4.4-4.3Zm-5-.3a6 6 0 1 0 0-12 6 6 0 0 0 0 12Zm-3-5a1 1 0 0 1 0-2h6a1 1 0 0 1 0 2H8Z" fill-rule="nonzero"/></svg>',
|
||||
}
|
||||
});
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -1,7 +0,0 @@
|
||||
// Exports the "default" icons for usage with module loaders
|
||||
// Usage:
|
||||
// CommonJS:
|
||||
// require('tinymce/icons/default')
|
||||
// ES2015:
|
||||
// import 'tinymce/icons/default'
|
||||
require('./icons.js');
|
||||
@@ -0,0 +1,3 @@
|
||||
This is where language files should be placed.
|
||||
|
||||
Please DO NOT translate these directly, use this service instead: https://crowdin.com/project/tinymce
|
||||
@@ -1,9 +1,6 @@
|
||||
Copyright (c) 2025 Ephox Corporation DBA Tiny Technologies, Inc.
|
||||
# Software License Agreement
|
||||
|
||||
SOFTWARE TERMS & CONDITIONS OF USE
|
||||
**TinyMCE** – [<https://github.com/tinymce/tinymce>](https://github.com/tinymce/tinymce)
|
||||
Copyright (c) 2024, Ephox Corporation DBA Tiny Technologies, Inc.
|
||||
|
||||
Licensed under, and subject to the restrictions of:
|
||||
1. The terms of [GNU General Public License Version 2 or later](http://www.gnu.org/licenses/gpl.html); or
|
||||
2. The Tiny Technologies, Inc Software Terms & Conditions of Use presented at <https://about.tiny.cloud/legal/> and specifically the Tiny Self-Hosted License Agreement, unless you have signed a negotiated commercial license agreement with us in which case your use is governed by those terms. By use of this Software you have agreed to these Tiny Technologies, Inc Software Terms & Conditions of Use.
|
||||
|
||||
If you are uncertain about your licensing requirements, please contact <sales@tiny.cloud> for further clarification.
|
||||
Licensed under the terms of [GNU General Public License Version 2 or later](http://www.gnu.org/licenses/gpl.html).
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
// Exports the "dom" model for usage with module loaders
|
||||
// Usage:
|
||||
// CommonJS:
|
||||
// require('tinymce/models/dom')
|
||||
// ES2015:
|
||||
// import 'tinymce/models/dom'
|
||||
require('./model.js');
|
||||
File diff suppressed because it is too large
Load Diff
Vendored
+4
-1
File diff suppressed because one or more lines are too long
@@ -1,21 +0,0 @@
|
||||
Below is a list of third party libraries that this software uses:
|
||||
----------------------------------------------------------------
|
||||
|
||||
dompurify
|
||||
owner: Mario Heiderich
|
||||
repo: https://github.com/cure53/DOMPurify
|
||||
version: 3.3.2
|
||||
license: MPL-2.0 OR Apache-2.0
|
||||
|
||||
prismjs
|
||||
owner: Lea Verou
|
||||
repo: https://github.com/PrismJS/prism
|
||||
version: 1.25.0
|
||||
license: MIT
|
||||
|
||||
|
||||
prism-themes
|
||||
owner: Lea Verou
|
||||
repo: https://github.com/PrismJS/prism-themes
|
||||
version: 1.9.0
|
||||
license: MIT
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"name": "tinymce",
|
||||
"version": "8.5.1",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/tinymce/tinymce.git",
|
||||
"directory": "modules/tinymce"
|
||||
},
|
||||
"description": "Web based JavaScript HTML WYSIWYG editor control.",
|
||||
"author": "Ephox Corporation DBA Tiny Technologies, Inc",
|
||||
"main": "tinymce.js",
|
||||
"types": "tinymce.d.ts",
|
||||
"license": "SEE LICENSE IN license.md",
|
||||
"keywords": [
|
||||
"wysiwyg",
|
||||
"tinymce",
|
||||
"richtext",
|
||||
"javascript",
|
||||
"html",
|
||||
"text",
|
||||
"rich editor",
|
||||
"rich text editor",
|
||||
"rte",
|
||||
"rich text",
|
||||
"contenteditable",
|
||||
"editing"
|
||||
],
|
||||
"homepage": "https://www.tiny.cloud/",
|
||||
"bugs": {
|
||||
"url": "https://github.com/tinymce/tinymce/issues"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
// Exports the "accordion" plugin for usage with module loaders
|
||||
// Usage:
|
||||
// CommonJS:
|
||||
// require('tinymce/plugins/accordion')
|
||||
// ES2015:
|
||||
// import 'tinymce/plugins/accordion'
|
||||
require('./plugin.js');
|
||||
File diff suppressed because it is too large
Load Diff
+4
-1
File diff suppressed because one or more lines are too long
@@ -1,7 +0,0 @@
|
||||
// Exports the "advlist" plugin for usage with module loaders
|
||||
// Usage:
|
||||
// CommonJS:
|
||||
// require('tinymce/plugins/advlist')
|
||||
// ES2015:
|
||||
// import 'tinymce/plugins/advlist'
|
||||
require('./plugin.js');
|
||||
@@ -1,473 +0,0 @@
|
||||
/**
|
||||
* TinyMCE version 8.5.1 (2026-05-19)
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var global$1 = tinymce.util.Tools.resolve('tinymce.PluginManager');
|
||||
|
||||
const applyListFormat = (editor, listName, styleValue) => {
|
||||
const cmd = listName === 'UL' ? 'InsertUnorderedList' : 'InsertOrderedList';
|
||||
editor.execCommand(cmd, false, styleValue === false ? null : { 'list-style-type': styleValue });
|
||||
};
|
||||
|
||||
const register$2 = (editor) => {
|
||||
editor.addCommand('ApplyUnorderedListStyle', (ui, value) => {
|
||||
applyListFormat(editor, 'UL', value['list-style-type']);
|
||||
});
|
||||
editor.addCommand('ApplyOrderedListStyle', (ui, value) => {
|
||||
applyListFormat(editor, 'OL', value['list-style-type']);
|
||||
});
|
||||
};
|
||||
|
||||
const option = (name) => (editor) => editor.options.get(name);
|
||||
const register$1 = (editor) => {
|
||||
const registerOption = editor.options.register;
|
||||
registerOption('advlist_number_styles', {
|
||||
processor: 'string[]',
|
||||
default: 'default,lower-alpha,lower-greek,lower-roman,upper-alpha,upper-roman'.split(',')
|
||||
});
|
||||
registerOption('advlist_bullet_styles', {
|
||||
processor: 'string[]',
|
||||
default: 'default,disc,circle,square'.split(',')
|
||||
});
|
||||
};
|
||||
const getNumberStyles = option('advlist_number_styles');
|
||||
const getBulletStyles = option('advlist_bullet_styles');
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-wrapper-object-types */
|
||||
const isNullable = (a) => a === null || a === undefined;
|
||||
const isNonNullable = (a) => !isNullable(a);
|
||||
|
||||
/**
|
||||
* The `Optional` type represents a value (of any type) that potentially does
|
||||
* not exist. Any `Optional<T>` can either be a `Some<T>` (in which case the
|
||||
* value does exist) or a `None` (in which case the value does not exist). This
|
||||
* module defines a whole lot of FP-inspired utility functions for dealing with
|
||||
* `Optional` objects.
|
||||
*
|
||||
* Comparison with null or undefined:
|
||||
* - We don't get fancy null coalescing operators with `Optional`
|
||||
* - We do get fancy helper functions with `Optional`
|
||||
* - `Optional` support nesting, and allow for the type to still be nullable (or
|
||||
* another `Optional`)
|
||||
* - There is no option to turn off strict-optional-checks like there is for
|
||||
* strict-null-checks
|
||||
*/
|
||||
class Optional {
|
||||
tag;
|
||||
value;
|
||||
// Sneaky optimisation: every instance of Optional.none is identical, so just
|
||||
// reuse the same object
|
||||
static singletonNone = new Optional(false);
|
||||
// The internal representation has a `tag` and a `value`, but both are
|
||||
// private: able to be console.logged, but not able to be accessed by code
|
||||
constructor(tag, value) {
|
||||
this.tag = tag;
|
||||
this.value = value;
|
||||
}
|
||||
// --- Identities ---
|
||||
/**
|
||||
* Creates a new `Optional<T>` that **does** contain a value.
|
||||
*/
|
||||
static some(value) {
|
||||
return new Optional(true, value);
|
||||
}
|
||||
/**
|
||||
* Create a new `Optional<T>` that **does not** contain a value. `T` can be
|
||||
* any type because we don't actually have a `T`.
|
||||
*/
|
||||
static none() {
|
||||
return Optional.singletonNone;
|
||||
}
|
||||
/**
|
||||
* Perform a transform on an `Optional` type. Regardless of whether this
|
||||
* `Optional` contains a value or not, `fold` will return a value of type `U`.
|
||||
* If this `Optional` does not contain a value, the `U` will be created by
|
||||
* calling `onNone`. If this `Optional` does contain a value, the `U` will be
|
||||
* created by calling `onSome`.
|
||||
*
|
||||
* For the FP enthusiasts in the room, this function:
|
||||
* 1. Could be used to implement all of the functions below
|
||||
* 2. Forms a catamorphism
|
||||
*/
|
||||
fold(onNone, onSome) {
|
||||
if (this.tag) {
|
||||
return onSome(this.value);
|
||||
}
|
||||
else {
|
||||
return onNone();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Determine if this `Optional` object contains a value.
|
||||
*/
|
||||
isSome() {
|
||||
return this.tag;
|
||||
}
|
||||
/**
|
||||
* Determine if this `Optional` object **does not** contain a value.
|
||||
*/
|
||||
isNone() {
|
||||
return !this.tag;
|
||||
}
|
||||
// --- Functor (name stolen from Haskell / maths) ---
|
||||
/**
|
||||
* Perform a transform on an `Optional` object, **if** there is a value. If
|
||||
* you provide a function to turn a T into a U, this is the function you use
|
||||
* to turn an `Optional<T>` into an `Optional<U>`. If this **does** contain
|
||||
* a value then the output will also contain a value (that value being the
|
||||
* output of `mapper(this.value)`), and if this **does not** contain a value
|
||||
* then neither will the output.
|
||||
*/
|
||||
map(mapper) {
|
||||
if (this.tag) {
|
||||
return Optional.some(mapper(this.value));
|
||||
}
|
||||
else {
|
||||
return Optional.none();
|
||||
}
|
||||
}
|
||||
// --- Monad (name stolen from Haskell / maths) ---
|
||||
/**
|
||||
* Perform a transform on an `Optional` object, **if** there is a value.
|
||||
* Unlike `map`, here the transform itself also returns an `Optional`.
|
||||
*/
|
||||
bind(binder) {
|
||||
if (this.tag) {
|
||||
return binder(this.value);
|
||||
}
|
||||
else {
|
||||
return Optional.none();
|
||||
}
|
||||
}
|
||||
// --- Traversable (name stolen from Haskell / maths) ---
|
||||
/**
|
||||
* For a given predicate, this function finds out if there **exists** a value
|
||||
* inside this `Optional` object that meets the predicate. In practice, this
|
||||
* means that for `Optional`s that do not contain a value it returns false (as
|
||||
* no predicate-meeting value exists).
|
||||
*/
|
||||
exists(predicate) {
|
||||
return this.tag && predicate(this.value);
|
||||
}
|
||||
/**
|
||||
* For a given predicate, this function finds out if **all** the values inside
|
||||
* this `Optional` object meet the predicate. In practice, this means that
|
||||
* for `Optional`s that do not contain a value it returns true (as all 0
|
||||
* objects do meet the predicate).
|
||||
*/
|
||||
forall(predicate) {
|
||||
return !this.tag || predicate(this.value);
|
||||
}
|
||||
filter(predicate) {
|
||||
if (!this.tag || predicate(this.value)) {
|
||||
return this;
|
||||
}
|
||||
else {
|
||||
return Optional.none();
|
||||
}
|
||||
}
|
||||
// --- Getters ---
|
||||
/**
|
||||
* Get the value out of the inside of the `Optional` object, using a default
|
||||
* `replacement` value if the provided `Optional` object does not contain a
|
||||
* value.
|
||||
*/
|
||||
getOr(replacement) {
|
||||
return this.tag ? this.value : replacement;
|
||||
}
|
||||
/**
|
||||
* Get the value out of the inside of the `Optional` object, using a default
|
||||
* `replacement` value if the provided `Optional` object does not contain a
|
||||
* value. Unlike `getOr`, in this method the `replacement` object is also
|
||||
* `Optional` - meaning that this method will always return an `Optional`.
|
||||
*/
|
||||
or(replacement) {
|
||||
return this.tag ? this : replacement;
|
||||
}
|
||||
/**
|
||||
* Get the value out of the inside of the `Optional` object, using a default
|
||||
* `replacement` value if the provided `Optional` object does not contain a
|
||||
* value. Unlike `getOr`, in this method the `replacement` value is
|
||||
* "thunked" - that is to say that you don't pass a value to `getOrThunk`, you
|
||||
* pass a function which (if called) will **return** the `value` you want to
|
||||
* use.
|
||||
*/
|
||||
getOrThunk(thunk) {
|
||||
return this.tag ? this.value : thunk();
|
||||
}
|
||||
/**
|
||||
* Get the value out of the inside of the `Optional` object, using a default
|
||||
* `replacement` value if the provided Optional object does not contain a
|
||||
* value.
|
||||
*
|
||||
* Unlike `or`, in this method the `replacement` value is "thunked" - that is
|
||||
* to say that you don't pass a value to `orThunk`, you pass a function which
|
||||
* (if called) will **return** the `value` you want to use.
|
||||
*
|
||||
* Unlike `getOrThunk`, in this method the `replacement` value is also
|
||||
* `Optional`, meaning that this method will always return an `Optional`.
|
||||
*/
|
||||
orThunk(thunk) {
|
||||
return this.tag ? this : thunk();
|
||||
}
|
||||
/**
|
||||
* Get the value out of the inside of the `Optional` object, throwing an
|
||||
* exception if the provided `Optional` object does not contain a value.
|
||||
*
|
||||
* WARNING:
|
||||
* You should only be using this function if you know that the `Optional`
|
||||
* object **is not** empty (otherwise you're throwing exceptions in production
|
||||
* code, which is bad).
|
||||
*
|
||||
* In tests this is more acceptable.
|
||||
*
|
||||
* Prefer other methods to this, such as `.each`.
|
||||
*/
|
||||
getOrDie(message) {
|
||||
if (!this.tag) {
|
||||
throw new Error(message ?? 'Called getOrDie on None');
|
||||
}
|
||||
else {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
// --- Interop with null and undefined ---
|
||||
/**
|
||||
* Creates an `Optional` value from a nullable (or undefined-able) input.
|
||||
* Null, or undefined, is converted to `None`, and anything else is converted
|
||||
* to `Some`.
|
||||
*/
|
||||
static from(value) {
|
||||
return isNonNullable(value) ? Optional.some(value) : Optional.none();
|
||||
}
|
||||
/**
|
||||
* Converts an `Optional` to a nullable type, by getting the value if it
|
||||
* exists, or returning `null` if it does not.
|
||||
*/
|
||||
getOrNull() {
|
||||
return this.tag ? this.value : null;
|
||||
}
|
||||
/**
|
||||
* Converts an `Optional` to an undefined-able type, by getting the value if
|
||||
* it exists, or returning `undefined` if it does not.
|
||||
*/
|
||||
getOrUndefined() {
|
||||
return this.value;
|
||||
}
|
||||
// --- Utilities ---
|
||||
/**
|
||||
* If the `Optional` contains a value, perform an action on that value.
|
||||
* Unlike the rest of the methods on this type, `.each` has side-effects. If
|
||||
* you want to transform an `Optional<T>` **into** something, then this is not
|
||||
* the method for you. If you want to use an `Optional<T>` to **do**
|
||||
* something, then this is the method for you - provided you're okay with not
|
||||
* doing anything in the case where the `Optional` doesn't have a value inside
|
||||
* it. If you're not sure whether your use-case fits into transforming
|
||||
* **into** something or **doing** something, check whether it has a return
|
||||
* value. If it does, you should be performing a transform.
|
||||
*/
|
||||
each(worker) {
|
||||
if (this.tag) {
|
||||
worker(this.value);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Turn the `Optional` object into an array that contains all of the values
|
||||
* stored inside the `Optional`. In practice, this means the output will have
|
||||
* either 0 or 1 elements.
|
||||
*/
|
||||
toArray() {
|
||||
return this.tag ? [this.value] : [];
|
||||
}
|
||||
/**
|
||||
* Turn the `Optional` object into a string for debugging or printing. Not
|
||||
* recommended for production code, but good for debugging. Also note that
|
||||
* these days an `Optional` object can be logged to the console directly, and
|
||||
* its inner value (if it exists) will be visible.
|
||||
*/
|
||||
toString() {
|
||||
return this.tag ? `some(${this.value})` : 'none()';
|
||||
}
|
||||
}
|
||||
|
||||
const nativeIndexOf = Array.prototype.indexOf;
|
||||
const rawIndexOf = (ts, t) => nativeIndexOf.call(ts, t);
|
||||
const contains = (xs, x) => rawIndexOf(xs, x) > -1;
|
||||
const findUntil = (xs, pred, until) => {
|
||||
for (let i = 0, len = xs.length; i < len; i++) {
|
||||
const x = xs[i];
|
||||
if (pred(x, i)) {
|
||||
return Optional.some(x);
|
||||
}
|
||||
else if (until(x, i)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return Optional.none();
|
||||
};
|
||||
|
||||
// There are many variations of Object iteration that are faster than the 'for-in' style:
|
||||
// http://jsperf.com/object-keys-iteration/107
|
||||
//
|
||||
// Use the native keys if it is available (IE9+), otherwise fall back to manually filtering
|
||||
const keys = Object.keys;
|
||||
const each = (obj, f) => {
|
||||
const props = keys(obj);
|
||||
for (let k = 0, len = props.length; k < len; k++) {
|
||||
const i = props[k];
|
||||
const x = obj[i];
|
||||
f(x, i);
|
||||
}
|
||||
};
|
||||
const map = (obj, f) => {
|
||||
return tupleMap(obj, (x, i) => ({
|
||||
k: i,
|
||||
v: f(x, i)
|
||||
}));
|
||||
};
|
||||
const tupleMap = (obj, f) => {
|
||||
const r = {};
|
||||
each(obj, (x, i) => {
|
||||
const tuple = f(x, i);
|
||||
r[tuple.k] = tuple.v;
|
||||
});
|
||||
return r;
|
||||
};
|
||||
|
||||
var global = tinymce.util.Tools.resolve('tinymce.util.Tools');
|
||||
|
||||
const isCustomList = (list) => /\btox\-/.test(list.className);
|
||||
const isChildOfBody = (editor, elm) => {
|
||||
return editor.dom.isChildOf(elm, editor.getBody());
|
||||
};
|
||||
const matchNodeNames = (regex) => (node) => isNonNullable(node) && regex.test(node.nodeName);
|
||||
const isListNode = matchNodeNames(/^(OL|UL|DL)$/);
|
||||
const isTableCellNode = matchNodeNames(/^(TH|TD)$/);
|
||||
const inList = (editor, parents, nodeName) => findUntil(parents, (parent) => isListNode(parent) && !isCustomList(parent), isTableCellNode)
|
||||
.exists((list) => list.nodeName === nodeName && isChildOfBody(editor, list));
|
||||
const getSelectedStyleType = (editor) => {
|
||||
const listElm = editor.dom.getParent(editor.selection.getNode(), 'ol,ul');
|
||||
const style = editor.dom.getStyle(listElm, 'listStyleType');
|
||||
return Optional.from(style);
|
||||
};
|
||||
// Lists/core/Util.ts - Duplicated in Lists plugin
|
||||
const isWithinNonEditable = (editor, element) => element !== null && !editor.dom.isEditable(element);
|
||||
const isWithinNonEditableList = (editor, element) => {
|
||||
const parentList = editor.dom.getParent(element, 'ol,ul,dl');
|
||||
return isWithinNonEditable(editor, parentList) || !editor.selection.isEditable();
|
||||
};
|
||||
const setNodeChangeHandler = (editor, nodeChangeHandler) => {
|
||||
const initialNode = editor.selection.getNode();
|
||||
// Set the initial state
|
||||
nodeChangeHandler({
|
||||
parents: editor.dom.getParents(initialNode),
|
||||
element: initialNode
|
||||
});
|
||||
editor.on('NodeChange', nodeChangeHandler);
|
||||
return () => editor.off('NodeChange', nodeChangeHandler);
|
||||
};
|
||||
|
||||
// <ListStyles>
|
||||
const styleValueToText = (styleValue) => {
|
||||
return styleValue.replace(/\-/g, ' ').replace(/\b\w/g, (chr) => {
|
||||
return chr.toUpperCase();
|
||||
});
|
||||
};
|
||||
const normalizeStyleValue = (styleValue) => isNullable(styleValue) || styleValue === 'default' ? '' : styleValue;
|
||||
const makeSetupHandler = (editor, nodeName) => (api) => {
|
||||
const updateButtonState = (editor, parents) => {
|
||||
const element = editor.selection.getStart(true);
|
||||
api.setActive(inList(editor, parents, nodeName));
|
||||
api.setEnabled(!isWithinNonEditableList(editor, element));
|
||||
};
|
||||
const nodeChangeHandler = (e) => updateButtonState(editor, e.parents);
|
||||
return setNodeChangeHandler(editor, nodeChangeHandler);
|
||||
};
|
||||
const addSplitButton = (editor, id, tooltip, cmd, nodeName, styles) => {
|
||||
const listStyleTypeAliases = {
|
||||
'lower-latin': 'lower-alpha',
|
||||
'upper-latin': 'upper-alpha',
|
||||
'lower-alpha': 'lower-latin',
|
||||
'upper-alpha': 'upper-latin'
|
||||
};
|
||||
const stylesContainsAliasMap = map(listStyleTypeAliases, (alias) => contains(styles, alias));
|
||||
editor.ui.registry.addSplitButton(id, {
|
||||
tooltip,
|
||||
chevronTooltip: tooltip,
|
||||
icon: nodeName === "OL" /* ListType.OrderedList */ ? 'ordered-list' : 'unordered-list',
|
||||
presets: 'listpreview',
|
||||
columns: nodeName === "OL" /* ListType.OrderedList */ ? 3 : 4,
|
||||
fetch: (callback) => {
|
||||
const items = global.map(styles, (styleValue) => {
|
||||
const iconStyle = nodeName === "OL" /* ListType.OrderedList */ ? 'num' : 'bull';
|
||||
const iconName = styleValue === 'decimal' ? 'default' : styleValue;
|
||||
const itemValue = normalizeStyleValue(styleValue);
|
||||
const displayText = styleValueToText(styleValue);
|
||||
return {
|
||||
type: 'choiceitem',
|
||||
value: itemValue,
|
||||
icon: 'list-' + iconStyle + '-' + iconName,
|
||||
text: displayText
|
||||
};
|
||||
});
|
||||
callback(items);
|
||||
},
|
||||
onAction: () => editor.execCommand(cmd),
|
||||
onItemAction: (_splitButtonApi, value) => {
|
||||
applyListFormat(editor, nodeName, value);
|
||||
},
|
||||
select: (value) => {
|
||||
const listStyleType = getSelectedStyleType(editor);
|
||||
return listStyleType.exists((listStyle) => value === listStyle || (listStyleTypeAliases[listStyle] === value && !stylesContainsAliasMap[value]));
|
||||
},
|
||||
onSetup: makeSetupHandler(editor, nodeName)
|
||||
});
|
||||
};
|
||||
const addButton = (editor, id, tooltip, cmd, nodeName, styleValue) => {
|
||||
editor.ui.registry.addToggleButton(id, {
|
||||
active: false,
|
||||
tooltip,
|
||||
icon: nodeName === "OL" /* ListType.OrderedList */ ? 'ordered-list' : 'unordered-list',
|
||||
onSetup: makeSetupHandler(editor, nodeName),
|
||||
// Need to make sure the button removes rather than applies if a list of the same type is selected
|
||||
onAction: () => editor.queryCommandState(cmd) || styleValue === '' ? editor.execCommand(cmd) : applyListFormat(editor, nodeName, styleValue)
|
||||
});
|
||||
};
|
||||
const addControl = (editor, id, tooltip, cmd, nodeName, styles) => {
|
||||
if (styles.length > 1) {
|
||||
addSplitButton(editor, id, tooltip, cmd, nodeName, styles);
|
||||
}
|
||||
else {
|
||||
addButton(editor, id, tooltip, cmd, nodeName, normalizeStyleValue(styles[0]));
|
||||
}
|
||||
};
|
||||
const register = (editor) => {
|
||||
addControl(editor, 'numlist', 'Numbered list', 'InsertOrderedList', "OL" /* ListType.OrderedList */, getNumberStyles(editor));
|
||||
addControl(editor, 'bullist', 'Bullet list', 'InsertUnorderedList', "UL" /* ListType.UnorderedList */, getBulletStyles(editor));
|
||||
};
|
||||
|
||||
var Plugin = () => {
|
||||
global$1.add('advlist', (editor) => {
|
||||
if (editor.hasPlugin('lists')) {
|
||||
register$1(editor);
|
||||
register(editor);
|
||||
register$2(editor);
|
||||
}
|
||||
else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Please use the Lists plugin together with the List Styles plugin.');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
Plugin();
|
||||
/** *****
|
||||
* DO NOT EXPORT ANYTHING
|
||||
*
|
||||
* IF YOU DO ROLLUP WILL LEAVE A GLOBAL ON THE PAGE
|
||||
*******/
|
||||
|
||||
})();
|
||||
+4
-1
@@ -1 +1,4 @@
|
||||
!function(){"use strict";var t=tinymce.util.Tools.resolve("tinymce.PluginManager");const e=(t,e,s)=>{const r="UL"===e?"InsertUnorderedList":"InsertOrderedList";t.execCommand(r,!1,!1===s?null:{"list-style-type":s})},s=t=>e=>e.options.get(t),r=s("advlist_number_styles"),n=s("advlist_bullet_styles"),l=t=>null==t,i=t=>!l(t);class o{tag;value;static singletonNone=new o(!1);constructor(t,e){this.tag=t,this.value=e}static some(t){return new o(!0,t)}static none(){return o.singletonNone}fold(t,e){return this.tag?e(this.value):t()}isSome(){return this.tag}isNone(){return!this.tag}map(t){return this.tag?o.some(t(this.value)):o.none()}bind(t){return this.tag?t(this.value):o.none()}exists(t){return this.tag&&t(this.value)}forall(t){return!this.tag||t(this.value)}filter(t){return!this.tag||t(this.value)?this:o.none()}getOr(t){return this.tag?this.value:t}or(t){return this.tag?this:t}getOrThunk(t){return this.tag?this.value:t()}orThunk(t){return this.tag?this:t()}getOrDie(t){if(this.tag)return this.value;throw new Error(t??"Called getOrDie on None")}static from(t){return i(t)?o.some(t):o.none()}getOrNull(){return this.tag?this.value:null}getOrUndefined(){return this.value}each(t){this.tag&&t(this.value)}toArray(){return this.tag?[this.value]:[]}toString(){return this.tag?`some(${this.value})`:"none()"}}const a=Array.prototype.indexOf,u=Object.keys;var d=tinymce.util.Tools.resolve("tinymce.util.Tools");const c=t=>e=>i(e)&&t.test(e.nodeName),h=c(/^(OL|UL|DL)$/),g=c(/^(TH|TD)$/),p=t=>l(t)||"default"===t?"":t,m=(t,e)=>s=>((t,e)=>{const s=t.selection.getNode();return e({parents:t.dom.getParents(s),element:s}),t.on("NodeChange",e),()=>t.off("NodeChange",e)})(t,r=>((t,r)=>{const n=t.selection.getStart(!0);s.setActive(((t,e,s)=>((t,e,s)=>{for(let r=0,n=t.length;r<n;r++){const n=t[r];if(e(n))return o.some(n);if(s(n,r))break}return o.none()})(e,t=>h(t)&&!/\btox\-/.test(t.className),g).exists(e=>e.nodeName===s&&((t,e)=>t.dom.isChildOf(e,t.getBody()))(t,e)))(t,r,e)),s.setEnabled(!((t,e)=>{const s=t.dom.getParent(e,"ol,ul,dl");return((t,e)=>null!==e&&!t.dom.isEditable(e))(t,s)||!t.selection.isEditable()})(t,n))})(t,r.parents)),v=(t,s,r,n,l,i)=>{const c={"lower-latin":"lower-alpha","upper-latin":"upper-alpha","lower-alpha":"lower-latin","upper-alpha":"upper-latin"},h=(g=t=>{return e=i,s=t,a.call(e,s)>-1;var e,s},((t,e)=>{const s={};return((t,e)=>{const s=u(t);for(let r=0,n=s.length;r<n;r++){const n=s[r];e(t[n],n)}})(t,(t,r)=>{const n=e(t,r);s[n.k]=n.v}),s})(c,(t,e)=>({k:e,v:g(t)})));var g;t.ui.registry.addSplitButton(s,{tooltip:r,chevronTooltip:r,icon:"OL"===l?"ordered-list":"unordered-list",presets:"listpreview",columns:"OL"===l?3:4,fetch:t=>{t(d.map(i,t=>{const e="OL"===l?"num":"bull",s="decimal"===t?"default":t,r=p(t),n=(t=>t.replace(/\-/g," ").replace(/\b\w/g,t=>t.toUpperCase()))(t);return{type:"choiceitem",value:r,icon:"list-"+e+"-"+s,text:n}}))},onAction:()=>t.execCommand(n),onItemAction:(s,r)=>{e(t,l,r)},select:e=>{const s=(t=>{const e=t.dom.getParent(t.selection.getNode(),"ol,ul"),s=t.dom.getStyle(e,"listStyleType");return o.from(s)})(t);return s.exists(t=>e===t||c[t]===e&&!h[e])},onSetup:m(t,l)})},y=(t,s,r,n,l,i)=>{i.length>1?v(t,s,r,n,l,i):((t,s,r,n,l,i)=>{t.ui.registry.addToggleButton(s,{active:!1,tooltip:r,icon:"OL"===l?"ordered-list":"unordered-list",onSetup:m(t,l),onAction:()=>t.queryCommandState(n)||""===i?t.execCommand(n):e(t,l,i)})})(t,s,r,n,l,p(i[0]))};t.add("advlist",t=>{t.hasPlugin("lists")?((t=>{const e=t.options.register;e("advlist_number_styles",{processor:"string[]",default:"default,lower-alpha,lower-greek,lower-roman,upper-alpha,upper-roman".split(",")}),e("advlist_bullet_styles",{processor:"string[]",default:"default,disc,circle,square".split(",")})})(t),(t=>{y(t,"numlist","Numbered list","InsertOrderedList","OL",r(t)),y(t,"bullist","Bullet list","InsertUnorderedList","UL",n(t))})(t),(t=>{t.addCommand("ApplyUnorderedListStyle",(s,r)=>{e(t,"UL",r["list-style-type"])}),t.addCommand("ApplyOrderedListStyle",(s,r)=>{e(t,"OL",r["list-style-type"])})})(t)):console.error("Please use the Lists plugin together with the List Styles plugin.")})}();
|
||||
/**
|
||||
* TinyMCE version 7.3.0 (2024-08-07)
|
||||
*/
|
||||
!function(){"use strict";var t=tinymce.util.Tools.resolve("tinymce.PluginManager");const e=(t,e,s)=>{const r="UL"===e?"InsertUnorderedList":"InsertOrderedList";t.execCommand(r,!1,!1===s?null:{"list-style-type":s})},s=t=>e=>e.options.get(t),r=s("advlist_number_styles"),n=s("advlist_bullet_styles"),i=t=>null==t,l=t=>!i(t);var o=tinymce.util.Tools.resolve("tinymce.util.Tools");class a{constructor(t,e){this.tag=t,this.value=e}static some(t){return new a(!0,t)}static none(){return a.singletonNone}fold(t,e){return this.tag?e(this.value):t()}isSome(){return this.tag}isNone(){return!this.tag}map(t){return this.tag?a.some(t(this.value)):a.none()}bind(t){return this.tag?t(this.value):a.none()}exists(t){return this.tag&&t(this.value)}forall(t){return!this.tag||t(this.value)}filter(t){return!this.tag||t(this.value)?this:a.none()}getOr(t){return this.tag?this.value:t}or(t){return this.tag?this:t}getOrThunk(t){return this.tag?this.value:t()}orThunk(t){return this.tag?this:t()}getOrDie(t){if(this.tag)return this.value;throw new Error(null!=t?t:"Called getOrDie on None")}static from(t){return l(t)?a.some(t):a.none()}getOrNull(){return this.tag?this.value:null}getOrUndefined(){return this.value}each(t){this.tag&&t(this.value)}toArray(){return this.tag?[this.value]:[]}toString(){return this.tag?`some(${this.value})`:"none()"}}a.singletonNone=new a(!1);const u=t=>e=>l(e)&&t.test(e.nodeName),d=u(/^(OL|UL|DL)$/),g=u(/^(TH|TD)$/),c=t=>i(t)||"default"===t?"":t,h=(t,e)=>s=>((t,e)=>{const s=t.selection.getNode();return e({parents:t.dom.getParents(s),element:s}),t.on("NodeChange",e),()=>t.off("NodeChange",e)})(t,(r=>((t,r)=>{const n=t.selection.getStart(!0);s.setActive(((t,e,s)=>((t,e,s)=>{for(let e=0,n=t.length;e<n;e++){const n=t[e];if(d(r=n)&&!/\btox\-/.test(r.className))return a.some(n);if(s(n,e))break}var r;return a.none()})(e,0,g).exists((e=>e.nodeName===s&&((t,e)=>t.dom.isChildOf(e,t.getBody()))(t,e))))(t,r,e)),s.setEnabled(!((t,e)=>{const s=t.dom.getParent(e,"ol,ul,dl");return((t,e)=>null!==e&&!t.dom.isEditable(e))(t,s)&&t.selection.isEditable()})(t,n)&&t.selection.isEditable())})(t,r.parents))),m=(t,s,r,n,i,l)=>{l.length>1?((t,s,r,n,i,l)=>{t.ui.registry.addSplitButton(s,{tooltip:r,icon:"OL"===i?"ordered-list":"unordered-list",presets:"listpreview",columns:3,fetch:t=>{t(o.map(l,(t=>{const e="OL"===i?"num":"bull",s="disc"===t||"decimal"===t?"default":t,r=c(t),n=(t=>t.replace(/\-/g," ").replace(/\b\w/g,(t=>t.toUpperCase())))(t);return{type:"choiceitem",value:r,icon:"list-"+e+"-"+s,text:n}})))},onAction:()=>t.execCommand(n),onItemAction:(s,r)=>{e(t,i,r)},select:e=>{const s=(t=>{const e=t.dom.getParent(t.selection.getNode(),"ol,ul"),s=t.dom.getStyle(e,"listStyleType");return a.from(s)})(t);return s.map((t=>e===t)).getOr(!1)},onSetup:h(t,i)})})(t,s,r,n,i,l):((t,s,r,n,i,l)=>{t.ui.registry.addToggleButton(s,{active:!1,tooltip:r,icon:"OL"===i?"ordered-list":"unordered-list",onSetup:h(t,i),onAction:()=>t.queryCommandState(n)||""===l?t.execCommand(n):e(t,i,l)})})(t,s,r,n,i,c(l[0]))};t.add("advlist",(t=>{t.hasPlugin("lists")?((t=>{const e=t.options.register;e("advlist_number_styles",{processor:"string[]",default:"default,lower-alpha,lower-greek,lower-roman,upper-alpha,upper-roman".split(",")}),e("advlist_bullet_styles",{processor:"string[]",default:"default,circle,square".split(",")})})(t),(t=>{m(t,"numlist","Numbered list","InsertOrderedList","OL",r(t)),m(t,"bullist","Bullet list","InsertUnorderedList","UL",n(t))})(t),(t=>{t.addCommand("ApplyUnorderedListStyle",((s,r)=>{e(t,"UL",r["list-style-type"])})),t.addCommand("ApplyOrderedListStyle",((s,r)=>{e(t,"OL",r["list-style-type"])}))})(t)):console.error("Please use the Lists plugin together with the List Styles plugin.")}))}();
|
||||
@@ -1,7 +0,0 @@
|
||||
// Exports the "anchor" plugin for usage with module loaders
|
||||
// Usage:
|
||||
// CommonJS:
|
||||
// require('tinymce/plugins/anchor')
|
||||
// ES2015:
|
||||
// import 'tinymce/plugins/anchor'
|
||||
require('./plugin.js');
|
||||
@@ -1,237 +0,0 @@
|
||||
/**
|
||||
* TinyMCE version 8.5.1 (2026-05-19)
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var global$2 = tinymce.util.Tools.resolve('tinymce.PluginManager');
|
||||
|
||||
var global$1 = tinymce.util.Tools.resolve('tinymce.dom.RangeUtils');
|
||||
|
||||
var global = tinymce.util.Tools.resolve('tinymce.util.Tools');
|
||||
|
||||
const option = (name) => (editor) => editor.options.get(name);
|
||||
const register$2 = (editor) => {
|
||||
const registerOption = editor.options.register;
|
||||
registerOption('allow_html_in_named_anchor', {
|
||||
processor: 'boolean',
|
||||
default: false
|
||||
});
|
||||
};
|
||||
const allowHtmlInNamedAnchor = option('allow_html_in_named_anchor');
|
||||
|
||||
const namedAnchorSelector = 'a:not([href])';
|
||||
const isEmptyString = (str) => !str;
|
||||
const getIdFromAnchor = (elm) => {
|
||||
const id = elm.getAttribute('id') || elm.getAttribute('name');
|
||||
return id || '';
|
||||
};
|
||||
const isAnchor = (elm) => elm.nodeName.toLowerCase() === 'a';
|
||||
const isNamedAnchor = (elm) => isAnchor(elm) && !elm.getAttribute('href') && getIdFromAnchor(elm) !== '';
|
||||
const isEmptyNamedAnchor = (elm) => isNamedAnchor(elm) && !elm.firstChild;
|
||||
|
||||
const removeEmptyNamedAnchorsInSelection = (editor) => {
|
||||
const dom = editor.dom;
|
||||
global$1(dom).walk(editor.selection.getRng(), (nodes) => {
|
||||
global.each(nodes, (node) => {
|
||||
if (isEmptyNamedAnchor(node)) {
|
||||
dom.remove(node, false);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
const isValidId = (id) =>
|
||||
// Follows HTML4 rules: https://www.w3.org/TR/html401/types.html#type-id
|
||||
/^[A-Za-z][A-Za-z0-9\-:._]*$/.test(id);
|
||||
const getNamedAnchor = (editor) => editor.dom.getParent(editor.selection.getStart(), namedAnchorSelector);
|
||||
const getId = (editor) => {
|
||||
const anchor = getNamedAnchor(editor);
|
||||
if (anchor) {
|
||||
return getIdFromAnchor(anchor);
|
||||
}
|
||||
else {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
const createAnchor = (editor, id) => {
|
||||
editor.undoManager.transact(() => {
|
||||
if (!allowHtmlInNamedAnchor(editor)) {
|
||||
editor.selection.collapse(true);
|
||||
}
|
||||
if (editor.selection.isCollapsed()) {
|
||||
editor.insertContent(editor.dom.createHTML('a', { id }));
|
||||
}
|
||||
else {
|
||||
// Remove any empty named anchors in the selection as they cannot be removed by the formatter since they are cef
|
||||
removeEmptyNamedAnchorsInSelection(editor);
|
||||
// Format is set up to truncate any partially selected named anchors so that they are not completely removed
|
||||
editor.formatter.remove('namedAnchor', undefined, undefined, true);
|
||||
// Insert new anchor using the formatter - will wrap selected content in anchor
|
||||
editor.formatter.apply('namedAnchor', { value: id });
|
||||
// Need to add visual classes to anchors if required
|
||||
editor.addVisual();
|
||||
}
|
||||
});
|
||||
};
|
||||
const updateAnchor = (editor, id, anchorElement) => {
|
||||
anchorElement.removeAttribute('name');
|
||||
anchorElement.id = id;
|
||||
editor.addVisual(); // Need to add visual classes to anchors if required
|
||||
editor.undoManager.add();
|
||||
};
|
||||
const insert = (editor, id) => {
|
||||
const anchor = getNamedAnchor(editor);
|
||||
if (anchor) {
|
||||
updateAnchor(editor, id, anchor);
|
||||
}
|
||||
else {
|
||||
createAnchor(editor, id);
|
||||
}
|
||||
editor.focus();
|
||||
};
|
||||
|
||||
const insertAnchor = (editor, newId) => {
|
||||
if (!isValidId(newId)) {
|
||||
editor.windowManager.alert('ID should start with a letter, followed only by letters, numbers, dashes, dots, colons or underscores.');
|
||||
return false;
|
||||
}
|
||||
else {
|
||||
insert(editor, newId);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
const open = (editor) => {
|
||||
const currentId = getId(editor);
|
||||
editor.windowManager.open({
|
||||
title: 'Anchor',
|
||||
size: 'normal',
|
||||
body: {
|
||||
type: 'panel',
|
||||
items: [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'input',
|
||||
label: 'ID',
|
||||
placeholder: 'example'
|
||||
}
|
||||
]
|
||||
},
|
||||
buttons: [
|
||||
{
|
||||
type: 'cancel',
|
||||
name: 'cancel',
|
||||
text: 'Cancel'
|
||||
},
|
||||
{
|
||||
type: 'submit',
|
||||
name: 'save',
|
||||
text: 'Save',
|
||||
primary: true
|
||||
}
|
||||
],
|
||||
initialData: {
|
||||
id: currentId
|
||||
},
|
||||
onSubmit: (api) => {
|
||||
if (insertAnchor(editor, api.getData().id)) { // TODO we need a better way to do validation
|
||||
api.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const register$1 = (editor) => {
|
||||
editor.addCommand('mceAnchor', () => {
|
||||
open(editor);
|
||||
});
|
||||
};
|
||||
|
||||
// Note: node.firstChild check is for the 'allow_html_in_named_anchor' setting
|
||||
// Only want to add contenteditable attributes if there is no text within the anchor
|
||||
const isNamedAnchorNode = (node) => isEmptyString(node.attr('href')) && !isEmptyString(node.attr('id') || node.attr('name'));
|
||||
const isEmptyNamedAnchorNode = (node) => isNamedAnchorNode(node) && !node.firstChild;
|
||||
const setContentEditable = (state) => (nodes) => {
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const node = nodes[i];
|
||||
if (isEmptyNamedAnchorNode(node)) {
|
||||
node.attr('contenteditable', state);
|
||||
}
|
||||
}
|
||||
};
|
||||
const setup = (editor) => {
|
||||
editor.on('PreInit', () => {
|
||||
editor.parser.addNodeFilter('a', setContentEditable('false'));
|
||||
editor.serializer.addNodeFilter('a', setContentEditable(null));
|
||||
});
|
||||
};
|
||||
|
||||
const registerFormats = (editor) => {
|
||||
editor.formatter.register('namedAnchor', {
|
||||
inline: 'a',
|
||||
selector: namedAnchorSelector,
|
||||
remove: 'all',
|
||||
split: true,
|
||||
deep: true,
|
||||
attributes: {
|
||||
id: '%value'
|
||||
},
|
||||
onmatch: (node, _fmt, _itemName) => {
|
||||
return isNamedAnchor(node);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onSetupEditable = (editor) => (api) => {
|
||||
const nodeChanged = () => {
|
||||
api.setEnabled(editor.selection.isEditable());
|
||||
};
|
||||
editor.on('NodeChange', nodeChanged);
|
||||
nodeChanged();
|
||||
return () => {
|
||||
editor.off('NodeChange', nodeChanged);
|
||||
};
|
||||
};
|
||||
const register = (editor) => {
|
||||
const onAction = () => editor.execCommand('mceAnchor');
|
||||
editor.ui.registry.addToggleButton('anchor', {
|
||||
icon: 'bookmark',
|
||||
tooltip: 'Anchor',
|
||||
onAction,
|
||||
onSetup: (buttonApi) => {
|
||||
const unbindSelectorChanged = editor.selection.selectorChangedWithUnbind('a:not([href])', buttonApi.setActive).unbind;
|
||||
const unbindEditableChanged = onSetupEditable(editor)(buttonApi);
|
||||
return () => {
|
||||
unbindSelectorChanged();
|
||||
unbindEditableChanged();
|
||||
};
|
||||
}
|
||||
});
|
||||
editor.ui.registry.addMenuItem('anchor', {
|
||||
icon: 'bookmark',
|
||||
text: 'Anchor...',
|
||||
onAction,
|
||||
onSetup: onSetupEditable(editor)
|
||||
});
|
||||
};
|
||||
|
||||
var Plugin = () => {
|
||||
global$2.add('anchor', (editor) => {
|
||||
register$2(editor);
|
||||
setup(editor);
|
||||
register$1(editor);
|
||||
register(editor);
|
||||
editor.on('PreInit', () => {
|
||||
registerFormats(editor);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
Plugin();
|
||||
/** *****
|
||||
* DO NOT EXPORT ANYTHING
|
||||
*
|
||||
* IF YOU DO ROLLUP WILL LEAVE A GLOBAL ON THE PAGE
|
||||
*******/
|
||||
|
||||
})();
|
||||
+4
-1
@@ -1 +1,4 @@
|
||||
!function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.PluginManager"),t=tinymce.util.Tools.resolve("tinymce.dom.RangeUtils"),o=tinymce.util.Tools.resolve("tinymce.util.Tools");const n=e=>e.options.get("allow_html_in_named_anchor");const a="a:not([href])",r=e=>!e,i=e=>e.getAttribute("id")||e.getAttribute("name")||"",l=e=>(e=>"a"===e.nodeName.toLowerCase())(e)&&!e.getAttribute("href")&&""!==i(e),s=e=>e.dom.getParent(e.selection.getStart(),a),d=(e,a)=>{const r=s(e);r?((e,t,o)=>{o.removeAttribute("name"),o.id=t,e.addVisual(),e.undoManager.add()})(e,a,r):((e,a)=>{e.undoManager.transact(()=>{n(e)||e.selection.collapse(!0),e.selection.isCollapsed()?e.insertContent(e.dom.createHTML("a",{id:a})):((e=>{const n=e.dom;t(n).walk(e.selection.getRng(),e=>{o.each(e,e=>{var t;l(t=e)&&!t.firstChild&&n.remove(e,!1)})})})(e),e.formatter.remove("namedAnchor",void 0,void 0,!0),e.formatter.apply("namedAnchor",{value:a}),e.addVisual())})})(e,a),e.focus()},c=e=>(e=>r(e.attr("href"))&&!r(e.attr("id")||e.attr("name")))(e)&&!e.firstChild,m=e=>t=>{for(let o=0;o<t.length;o++){const n=t[o];c(n)&&n.attr("contenteditable",e)}},u=e=>t=>{const o=()=>{t.setEnabled(e.selection.isEditable())};return e.on("NodeChange",o),o(),()=>{e.off("NodeChange",o)}};e.add("anchor",e=>{(e=>{(0,e.options.register)("allow_html_in_named_anchor",{processor:"boolean",default:!1})})(e),(e=>{e.on("PreInit",()=>{e.parser.addNodeFilter("a",m("false")),e.serializer.addNodeFilter("a",m(null))})})(e),(e=>{e.addCommand("mceAnchor",()=>{(e=>{const t=(e=>{const t=s(e);return t?i(t):""})(e);e.windowManager.open({title:"Anchor",size:"normal",body:{type:"panel",items:[{name:"id",type:"input",label:"ID",placeholder:"example"}]},buttons:[{type:"cancel",name:"cancel",text:"Cancel"},{type:"submit",name:"save",text:"Save",primary:!0}],initialData:{id:t},onSubmit:t=>{((e,t)=>/^[A-Za-z][A-Za-z0-9\-:._]*$/.test(t)?(d(e,t),!0):(e.windowManager.alert("ID should start with a letter, followed only by letters, numbers, dashes, dots, colons or underscores."),!1))(e,t.getData().id)&&t.close()}})})(e)})})(e),(e=>{const t=()=>e.execCommand("mceAnchor");e.ui.registry.addToggleButton("anchor",{icon:"bookmark",tooltip:"Anchor",onAction:t,onSetup:t=>{const o=e.selection.selectorChangedWithUnbind("a:not([href])",t.setActive).unbind,n=u(e)(t);return()=>{o(),n()}}}),e.ui.registry.addMenuItem("anchor",{icon:"bookmark",text:"Anchor...",onAction:t,onSetup:u(e)})})(e),e.on("PreInit",()=>{(e=>{e.formatter.register("namedAnchor",{inline:"a",selector:a,remove:"all",split:!0,deep:!0,attributes:{id:"%value"},onmatch:(e,t,o)=>l(e)})})(e)})})}();
|
||||
/**
|
||||
* TinyMCE version 7.3.0 (2024-08-07)
|
||||
*/
|
||||
!function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.PluginManager"),t=tinymce.util.Tools.resolve("tinymce.dom.RangeUtils"),o=tinymce.util.Tools.resolve("tinymce.util.Tools");const n=("allow_html_in_named_anchor",e=>e.options.get("allow_html_in_named_anchor"));const a="a:not([href])",r=e=>!e,i=e=>e.getAttribute("id")||e.getAttribute("name")||"",l=e=>(e=>"a"===e.nodeName.toLowerCase())(e)&&!e.getAttribute("href")&&""!==i(e),s=e=>e.dom.getParent(e.selection.getStart(),a),d=(e,a)=>{const r=s(e);r?((e,t,o)=>{o.removeAttribute("name"),o.id=t,e.addVisual(),e.undoManager.add()})(e,a,r):((e,a)=>{e.undoManager.transact((()=>{n(e)||e.selection.collapse(!0),e.selection.isCollapsed()?e.insertContent(e.dom.createHTML("a",{id:a})):((e=>{const n=e.dom;t(n).walk(e.selection.getRng(),(e=>{o.each(e,(e=>{var t;l(t=e)&&!t.firstChild&&n.remove(e,!1)}))}))})(e),e.formatter.remove("namedAnchor",void 0,void 0,!0),e.formatter.apply("namedAnchor",{value:a}),e.addVisual())}))})(e,a),e.focus()},c=e=>(e=>r(e.attr("href"))&&!r(e.attr("id")||e.attr("name")))(e)&&!e.firstChild,m=e=>t=>{for(let o=0;o<t.length;o++){const n=t[o];c(n)&&n.attr("contenteditable",e)}},u=e=>t=>{const o=()=>{t.setEnabled(e.selection.isEditable())};return e.on("NodeChange",o),o(),()=>{e.off("NodeChange",o)}};e.add("anchor",(e=>{(e=>{(0,e.options.register)("allow_html_in_named_anchor",{processor:"boolean",default:!1})})(e),(e=>{e.on("PreInit",(()=>{e.parser.addNodeFilter("a",m("false")),e.serializer.addNodeFilter("a",m(null))}))})(e),(e=>{e.addCommand("mceAnchor",(()=>{(e=>{const t=(e=>{const t=s(e);return t?i(t):""})(e);e.windowManager.open({title:"Anchor",size:"normal",body:{type:"panel",items:[{name:"id",type:"input",label:"ID",placeholder:"example"}]},buttons:[{type:"cancel",name:"cancel",text:"Cancel"},{type:"submit",name:"save",text:"Save",primary:!0}],initialData:{id:t},onSubmit:t=>{((e,t)=>/^[A-Za-z][A-Za-z0-9\-:._]*$/.test(t)?(d(e,t),!0):(e.windowManager.alert("ID should start with a letter, followed only by letters, numbers, dashes, dots, colons or underscores."),!1))(e,t.getData().id)&&t.close()}})})(e)}))})(e),(e=>{const t=()=>e.execCommand("mceAnchor");e.ui.registry.addToggleButton("anchor",{icon:"bookmark",tooltip:"Anchor",onAction:t,onSetup:t=>{const o=e.selection.selectorChangedWithUnbind("a:not([href])",t.setActive).unbind,n=u(e)(t);return()=>{o(),n()}}}),e.ui.registry.addMenuItem("anchor",{icon:"bookmark",text:"Anchor...",onAction:t,onSetup:u(e)})})(e),e.on("PreInit",(()=>{(e=>{e.formatter.register("namedAnchor",{inline:"a",selector:a,remove:"all",split:!0,deep:!0,attributes:{id:"%value"},onmatch:(e,t,o)=>l(e)})})(e)}))}))}();
|
||||
@@ -1,7 +0,0 @@
|
||||
// Exports the "autolink" plugin for usage with module loaders
|
||||
// Usage:
|
||||
// CommonJS:
|
||||
// require('tinymce/plugins/autolink')
|
||||
// ES2015:
|
||||
// import 'tinymce/plugins/autolink'
|
||||
require('./plugin.js');
|
||||
@@ -1,315 +0,0 @@
|
||||
/**
|
||||
* TinyMCE version 8.5.1 (2026-05-19)
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var global$1 = tinymce.util.Tools.resolve('tinymce.PluginManager');
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-wrapper-object-types */
|
||||
const hasProto = (v, constructor, predicate) => {
|
||||
if (predicate(v, constructor.prototype)) {
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
// String-based fallback time
|
||||
return v.constructor?.name === constructor.name;
|
||||
}
|
||||
};
|
||||
const typeOf = (x) => {
|
||||
const t = typeof x;
|
||||
if (x === null) {
|
||||
return 'null';
|
||||
}
|
||||
else if (t === 'object' && Array.isArray(x)) {
|
||||
return 'array';
|
||||
}
|
||||
else if (t === 'object' && hasProto(x, String, (o, proto) => proto.isPrototypeOf(o))) {
|
||||
return 'string';
|
||||
}
|
||||
else {
|
||||
return t;
|
||||
}
|
||||
};
|
||||
const isType = (type) => (value) => typeOf(value) === type;
|
||||
const eq = (t) => (a) => t === a;
|
||||
const isString = isType('string');
|
||||
const isUndefined = eq(undefined);
|
||||
const isNullable = (a) => a === null || a === undefined;
|
||||
const isNonNullable = (a) => !isNullable(a);
|
||||
|
||||
const not = (f) => (t) => !f(t);
|
||||
|
||||
const hasOwnProperty = Object.hasOwnProperty;
|
||||
const has = (obj, key) => hasOwnProperty.call(obj, key);
|
||||
|
||||
const checkRange = (str, substr, start) => substr === '' || str.length >= substr.length && str.substr(start, start + substr.length) === substr;
|
||||
const contains = (str, substr, start = 0, end) => {
|
||||
const idx = str.indexOf(substr, start);
|
||||
if (idx !== -1) {
|
||||
return isUndefined(end) ? true : idx + substr.length <= end;
|
||||
}
|
||||
else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
/** Does 'str' start with 'prefix'?
|
||||
* Note: all strings start with the empty string.
|
||||
* More formally, for all strings x, startsWith(x, "").
|
||||
* This is so that for all strings x and y, startsWith(y + x, y)
|
||||
*/
|
||||
const startsWith = (str, prefix) => {
|
||||
return checkRange(str, prefix, 0);
|
||||
};
|
||||
|
||||
const zeroWidth = '\uFEFF';
|
||||
const isZwsp = (char) => char === zeroWidth;
|
||||
const removeZwsp = (s) => s.replace(/\uFEFF/g, '');
|
||||
|
||||
/*
|
||||
The RegEx parses the following components (https://www.rfc-editor.org/rfc/rfc3986.txt):
|
||||
|
||||
scheme:[//[user:password@]host[:port]][/]path[?query][#fragment]
|
||||
|
||||
foo://example.com:8042/over/there?name=ferret#nose
|
||||
\_/ \______________/\_________/ \_________/ \__/
|
||||
| | | | |
|
||||
scheme authority path query fragment
|
||||
|
||||
Originally from:
|
||||
http://blog.mattheworiordan.com/post/13174566389/url-regular-expression-for-links-with-or-without-the
|
||||
|
||||
Modified to:
|
||||
- include port numbers
|
||||
- allow full stops in email addresses
|
||||
- allow [-.~*+=!&;:'%@?^${}(),\/\w] after the #
|
||||
- allow [-.~*+=!&;:'%@?^${}(),\/\w] after the ?
|
||||
- move allow -_.~*+=!&;:'%@?^${}() in email usernames to the first @ match (TBIO-4809)
|
||||
- enforce domains to be [A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)* so they can't end in a period (TBIO-4809)
|
||||
- removed a bunch of escaping, made every group non-capturing (during TBIO-4809)
|
||||
- colons are only valid when followed directly by // or some text and then @ (TBIO-4867)
|
||||
- only include the fragment '#' if it has 1 or more trailing matches
|
||||
- only include the query '?' if it has 1 or more trailing matches
|
||||
- allow commas in URL path
|
||||
- exclude trailing comma and period in URL path
|
||||
- allow up to 15 character schemes including all valid characters from the spec https://url.spec.whatwg.org/#url-scheme-string (TINY-5074)
|
||||
- changed instances of 0-9 to be \d (TINY-5074)
|
||||
- reduced duplication (TINY-5074)
|
||||
- allow [*!;:'@$] in the path segment as they are valid characters per the spec: https://url.spec.whatwg.org/#url-path-segment-string (TINY-8069)
|
||||
|
||||
(?:
|
||||
(?:
|
||||
[A-Za-z][A-Za-z\d.+-]{0,14}:\/\/(?:[-.~*+=!&;:'%@?^${}(),\w]+@)?
|
||||
| www\.
|
||||
| [-;:&=+$,.\w]+@
|
||||
)
|
||||
[A-Za-z\d-]+
|
||||
(?:\.[A-Za-z\d-]+)*
|
||||
)
|
||||
(?::\d+)?
|
||||
(?:
|
||||
\/
|
||||
(?:
|
||||
[-.~*+=!;:'%@$(),\/\w]*[-~*+=%@$()\/\w]
|
||||
)?
|
||||
)?
|
||||
(?:
|
||||
\?
|
||||
(?:
|
||||
[-.~*+=!&;:'%@?^${}(),\/\w]+
|
||||
)
|
||||
)?
|
||||
(?:
|
||||
#
|
||||
(?:
|
||||
[-.~*+=!&;:'%@?^${}(),\/\w]+
|
||||
)
|
||||
)?
|
||||
*/
|
||||
const link = () =>
|
||||
// eslint-disable-next-line max-len
|
||||
/(?:[A-Za-z][A-Za-z\d.+-]{0,14}:\/\/(?:[-.~*+=!&;:'%@?^${}(),\w]+@)?|www\.|[-;:&=+$,.\w]+@)[A-Za-z\d-]+(?:\.[A-Za-z\d-]+)*(?::\d+)?(?:\/(?:[-.~*+=!;:'%@$(),\/\w]*[-~*+=%@$()\/\w])?)?(?:\?(?:[-.~*+=!&;:'%@?^${}(),\/\w]+))?(?:#(?:[-.~*+=!&;:'%@?^${}(),\/\w]+))?/g;
|
||||
|
||||
const option = (name) => (editor) => editor.options.get(name);
|
||||
const register = (editor) => {
|
||||
const registerOption = editor.options.register;
|
||||
registerOption('autolink_pattern', {
|
||||
processor: 'regexp',
|
||||
// Use the Polaris link detection, however for autolink we need to make it be an exact match
|
||||
default: new RegExp('^' + link().source + '$', 'i')
|
||||
});
|
||||
registerOption('link_default_target', {
|
||||
processor: 'string'
|
||||
});
|
||||
registerOption('link_default_protocol', {
|
||||
processor: 'string',
|
||||
default: 'https'
|
||||
});
|
||||
};
|
||||
const getAutoLinkPattern = option('autolink_pattern');
|
||||
const getDefaultLinkTarget = option('link_default_target');
|
||||
const getDefaultLinkProtocol = option('link_default_protocol');
|
||||
const allowUnsafeLinkTarget = option('allow_unsafe_link_target');
|
||||
|
||||
var global = tinymce.util.Tools.resolve('tinymce.dom.TextSeeker');
|
||||
|
||||
const isTextNode = (node) => node.nodeType === 3;
|
||||
const isElement = (node) => node.nodeType === 1;
|
||||
const isBracketOrSpace = (char) => /^[(\[{ \u00a0]$/.test(char);
|
||||
// Note: This is similar to the Polaris protocol detection, except it also handles `mailto` and any length scheme
|
||||
const hasProtocol = (url) => /^([A-Za-z][A-Za-z\d.+-]*:\/\/)|mailto:/.test(url);
|
||||
// A limited list of punctuation characters that might be used after a link
|
||||
const isPunctuation = (char) => /[?!,.;:]/.test(char);
|
||||
const findChar = (text, index, predicate) => {
|
||||
for (let i = index - 1; i >= 0; i--) {
|
||||
const char = text.charAt(i);
|
||||
if (!isZwsp(char) && predicate(char)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
const freefallRtl = (container, offset) => {
|
||||
let tempNode = container;
|
||||
let tempOffset = offset;
|
||||
while (isElement(tempNode) && tempNode.childNodes[tempOffset]) {
|
||||
tempNode = tempNode.childNodes[tempOffset];
|
||||
tempOffset = isTextNode(tempNode) ? tempNode.data.length : tempNode.childNodes.length;
|
||||
}
|
||||
return { container: tempNode, offset: tempOffset };
|
||||
};
|
||||
|
||||
const parseCurrentLine = (editor, offset) => {
|
||||
const voidElements = editor.schema.getVoidElements();
|
||||
const autoLinkPattern = getAutoLinkPattern(editor);
|
||||
const { dom, selection } = editor;
|
||||
// Never create a link when we are inside a link
|
||||
if (dom.getParent(selection.getNode(), 'a[href]') !== null || editor.mode.isReadOnly()) {
|
||||
return null;
|
||||
}
|
||||
const rng = selection.getRng();
|
||||
const textSeeker = global(dom, (node) => {
|
||||
return dom.isBlock(node) || has(voidElements, node.nodeName.toLowerCase()) || dom.getContentEditable(node) === 'false' || dom.getParent(node, 'a[href]') !== null;
|
||||
});
|
||||
// Descend down the end container to find the text node
|
||||
const { container: endContainer, offset: endOffset } = freefallRtl(rng.endContainer, rng.endOffset);
|
||||
// Find the root container to use when walking
|
||||
const root = dom.getParent(endContainer, dom.isBlock) ?? dom.getRoot();
|
||||
// Move the selection backwards to the start of the potential URL to account for the pressed character
|
||||
// while also excluding the last full stop from a word like "www.site.com."
|
||||
const endSpot = textSeeker.backwards(endContainer, endOffset + offset, (node, offset) => {
|
||||
const text = node.data;
|
||||
const idx = findChar(text, offset, not(isBracketOrSpace));
|
||||
// Move forward one so the offset is after the found character unless the found char is a punctuation char
|
||||
return idx === -1 || isPunctuation(text[idx]) ? idx : idx + 1;
|
||||
}, root);
|
||||
if (!endSpot) {
|
||||
return null;
|
||||
}
|
||||
// Walk backwards until we find a boundary or a bracket/space
|
||||
let lastTextNode = endSpot.container;
|
||||
const startSpot = textSeeker.backwards(endSpot.container, endSpot.offset, (node, offset) => {
|
||||
lastTextNode = node;
|
||||
const idx = findChar(node.data, offset, isBracketOrSpace);
|
||||
// Move forward one so that the offset is after the bracket/space
|
||||
return idx === -1 ? idx : idx + 1;
|
||||
}, root);
|
||||
const newRng = dom.createRng();
|
||||
if (!startSpot) {
|
||||
newRng.setStart(lastTextNode, 0);
|
||||
}
|
||||
else {
|
||||
newRng.setStart(startSpot.container, startSpot.offset);
|
||||
}
|
||||
newRng.setEnd(endSpot.container, endSpot.offset);
|
||||
const rngText = removeZwsp(newRng.toString());
|
||||
const matches = rngText.match(autoLinkPattern);
|
||||
if (matches) {
|
||||
let url = matches[0];
|
||||
if (startsWith(url, 'www.')) {
|
||||
const protocol = getDefaultLinkProtocol(editor);
|
||||
url = protocol + '://' + url;
|
||||
}
|
||||
else if (contains(url, '@') && !hasProtocol(url)) {
|
||||
url = 'mailto:' + url;
|
||||
}
|
||||
return { rng: newRng, url };
|
||||
}
|
||||
else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const convertToLink = (editor, result) => {
|
||||
const { dom, selection } = editor;
|
||||
const { rng, url } = result;
|
||||
const bookmark = selection.getBookmark();
|
||||
selection.setRng(rng);
|
||||
// Needs to be a native createlink command since this is executed in a keypress event handler
|
||||
// so the pending character that is to be inserted needs to be inserted after the link. That will not
|
||||
// happen if we use the formatter create link version. Since we're using the native command
|
||||
// then we also need to ensure the exec command events are fired for backwards compatibility.
|
||||
const command = 'createlink';
|
||||
const args = { command, ui: false, value: url };
|
||||
const beforeExecEvent = editor.dispatch('BeforeExecCommand', args);
|
||||
if (!beforeExecEvent.isDefaultPrevented()) {
|
||||
editor.getDoc().execCommand(command, false, url);
|
||||
editor.dispatch('ExecCommand', args);
|
||||
const defaultLinkTarget = getDefaultLinkTarget(editor);
|
||||
if (isString(defaultLinkTarget)) {
|
||||
const anchor = selection.getNode();
|
||||
dom.setAttrib(anchor, 'target', defaultLinkTarget);
|
||||
// Ensure noopener is added for blank targets to prevent window opener attacks
|
||||
if (defaultLinkTarget === '_blank' && !allowUnsafeLinkTarget(editor)) {
|
||||
dom.setAttrib(anchor, 'rel', 'noopener');
|
||||
}
|
||||
}
|
||||
}
|
||||
selection.moveToBookmark(bookmark);
|
||||
editor.nodeChanged();
|
||||
};
|
||||
const handleSpacebar = (editor) => {
|
||||
const result = parseCurrentLine(editor, -1);
|
||||
if (isNonNullable(result)) {
|
||||
convertToLink(editor, result);
|
||||
}
|
||||
};
|
||||
const handleBracket = handleSpacebar;
|
||||
const handleEnter = (editor) => {
|
||||
const result = parseCurrentLine(editor, 0);
|
||||
if (isNonNullable(result)) {
|
||||
convertToLink(editor, result);
|
||||
}
|
||||
};
|
||||
const setup = (editor) => {
|
||||
editor.on('keydown', (e) => {
|
||||
if (e.keyCode === 13 && !e.isDefaultPrevented()) {
|
||||
handleEnter(editor);
|
||||
}
|
||||
});
|
||||
editor.on('keyup', (e) => {
|
||||
if (e.keyCode === 32) {
|
||||
handleSpacebar(editor);
|
||||
// One of the closing bracket keys: ), ] or }
|
||||
}
|
||||
else if (e.keyCode === 48 && e.shiftKey || e.keyCode === 221) {
|
||||
handleBracket(editor);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
var Plugin = () => {
|
||||
global$1.add('autolink', (editor) => {
|
||||
register(editor);
|
||||
setup(editor);
|
||||
});
|
||||
};
|
||||
|
||||
Plugin();
|
||||
/** *****
|
||||
* DO NOT EXPORT ANYTHING
|
||||
*
|
||||
* IF YOU DO ROLLUP WILL LEAVE A GLOBAL ON THE PAGE
|
||||
*******/
|
||||
|
||||
})();
|
||||
+4
-1
@@ -1 +1,4 @@
|
||||
!function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.PluginManager");const t=e=>"string"===(e=>{const t=typeof e;return null===e?"null":"object"===t&&Array.isArray(e)?"array":"object"===t&&(n=o=e,(r=String).prototype.isPrototypeOf(n)||o.constructor?.name===r.name)?"string":t;var n,o,r})(e);const n=e=>undefined===e;const o=e=>!(e=>null==e)(e),r=Object.hasOwnProperty,a=e=>"\ufeff"===e,s=e=>t=>t.options.get(e),l=s("autolink_pattern"),c=s("link_default_target"),i=s("link_default_protocol"),d=s("allow_unsafe_link_target");var u=tinymce.util.Tools.resolve("tinymce.dom.TextSeeker");const f=e=>3===e.nodeType,g=e=>1===e.nodeType,m=e=>/^[(\[{ \u00a0]$/.test(e),k=(e,t,n)=>{for(let o=t-1;o>=0;o--){const t=e.charAt(o);if(!a(t)&&n(t))return o}return-1},p=(e,t)=>{const o=e.schema.getVoidElements(),a=l(e),{dom:s,selection:c}=e;if(null!==s.getParent(c.getNode(),"a[href]")||e.mode.isReadOnly())return null;const d=c.getRng(),p=u(s,e=>{return s.isBlock(e)||(t=o,n=e.nodeName.toLowerCase(),r.call(t,n))||"false"===s.getContentEditable(e)||null!==s.getParent(e,"a[href]");var t,n}),{container:y,offset:w}=((e,t)=>{let n=e,o=t;for(;g(n)&&n.childNodes[o];)n=n.childNodes[o],o=f(n)?n.data.length:n.childNodes.length;return{container:n,offset:o}})(d.endContainer,d.endOffset),h=s.getParent(y,s.isBlock)??s.getRoot(),_=p.backwards(y,w+t,(e,t)=>{const n=e.data,o=k(n,t,(r=m,e=>!r(e)));var r,a;return-1===o||(a=n[o],/[?!,.;:]/.test(a))?o:o+1},h);if(!_)return null;let v=_.container;const A=p.backwards(_.container,_.offset,(e,t)=>{v=e;const n=k(e.data,t,m);return-1===n?n:n+1},h),C=s.createRng();A?C.setStart(A.container,A.offset):C.setStart(v,0),C.setEnd(_.container,_.offset);const b=C.toString().replace(/\uFEFF/g,"").match(a);if(b){let t=b[0];return $="www.",(P=t).length>=4&&P.substr(0,4)===$?t=i(e)+"://"+t:((e,t,o=0,r)=>{const a=e.indexOf(t,o);return-1!==a&&(!!n(r)||a+t.length<=r)})(t,"@")&&!(e=>/^([A-Za-z][A-Za-z\d.+-]*:\/\/)|mailto:/.test(e))(t)&&(t="mailto:"+t),{rng:C,url:t}}var P,$;return null},y=(e,n)=>{const{dom:o,selection:r}=e,{rng:a,url:s}=n,l=r.getBookmark();r.setRng(a);const i="createlink",u={command:i,ui:!1,value:s};if(!e.dispatch("BeforeExecCommand",u).isDefaultPrevented()){e.getDoc().execCommand(i,!1,s),e.dispatch("ExecCommand",u);const n=c(e);if(t(n)){const t=r.getNode();o.setAttrib(t,"target",n),"_blank"!==n||d(e)||o.setAttrib(t,"rel","noopener")}}r.moveToBookmark(l),e.nodeChanged()},w=e=>{const t=p(e,-1);o(t)&&y(e,t)},h=w;e.add("autolink",e=>{(e=>{const t=e.options.register;t("autolink_pattern",{processor:"regexp",default:new RegExp("^"+/(?:[A-Za-z][A-Za-z\d.+-]{0,14}:\/\/(?:[-.~*+=!&;:'%@?^${}(),\w]+@)?|www\.|[-;:&=+$,.\w]+@)[A-Za-z\d-]+(?:\.[A-Za-z\d-]+)*(?::\d+)?(?:\/(?:[-.~*+=!;:'%@$(),\/\w]*[-~*+=%@$()\/\w])?)?(?:\?(?:[-.~*+=!&;:'%@?^${}(),\/\w]+))?(?:#(?:[-.~*+=!&;:'%@?^${}(),\/\w]+))?/g.source+"$","i")}),t("link_default_target",{processor:"string"}),t("link_default_protocol",{processor:"string",default:"https"})})(e),(e=>{e.on("keydown",t=>{13!==t.keyCode||t.isDefaultPrevented()||(e=>{const t=p(e,0);o(t)&&y(e,t)})(e)}),e.on("keyup",t=>{32===t.keyCode?w(e):(48===t.keyCode&&t.shiftKey||221===t.keyCode)&&h(e)})})(e)})}();
|
||||
/**
|
||||
* TinyMCE version 7.3.0 (2024-08-07)
|
||||
*/
|
||||
!function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.PluginManager");const t=e=>t=>t.options.get(e),n=t("autolink_pattern"),o=t("link_default_target"),r=t("link_default_protocol"),a=t("allow_unsafe_link_target"),s=("string",e=>"string"===(e=>{const t=typeof e;return null===e?"null":"object"===t&&Array.isArray(e)?"array":"object"===t&&(n=o=e,(r=String).prototype.isPrototypeOf(n)||(null===(a=o.constructor)||void 0===a?void 0:a.name)===r.name)?"string":t;var n,o,r,a})(e));const l=(void 0,e=>undefined===e);const i=e=>!(e=>null==e)(e),c=Object.hasOwnProperty,d=e=>"\ufeff"===e;var u=tinymce.util.Tools.resolve("tinymce.dom.TextSeeker");const f=e=>/^[(\[{ \u00a0]$/.test(e),g=(e,t,n)=>{for(let o=t-1;o>=0;o--){const t=e.charAt(o);if(!d(t)&&n(t))return o}return-1},m=(e,t)=>{var o;const a=e.schema.getVoidElements(),s=n(e),{dom:i,selection:d}=e;if(null!==i.getParent(d.getNode(),"a[href]"))return null;const m=d.getRng(),k=u(i,(e=>{return i.isBlock(e)||(t=a,n=e.nodeName.toLowerCase(),c.call(t,n))||"false"===i.getContentEditable(e);var t,n})),{container:p,offset:y}=((e,t)=>{let n=e,o=t;for(;1===n.nodeType&&n.childNodes[o];)n=n.childNodes[o],o=3===n.nodeType?n.data.length:n.childNodes.length;return{container:n,offset:o}})(m.endContainer,m.endOffset),w=null!==(o=i.getParent(p,i.isBlock))&&void 0!==o?o:i.getRoot(),h=k.backwards(p,y+t,((e,t)=>{const n=e.data,o=g(n,t,(r=f,e=>!r(e)));var r,a;return-1===o||(a=n[o],/[?!,.;:]/.test(a))?o:o+1}),w);if(!h)return null;let v=h.container;const _=k.backwards(h.container,h.offset,((e,t)=>{v=e;const n=g(e.data,t,f);return-1===n?n:n+1}),w),A=i.createRng();_?A.setStart(_.container,_.offset):A.setStart(v,0),A.setEnd(h.container,h.offset);const C=A.toString().replace(/\uFEFF/g,"").match(s);if(C){let t=C[0];return $="www.",(b=t).length>=4&&b.substr(0,4)===$?t=r(e)+"://"+t:((e,t,n=0,o)=>{const r=e.indexOf(t,n);return-1!==r&&(!!l(o)||r+t.length<=o)})(t,"@")&&!(e=>/^([A-Za-z][A-Za-z\d.+-]*:\/\/)|mailto:/.test(e))(t)&&(t="mailto:"+t),{rng:A,url:t}}var b,$;return null},k=(e,t)=>{const{dom:n,selection:r}=e,{rng:l,url:i}=t,c=r.getBookmark();r.setRng(l);const d="createlink",u={command:d,ui:!1,value:i};if(!e.dispatch("BeforeExecCommand",u).isDefaultPrevented()){e.getDoc().execCommand(d,!1,i),e.dispatch("ExecCommand",u);const t=o(e);if(s(t)){const o=r.getNode();n.setAttrib(o,"target",t),"_blank"!==t||a(e)||n.setAttrib(o,"rel","noopener")}}r.moveToBookmark(c),e.nodeChanged()},p=e=>{const t=m(e,-1);i(t)&&k(e,t)},y=p;e.add("autolink",(e=>{(e=>{const t=e.options.register;t("autolink_pattern",{processor:"regexp",default:new RegExp("^"+/(?:[A-Za-z][A-Za-z\d.+-]{0,14}:\/\/(?:[-.~*+=!&;:'%@?^${}(),\w]+@)?|www\.|[-;:&=+$,.\w]+@)[A-Za-z\d-]+(?:\.[A-Za-z\d-]+)*(?::\d+)?(?:\/(?:[-.~*+=!;:'%@$(),\/\w]*[-~*+=%@$()\/\w])?)?(?:\?(?:[-.~*+=!&;:'%@?^${}(),\/\w]+))?(?:#(?:[-.~*+=!&;:'%@?^${}(),\/\w]+))?/g.source+"$","i")}),t("link_default_target",{processor:"string"}),t("link_default_protocol",{processor:"string",default:"https"})})(e),(e=>{e.on("keydown",(t=>{13!==t.keyCode||t.isDefaultPrevented()||(e=>{const t=m(e,0);i(t)&&k(e,t)})(e)})),e.on("keyup",(t=>{32===t.keyCode?p(e):(48===t.keyCode&&t.shiftKey||221===t.keyCode)&&y(e)}))})(e)}))}();
|
||||
@@ -1,7 +0,0 @@
|
||||
// Exports the "autoresize" plugin for usage with module loaders
|
||||
// Usage:
|
||||
// CommonJS:
|
||||
// require('tinymce/plugins/autoresize')
|
||||
// ES2015:
|
||||
// import 'tinymce/plugins/autoresize'
|
||||
require('./plugin.js');
|
||||
@@ -1,221 +0,0 @@
|
||||
/**
|
||||
* TinyMCE version 8.5.1 (2026-05-19)
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const Cell = (initial) => {
|
||||
let value = initial;
|
||||
const get = () => {
|
||||
return value;
|
||||
};
|
||||
const set = (v) => {
|
||||
value = v;
|
||||
};
|
||||
return {
|
||||
get,
|
||||
set
|
||||
};
|
||||
};
|
||||
|
||||
var global$1 = tinymce.util.Tools.resolve('tinymce.PluginManager');
|
||||
|
||||
var global = tinymce.util.Tools.resolve('tinymce.Env');
|
||||
|
||||
const fireResizeEditor = (editor) => editor.dispatch('ResizeEditor');
|
||||
|
||||
const option = (name) => (editor) => editor.options.get(name);
|
||||
const register$1 = (editor) => {
|
||||
const registerOption = editor.options.register;
|
||||
registerOption('autoresize_overflow_padding', {
|
||||
processor: 'number',
|
||||
default: 1
|
||||
});
|
||||
registerOption('autoresize_bottom_margin', {
|
||||
processor: 'number',
|
||||
default: 50
|
||||
});
|
||||
};
|
||||
const getMinHeight = option('min_height');
|
||||
const getMaxHeight = option('max_height');
|
||||
const getAutoResizeOverflowPadding = option('autoresize_overflow_padding');
|
||||
const getAutoResizeBottomMargin = option('autoresize_bottom_margin');
|
||||
|
||||
/**
|
||||
* This class contains all core logic for the autoresize plugin.
|
||||
*
|
||||
* @class tinymce.autoresize.Plugin
|
||||
* @private
|
||||
*/
|
||||
const isFullscreen = (editor) => editor.plugins.fullscreen && editor.plugins.fullscreen.isFullscreen();
|
||||
const toggleScrolling = (editor, state) => {
|
||||
const body = editor.getBody();
|
||||
if (body) {
|
||||
body.style.overflowY = state ? '' : 'hidden';
|
||||
if (!state) {
|
||||
body.scrollTop = 0;
|
||||
}
|
||||
}
|
||||
};
|
||||
const parseCssValueToInt = (dom, elm, name, computed) => {
|
||||
const value = parseInt(dom.getStyle(elm, name, computed) ?? '', 10);
|
||||
// The value maybe be an empty string, so in that case treat it as being 0
|
||||
return isNaN(value) ? 0 : value;
|
||||
};
|
||||
const shouldScrollIntoView = (trigger) => {
|
||||
// Only scroll the selection into view when we're inserting content. Any other
|
||||
// triggers the selection should already be in view and resizing would only
|
||||
// extend the content area.
|
||||
if (trigger?.type.toLowerCase() === 'setcontent') {
|
||||
const setContentEvent = trigger;
|
||||
return setContentEvent.selection === true || setContentEvent.paste === true;
|
||||
}
|
||||
else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
/**
|
||||
* This method gets executed each time the editor needs to resize.
|
||||
*/
|
||||
const resize = (editor, oldSize, trigger, getExtraMarginBottom) => {
|
||||
const dom = editor.dom;
|
||||
const doc = editor.getDoc();
|
||||
if (!doc) {
|
||||
return;
|
||||
}
|
||||
if (isFullscreen(editor)) {
|
||||
toggleScrolling(editor, true);
|
||||
return;
|
||||
}
|
||||
const docEle = doc.documentElement;
|
||||
const resizeBottomMargin = getExtraMarginBottom ? getExtraMarginBottom() : getAutoResizeOverflowPadding(editor);
|
||||
const minHeight = getMinHeight(editor) ?? editor.getElement().offsetHeight;
|
||||
let resizeHeight = minHeight;
|
||||
// Calculate outer height of the doc element using CSS styles
|
||||
const marginTop = parseCssValueToInt(dom, docEle, 'margin-top', true);
|
||||
const marginBottom = parseCssValueToInt(dom, docEle, 'margin-bottom', true);
|
||||
let contentHeight = docEle.offsetHeight + marginTop + marginBottom + resizeBottomMargin;
|
||||
// Make sure we have a valid height
|
||||
if (contentHeight < 0) {
|
||||
contentHeight = 0;
|
||||
}
|
||||
// Determine the size of the chroming (menubar, toolbar, etc...)
|
||||
const containerHeight = editor.getContainer().offsetHeight;
|
||||
const contentAreaHeight = editor.getContentAreaContainer().offsetHeight;
|
||||
const chromeHeight = containerHeight - contentAreaHeight;
|
||||
// Don't make it smaller than the minimum height
|
||||
if (contentHeight + chromeHeight > minHeight) {
|
||||
resizeHeight = contentHeight + chromeHeight;
|
||||
}
|
||||
// If a maximum height has been defined don't exceed this height
|
||||
const maxHeight = getMaxHeight(editor);
|
||||
if (maxHeight && resizeHeight > maxHeight) {
|
||||
resizeHeight = maxHeight;
|
||||
toggleScrolling(editor, true);
|
||||
}
|
||||
else {
|
||||
toggleScrolling(editor, false);
|
||||
}
|
||||
const old = oldSize.get();
|
||||
if (old.set) {
|
||||
editor.dom.setStyles(editor.getDoc().documentElement, { 'min-height': 0 });
|
||||
editor.dom.setStyles(editor.getBody(), { 'min-height': 'inherit' });
|
||||
}
|
||||
// Resize content element
|
||||
if (resizeHeight !== old.totalHeight && (contentHeight - resizeBottomMargin !== old.contentHeight || !old.set)) {
|
||||
const deltaSize = (resizeHeight - old.totalHeight);
|
||||
dom.setStyle(editor.getContainer(), 'height', resizeHeight + 'px');
|
||||
oldSize.set({
|
||||
totalHeight: resizeHeight,
|
||||
contentHeight,
|
||||
set: true,
|
||||
});
|
||||
fireResizeEditor(editor);
|
||||
// iPadOS has an issue where it won't rerender the body when the iframe is resized
|
||||
// however if we reset the scroll position then it re-renders correctly
|
||||
if (global.browser.isSafari() && (global.os.isMacOS() || global.os.isiOS())) {
|
||||
const win = editor.getWin();
|
||||
win.scrollTo(win.pageXOffset, win.pageYOffset);
|
||||
}
|
||||
// Ensure the selection is in view, as it's potentially out of view after inserting content into the editor
|
||||
if (editor.hasFocus() && shouldScrollIntoView(trigger)) {
|
||||
editor.selection.scrollIntoView();
|
||||
}
|
||||
// WebKit doesn't decrease the size of the body element until the iframe gets resized
|
||||
// So we need to continue to resize the iframe down until the size gets fixed
|
||||
if ((global.browser.isSafari() || global.browser.isChromium()) && deltaSize < 0) {
|
||||
resize(editor, oldSize, trigger, getExtraMarginBottom);
|
||||
}
|
||||
}
|
||||
};
|
||||
const setup = (editor, oldSize) => {
|
||||
const getExtraMarginBottom = () => getAutoResizeBottomMargin(editor);
|
||||
editor.on('init', (e) => {
|
||||
const overflowPadding = getAutoResizeOverflowPadding(editor);
|
||||
const dom = editor.dom;
|
||||
// Disable height 100% on the root document element otherwise we'll end up resizing indefinitely
|
||||
dom.setStyles(editor.getDoc().documentElement, {
|
||||
height: 'auto'
|
||||
});
|
||||
if (global.browser.isEdge() || global.browser.isIE()) {
|
||||
dom.setStyles(editor.getBody(), {
|
||||
'paddingLeft': overflowPadding,
|
||||
'paddingRight': overflowPadding,
|
||||
// IE & Edge have a min height of 150px by default on the body, so override that
|
||||
'min-height': 0
|
||||
});
|
||||
}
|
||||
else {
|
||||
dom.setStyles(editor.getBody(), {
|
||||
paddingLeft: overflowPadding,
|
||||
paddingRight: overflowPadding
|
||||
});
|
||||
}
|
||||
resize(editor, oldSize, e, getExtraMarginBottom);
|
||||
});
|
||||
editor.on('NodeChange SetContent keyup FullscreenStateChanged ResizeContent', (e) => {
|
||||
resize(editor, oldSize, e, getExtraMarginBottom);
|
||||
});
|
||||
};
|
||||
|
||||
const register = (editor, oldSize) => {
|
||||
editor.addCommand('mceAutoResize', () => {
|
||||
resize(editor, oldSize);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* This class contains all core logic for the autoresize plugin.
|
||||
*
|
||||
* @class tinymce.autoresize.Plugin
|
||||
* @private
|
||||
*/
|
||||
var Plugin = () => {
|
||||
global$1.add('autoresize', (editor) => {
|
||||
register$1(editor);
|
||||
// If autoresize is enabled, disable resize if the user hasn't explicitly enabled it
|
||||
// TINY-8288: This currently does nothing because of a bug in the theme
|
||||
if (!editor.options.isSet('resize')) {
|
||||
editor.options.set('resize', false);
|
||||
}
|
||||
if (!editor.inline) {
|
||||
const oldSize = Cell({
|
||||
totalHeight: 0,
|
||||
contentHeight: 0,
|
||||
set: false,
|
||||
});
|
||||
register(editor, oldSize);
|
||||
setup(editor, oldSize);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
Plugin();
|
||||
/** *****
|
||||
* DO NOT EXPORT ANYTHING
|
||||
*
|
||||
* IF YOU DO ROLLUP WILL LEAVE A GLOBAL ON THE PAGE
|
||||
*******/
|
||||
|
||||
})();
|
||||
+4
-1
@@ -1 +1,4 @@
|
||||
!function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.PluginManager"),t=tinymce.util.Tools.resolve("tinymce.Env");const o=e=>t=>t.options.get(e),s=o("min_height"),n=o("max_height"),i=o("autoresize_overflow_padding"),r=o("autoresize_bottom_margin"),g=(e,t)=>{const o=e.getBody();o&&(o.style.overflowY=t?"":"hidden",t||(o.scrollTop=0))},l=(e,t,o,s)=>{const n=parseInt(e.getStyle(t,o,s)??"",10);return isNaN(n)?0:n},a=(e,o,r,c)=>{const d=e.dom,h=e.getDoc();if(!h)return;if((e=>e.plugins.fullscreen&&e.plugins.fullscreen.isFullscreen())(e))return void g(e,!0);const m=h.documentElement,u=c?c():i(e),f=s(e)??e.getElement().offsetHeight;let p=f;const y=l(d,m,"margin-top",!0),S=l(d,m,"margin-bottom",!0);let C=m.offsetHeight+y+S+u;C<0&&(C=0);const H=e.getContainer().offsetHeight-e.getContentAreaContainer().offsetHeight;C+H>f&&(p=C+H);const b=n(e);b&&p>b?(p=b,g(e,!0)):g(e,!1);const w=o.get();if(w.set&&(e.dom.setStyles(e.getDoc().documentElement,{"min-height":0}),e.dom.setStyles(e.getBody(),{"min-height":"inherit"})),p!==w.totalHeight&&(C-u!==w.contentHeight||!w.set)){const s=p-w.totalHeight;if(d.setStyle(e.getContainer(),"height",p+"px"),o.set({totalHeight:p,contentHeight:C,set:!0}),(e=>{e.dispatch("ResizeEditor")})(e),t.browser.isSafari()&&(t.os.isMacOS()||t.os.isiOS())){const t=e.getWin();t.scrollTo(t.pageXOffset,t.pageYOffset)}e.hasFocus()&&(e=>{if("setcontent"===e?.type.toLowerCase()){const t=e;return!0===t.selection||!0===t.paste}return!1})(r)&&e.selection.scrollIntoView(),(t.browser.isSafari()||t.browser.isChromium())&&s<0&&a(e,o,r,c)}};e.add("autoresize",e=>{if((e=>{const t=e.options.register;t("autoresize_overflow_padding",{processor:"number",default:1}),t("autoresize_bottom_margin",{processor:"number",default:50})})(e),e.options.isSet("resize")||e.options.set("resize",!1),!e.inline){const o=(()=>{let e={totalHeight:0,contentHeight:0,set:!1};return{get:()=>e,set:t=>{e=t}}})();((e,t)=>{e.addCommand("mceAutoResize",()=>{a(e,t)})})(e,o),((e,o)=>{const s=()=>r(e);e.on("init",n=>{const r=i(e),g=e.dom;g.setStyles(e.getDoc().documentElement,{height:"auto"}),t.browser.isEdge()||t.browser.isIE()?g.setStyles(e.getBody(),{paddingLeft:r,paddingRight:r,"min-height":0}):g.setStyles(e.getBody(),{paddingLeft:r,paddingRight:r}),a(e,o,n,s)}),e.on("NodeChange SetContent keyup FullscreenStateChanged ResizeContent",t=>{a(e,o,t,s)})})(e,o)}})}();
|
||||
/**
|
||||
* TinyMCE version 7.3.0 (2024-08-07)
|
||||
*/
|
||||
!function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.PluginManager"),t=tinymce.util.Tools.resolve("tinymce.Env");const o=e=>t=>t.options.get(e),n=o("min_height"),s=o("max_height"),i=o("autoresize_overflow_padding"),r=o("autoresize_bottom_margin"),g=(e,t)=>{const o=e.getBody();o&&(o.style.overflowY=t?"":"hidden",t||(o.scrollTop=0))},l=(e,t,o,n)=>{var s;const i=parseInt(null!==(s=e.getStyle(t,o,n))&&void 0!==s?s:"",10);return isNaN(i)?0:i},a=(e,o,r,c)=>{var d;const u=e.dom,h=e.getDoc();if(!h)return;if((e=>e.plugins.fullscreen&&e.plugins.fullscreen.isFullscreen())(e))return void g(e,!0);const m=h.documentElement,f=c?c():i(e),p=null!==(d=n(e))&&void 0!==d?d:e.getElement().offsetHeight;let y=p;const S=l(u,m,"margin-top",!0),v=l(u,m,"margin-bottom",!0);let C=m.offsetHeight+S+v+f;C<0&&(C=0);const H=e.getContainer().offsetHeight-e.getContentAreaContainer().offsetHeight;C+H>p&&(y=C+H);const b=s(e);b&&y>b?(y=b,g(e,!0)):g(e,!1);const w=o.get();if(w.set&&(e.dom.setStyles(e.getDoc().documentElement,{"min-height":0}),e.dom.setStyles(e.getBody(),{"min-height":"inherit"})),y!==w.totalHeight&&(C-f!==w.contentHeight||!w.set)){const n=y-w.totalHeight;if(u.setStyle(e.getContainer(),"height",y+"px"),o.set({totalHeight:y,contentHeight:C,set:!0}),(e=>{e.dispatch("ResizeEditor")})(e),t.browser.isSafari()&&(t.os.isMacOS()||t.os.isiOS())){const t=e.getWin();t.scrollTo(t.pageXOffset,t.pageYOffset)}e.hasFocus()&&(e=>{if("setcontent"===(null==e?void 0:e.type.toLowerCase())){const t=e;return!0===t.selection||!0===t.paste}return!1})(r)&&e.selection.scrollIntoView(),(t.browser.isSafari()||t.browser.isChromium())&&n<0&&a(e,o,r,c)}};e.add("autoresize",(e=>{if((e=>{const t=e.options.register;t("autoresize_overflow_padding",{processor:"number",default:1}),t("autoresize_bottom_margin",{processor:"number",default:50})})(e),e.options.isSet("resize")||e.options.set("resize",!1),!e.inline){const o=(e=>{let t={totalHeight:0,contentHeight:0,set:!1};return{get:()=>t,set:e=>{t=e}}})();((e,t)=>{e.addCommand("mceAutoResize",(()=>{a(e,t)}))})(e,o),((e,o)=>{const n=()=>r(e);e.on("init",(s=>{const r=i(e),g=e.dom;g.setStyles(e.getDoc().documentElement,{height:"auto"}),t.browser.isEdge()||t.browser.isIE()?g.setStyles(e.getBody(),{paddingLeft:r,paddingRight:r,"min-height":0}):g.setStyles(e.getBody(),{paddingLeft:r,paddingRight:r}),a(e,o,s,n)})),e.on("NodeChange SetContent keyup FullscreenStateChanged ResizeContent",(t=>{a(e,o,t,n)}))})(e,o)}}))}();
|
||||
@@ -1,7 +0,0 @@
|
||||
// Exports the "autosave" plugin for usage with module loaders
|
||||
// Usage:
|
||||
// CommonJS:
|
||||
// require('tinymce/plugins/autosave')
|
||||
// ES2015:
|
||||
// import 'tinymce/plugins/autosave'
|
||||
require('./plugin.js');
|
||||
@@ -1,249 +0,0 @@
|
||||
/**
|
||||
* TinyMCE version 8.5.1 (2026-05-19)
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var global$4 = tinymce.util.Tools.resolve('tinymce.PluginManager');
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-wrapper-object-types */
|
||||
const hasProto = (v, constructor, predicate) => {
|
||||
if (predicate(v, constructor.prototype)) {
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
// String-based fallback time
|
||||
return v.constructor?.name === constructor.name;
|
||||
}
|
||||
};
|
||||
const typeOf = (x) => {
|
||||
const t = typeof x;
|
||||
if (x === null) {
|
||||
return 'null';
|
||||
}
|
||||
else if (t === 'object' && Array.isArray(x)) {
|
||||
return 'array';
|
||||
}
|
||||
else if (t === 'object' && hasProto(x, String, (o, proto) => proto.isPrototypeOf(o))) {
|
||||
return 'string';
|
||||
}
|
||||
else {
|
||||
return t;
|
||||
}
|
||||
};
|
||||
const isType = (type) => (value) => typeOf(value) === type;
|
||||
const eq = (t) => (a) => t === a;
|
||||
const isString = isType('string');
|
||||
const isUndefined = eq(undefined);
|
||||
|
||||
var global$3 = tinymce.util.Tools.resolve('tinymce.util.Delay');
|
||||
|
||||
var global$2 = tinymce.util.Tools.resolve('tinymce.util.LocalStorage');
|
||||
|
||||
var global$1 = tinymce.util.Tools.resolve('tinymce.util.Tools');
|
||||
|
||||
const fireRestoreDraft = (editor) => editor.dispatch('RestoreDraft');
|
||||
const fireStoreDraft = (editor) => editor.dispatch('StoreDraft');
|
||||
const fireRemoveDraft = (editor) => editor.dispatch('RemoveDraft');
|
||||
|
||||
const parse = (timeString) => {
|
||||
const multiples = {
|
||||
s: 1000,
|
||||
m: 60000
|
||||
};
|
||||
const parsedTime = /^(\d+)([ms]?)$/.exec(timeString);
|
||||
return (parsedTime && parsedTime[2] ? multiples[parsedTime[2]] : 1) * parseInt(timeString, 10);
|
||||
};
|
||||
|
||||
const option = (name) => (editor) => editor.options.get(name);
|
||||
const register$1 = (editor) => {
|
||||
const registerOption = editor.options.register;
|
||||
const timeProcessor = (value) => {
|
||||
const valid = isString(value);
|
||||
if (valid) {
|
||||
return { value: parse(value), valid };
|
||||
}
|
||||
else {
|
||||
return { valid: false, message: 'Must be a string.' };
|
||||
}
|
||||
};
|
||||
registerOption('autosave_ask_before_unload', {
|
||||
processor: 'boolean',
|
||||
default: true
|
||||
});
|
||||
registerOption('autosave_prefix', {
|
||||
processor: 'string',
|
||||
default: 'tinymce-autosave-{path}{query}{hash}-{id}-'
|
||||
});
|
||||
registerOption('autosave_restore_when_empty', {
|
||||
processor: 'boolean',
|
||||
default: false
|
||||
});
|
||||
registerOption('autosave_interval', {
|
||||
processor: timeProcessor,
|
||||
default: '30s'
|
||||
});
|
||||
registerOption('autosave_retention', {
|
||||
processor: timeProcessor,
|
||||
default: '20m'
|
||||
});
|
||||
};
|
||||
const shouldAskBeforeUnload = option('autosave_ask_before_unload');
|
||||
const shouldRestoreWhenEmpty = option('autosave_restore_when_empty');
|
||||
const getAutoSaveInterval = option('autosave_interval');
|
||||
const getAutoSaveRetention = option('autosave_retention');
|
||||
const getAutoSavePrefix = (editor) => {
|
||||
const location = document.location;
|
||||
return editor.options.get('autosave_prefix').replace(/{path}/g, location.pathname)
|
||||
.replace(/{query}/g, location.search)
|
||||
.replace(/{hash}/g, location.hash)
|
||||
.replace(/{id}/g, editor.id);
|
||||
};
|
||||
|
||||
const isEmpty = (editor, html) => {
|
||||
if (isUndefined(html)) {
|
||||
return editor.dom.isEmpty(editor.getBody());
|
||||
}
|
||||
else {
|
||||
const trimmedHtml = global$1.trim(html);
|
||||
if (trimmedHtml === '') {
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
const fragment = new DOMParser().parseFromString(trimmedHtml, 'text/html');
|
||||
return editor.dom.isEmpty(fragment);
|
||||
}
|
||||
}
|
||||
};
|
||||
const hasDraft = (editor) => {
|
||||
const time = parseInt(global$2.getItem(getAutoSavePrefix(editor) + 'time') ?? '0', 10) || 0;
|
||||
if (new Date().getTime() - time > getAutoSaveRetention(editor)) {
|
||||
removeDraft(editor, false);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const removeDraft = (editor, fire) => {
|
||||
const prefix = getAutoSavePrefix(editor);
|
||||
global$2.removeItem(prefix + 'draft');
|
||||
global$2.removeItem(prefix + 'time');
|
||||
if (fire !== false) {
|
||||
fireRemoveDraft(editor);
|
||||
}
|
||||
};
|
||||
const storeDraft = (editor) => {
|
||||
const prefix = getAutoSavePrefix(editor);
|
||||
if (!isEmpty(editor) && editor.isDirty()) {
|
||||
global$2.setItem(prefix + 'draft', editor.getContent({ format: 'raw', no_events: true }));
|
||||
global$2.setItem(prefix + 'time', new Date().getTime().toString());
|
||||
fireStoreDraft(editor);
|
||||
}
|
||||
};
|
||||
const restoreDraft = (editor) => {
|
||||
const prefix = getAutoSavePrefix(editor);
|
||||
if (hasDraft(editor)) {
|
||||
editor.setContent(global$2.getItem(prefix + 'draft') ?? '', { format: 'raw' });
|
||||
fireRestoreDraft(editor);
|
||||
}
|
||||
};
|
||||
const startStoreDraft = (editor) => {
|
||||
const interval = getAutoSaveInterval(editor);
|
||||
global$3.setEditorInterval(editor, () => {
|
||||
storeDraft(editor);
|
||||
}, interval);
|
||||
};
|
||||
const restoreLastDraft = (editor) => {
|
||||
editor.undoManager.transact(() => {
|
||||
restoreDraft(editor);
|
||||
removeDraft(editor);
|
||||
});
|
||||
editor.focus();
|
||||
};
|
||||
|
||||
const get = (editor) => ({
|
||||
hasDraft: () => hasDraft(editor),
|
||||
storeDraft: () => storeDraft(editor),
|
||||
restoreDraft: () => restoreDraft(editor),
|
||||
removeDraft: (fire) => removeDraft(editor, fire),
|
||||
isEmpty: (html) => isEmpty(editor, html)
|
||||
});
|
||||
|
||||
var global = tinymce.util.Tools.resolve('tinymce.EditorManager');
|
||||
|
||||
const setup = (editor) => {
|
||||
editor.editorManager.on('BeforeUnload', (e) => {
|
||||
let msg;
|
||||
global$1.each(global.get(), (editor) => {
|
||||
// Store a draft for each editor instance
|
||||
if (editor.plugins.autosave) {
|
||||
editor.plugins.autosave.storeDraft();
|
||||
}
|
||||
// Setup a return message if the editor is dirty
|
||||
if (!msg && editor.isDirty() && shouldAskBeforeUnload(editor)) {
|
||||
msg = editor.translate('You have unsaved changes are you sure you want to navigate away?');
|
||||
}
|
||||
});
|
||||
if (msg) {
|
||||
e.preventDefault();
|
||||
e.returnValue = msg;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const makeSetupHandler = (editor) => (api) => {
|
||||
const shouldEnable = () => hasDraft(editor) && !editor.mode.isReadOnly();
|
||||
api.setEnabled(shouldEnable());
|
||||
const editorEventCallback = () => api.setEnabled(shouldEnable());
|
||||
editor.on('StoreDraft RestoreDraft RemoveDraft', editorEventCallback);
|
||||
return () => editor.off('StoreDraft RestoreDraft RemoveDraft', editorEventCallback);
|
||||
};
|
||||
const register = (editor) => {
|
||||
// TODO: This was moved from makeSetupHandler as it would only be called when the menu item was rendered?
|
||||
// Is it safe to start this process when the plugin is registered?
|
||||
startStoreDraft(editor);
|
||||
const onAction = () => {
|
||||
restoreLastDraft(editor);
|
||||
};
|
||||
editor.ui.registry.addButton('restoredraft', {
|
||||
tooltip: 'Restore last draft',
|
||||
icon: 'restore-draft',
|
||||
onAction,
|
||||
onSetup: makeSetupHandler(editor)
|
||||
});
|
||||
editor.ui.registry.addMenuItem('restoredraft', {
|
||||
text: 'Restore last draft',
|
||||
icon: 'restore-draft',
|
||||
onAction,
|
||||
onSetup: makeSetupHandler(editor)
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* This class contains all core logic for the autosave plugin.
|
||||
*
|
||||
* @class tinymce.autosave.Plugin
|
||||
* @private
|
||||
*/
|
||||
var Plugin = () => {
|
||||
global$4.add('autosave', (editor) => {
|
||||
register$1(editor);
|
||||
setup(editor);
|
||||
register(editor);
|
||||
editor.on('init', () => {
|
||||
if (shouldRestoreWhenEmpty(editor) && editor.dom.isEmpty(editor.getBody())) {
|
||||
restoreDraft(editor);
|
||||
}
|
||||
});
|
||||
return get(editor);
|
||||
});
|
||||
};
|
||||
|
||||
Plugin();
|
||||
/** *****
|
||||
* DO NOT EXPORT ANYTHING
|
||||
*
|
||||
* IF YOU DO ROLLUP WILL LEAVE A GLOBAL ON THE PAGE
|
||||
*******/
|
||||
|
||||
})();
|
||||
+4
-1
@@ -1 +1,4 @@
|
||||
!function(){"use strict";var t=tinymce.util.Tools.resolve("tinymce.PluginManager");const e=t=>"string"===(t=>{const e=typeof t;return null===t?"null":"object"===e&&Array.isArray(t)?"array":"object"===e&&(r=o=t,(a=String).prototype.isPrototypeOf(r)||o.constructor?.name===a.name)?"string":e;var r,o,a})(t);const r=t=>undefined===t;var o=tinymce.util.Tools.resolve("tinymce.util.Delay"),a=tinymce.util.Tools.resolve("tinymce.util.LocalStorage"),s=tinymce.util.Tools.resolve("tinymce.util.Tools");const n=t=>{const e=/^(\d+)([ms]?)$/.exec(t);return(e&&e[2]?{s:1e3,m:6e4}[e[2]]:1)*parseInt(t,10)},i=t=>e=>e.options.get(t),u=i("autosave_ask_before_unload"),c=i("autosave_restore_when_empty"),l=i("autosave_interval"),m=i("autosave_retention"),d=t=>{const e=document.location;return t.options.get("autosave_prefix").replace(/{path}/g,e.pathname).replace(/{query}/g,e.search).replace(/{hash}/g,e.hash).replace(/{id}/g,t.id)},f=(t,e)=>{if(r(e))return t.dom.isEmpty(t.getBody());{const r=s.trim(e);if(""===r)return!0;{const e=(new DOMParser).parseFromString(r,"text/html");return t.dom.isEmpty(e)}}},v=t=>{const e=parseInt(a.getItem(d(t)+"time")??"0",10)||0;return!((new Date).getTime()-e>m(t)&&(p(t,!1),1))},p=(t,e)=>{const r=d(t);a.removeItem(r+"draft"),a.removeItem(r+"time"),!1!==e&&(t=>{t.dispatch("RemoveDraft")})(t)},y=t=>{const e=d(t);!f(t)&&t.isDirty()&&(a.setItem(e+"draft",t.getContent({format:"raw",no_events:!0})),a.setItem(e+"time",(new Date).getTime().toString()),(t=>{t.dispatch("StoreDraft")})(t))},g=t=>{const e=d(t);v(t)&&(t.setContent(a.getItem(e+"draft")??"",{format:"raw"}),(t=>{t.dispatch("RestoreDraft")})(t))};var D=tinymce.util.Tools.resolve("tinymce.EditorManager");const h=t=>e=>{const r=()=>v(t)&&!t.mode.isReadOnly();e.setEnabled(r());const o=()=>e.setEnabled(r());return t.on("StoreDraft RestoreDraft RemoveDraft",o),()=>t.off("StoreDraft RestoreDraft RemoveDraft",o)};t.add("autosave",t=>((t=>{const r=t.options.register,o=t=>{const r=e(t);return r?{value:n(t),valid:r}:{valid:!1,message:"Must be a string."}};r("autosave_ask_before_unload",{processor:"boolean",default:!0}),r("autosave_prefix",{processor:"string",default:"tinymce-autosave-{path}{query}{hash}-{id}-"}),r("autosave_restore_when_empty",{processor:"boolean",default:!1}),r("autosave_interval",{processor:o,default:"30s"}),r("autosave_retention",{processor:o,default:"20m"})})(t),(t=>{t.editorManager.on("BeforeUnload",t=>{let e;s.each(D.get(),t=>{t.plugins.autosave&&t.plugins.autosave.storeDraft(),!e&&t.isDirty()&&u(t)&&(e=t.translate("You have unsaved changes are you sure you want to navigate away?"))}),e&&(t.preventDefault(),t.returnValue=e)})})(t),(t=>{(t=>{const e=l(t);o.setEditorInterval(t,()=>{y(t)},e)})(t);const e=()=>{(t=>{t.undoManager.transact(()=>{g(t),p(t)}),t.focus()})(t)};t.ui.registry.addButton("restoredraft",{tooltip:"Restore last draft",icon:"restore-draft",onAction:e,onSetup:h(t)}),t.ui.registry.addMenuItem("restoredraft",{text:"Restore last draft",icon:"restore-draft",onAction:e,onSetup:h(t)})})(t),t.on("init",()=>{c(t)&&t.dom.isEmpty(t.getBody())&&g(t)}),(t=>({hasDraft:()=>v(t),storeDraft:()=>y(t),restoreDraft:()=>g(t),removeDraft:e=>p(t,e),isEmpty:e=>f(t,e)}))(t)))}();
|
||||
/**
|
||||
* TinyMCE version 7.3.0 (2024-08-07)
|
||||
*/
|
||||
!function(){"use strict";var t=tinymce.util.Tools.resolve("tinymce.PluginManager");const e=("string",t=>"string"===(t=>{const e=typeof t;return null===t?"null":"object"===e&&Array.isArray(t)?"array":"object"===e&&(r=o=t,(a=String).prototype.isPrototypeOf(r)||(null===(s=o.constructor)||void 0===s?void 0:s.name)===a.name)?"string":e;var r,o,a,s})(t));const r=(void 0,t=>undefined===t);var o=tinymce.util.Tools.resolve("tinymce.util.Delay"),a=tinymce.util.Tools.resolve("tinymce.util.LocalStorage"),s=tinymce.util.Tools.resolve("tinymce.util.Tools");const n=t=>{const e=/^(\d+)([ms]?)$/.exec(t);return(e&&e[2]?{s:1e3,m:6e4}[e[2]]:1)*parseInt(t,10)},i=t=>e=>e.options.get(t),u=i("autosave_ask_before_unload"),l=i("autosave_restore_when_empty"),c=i("autosave_interval"),d=i("autosave_retention"),m=t=>{const e=document.location;return t.options.get("autosave_prefix").replace(/{path}/g,e.pathname).replace(/{query}/g,e.search).replace(/{hash}/g,e.hash).replace(/{id}/g,t.id)},v=(t,e)=>{if(r(e))return t.dom.isEmpty(t.getBody());{const r=s.trim(e);if(""===r)return!0;{const e=(new DOMParser).parseFromString(r,"text/html");return t.dom.isEmpty(e)}}},f=t=>{var e;const r=parseInt(null!==(e=a.getItem(m(t)+"time"))&&void 0!==e?e:"0",10)||0;return!((new Date).getTime()-r>d(t)&&(p(t,!1),1))},p=(t,e)=>{const r=m(t);a.removeItem(r+"draft"),a.removeItem(r+"time"),!1!==e&&(t=>{t.dispatch("RemoveDraft")})(t)},g=t=>{const e=m(t);!v(t)&&t.isDirty()&&(a.setItem(e+"draft",t.getContent({format:"raw",no_events:!0})),a.setItem(e+"time",(new Date).getTime().toString()),(t=>{t.dispatch("StoreDraft")})(t))},y=t=>{var e;const r=m(t);f(t)&&(t.setContent(null!==(e=a.getItem(r+"draft"))&&void 0!==e?e:"",{format:"raw"}),(t=>{t.dispatch("RestoreDraft")})(t))};var D=tinymce.util.Tools.resolve("tinymce.EditorManager");const h=t=>e=>{e.setEnabled(f(t));const r=()=>e.setEnabled(f(t));return t.on("StoreDraft RestoreDraft RemoveDraft",r),()=>t.off("StoreDraft RestoreDraft RemoveDraft",r)};t.add("autosave",(t=>((t=>{const r=t.options.register,o=t=>{const r=e(t);return r?{value:n(t),valid:r}:{valid:!1,message:"Must be a string."}};r("autosave_ask_before_unload",{processor:"boolean",default:!0}),r("autosave_prefix",{processor:"string",default:"tinymce-autosave-{path}{query}{hash}-{id}-"}),r("autosave_restore_when_empty",{processor:"boolean",default:!1}),r("autosave_interval",{processor:o,default:"30s"}),r("autosave_retention",{processor:o,default:"20m"})})(t),(t=>{t.editorManager.on("BeforeUnload",(t=>{let e;s.each(D.get(),(t=>{t.plugins.autosave&&t.plugins.autosave.storeDraft(),!e&&t.isDirty()&&u(t)&&(e=t.translate("You have unsaved changes are you sure you want to navigate away?"))})),e&&(t.preventDefault(),t.returnValue=e)}))})(t),(t=>{(t=>{const e=c(t);o.setEditorInterval(t,(()=>{g(t)}),e)})(t);const e=()=>{(t=>{t.undoManager.transact((()=>{y(t),p(t)})),t.focus()})(t)};t.ui.registry.addButton("restoredraft",{tooltip:"Restore last draft",icon:"restore-draft",onAction:e,onSetup:h(t)}),t.ui.registry.addMenuItem("restoredraft",{text:"Restore last draft",icon:"restore-draft",onAction:e,onSetup:h(t)})})(t),t.on("init",(()=>{l(t)&&t.dom.isEmpty(t.getBody())&&y(t)})),(t=>({hasDraft:()=>f(t),storeDraft:()=>g(t),restoreDraft:()=>y(t),removeDraft:e=>p(t,e),isEmpty:e=>v(t,e)}))(t))))}();
|
||||
@@ -1,7 +0,0 @@
|
||||
// Exports the "charmap" plugin for usage with module loaders
|
||||
// Usage:
|
||||
// CommonJS:
|
||||
// require('tinymce/plugins/charmap')
|
||||
// ES2015:
|
||||
// import 'tinymce/plugins/charmap'
|
||||
require('./plugin.js');
|
||||
@@ -1,997 +0,0 @@
|
||||
/**
|
||||
* TinyMCE version 8.5.1 (2026-05-19)
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var global$1 = tinymce.util.Tools.resolve('tinymce.PluginManager');
|
||||
|
||||
const fireInsertCustomChar = (editor, chr) => {
|
||||
return editor.dispatch('insertCustomChar', { chr });
|
||||
};
|
||||
|
||||
const insertChar = (editor, chr) => {
|
||||
const evtChr = fireInsertCustomChar(editor, chr).chr;
|
||||
editor.execCommand('mceInsertContent', false, evtChr);
|
||||
};
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-wrapper-object-types */
|
||||
const hasProto = (v, constructor, predicate) => {
|
||||
if (predicate(v, constructor.prototype)) {
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
// String-based fallback time
|
||||
return v.constructor?.name === constructor.name;
|
||||
}
|
||||
};
|
||||
const typeOf = (x) => {
|
||||
const t = typeof x;
|
||||
if (x === null) {
|
||||
return 'null';
|
||||
}
|
||||
else if (t === 'object' && Array.isArray(x)) {
|
||||
return 'array';
|
||||
}
|
||||
else if (t === 'object' && hasProto(x, String, (o, proto) => proto.isPrototypeOf(o))) {
|
||||
return 'string';
|
||||
}
|
||||
else {
|
||||
return t;
|
||||
}
|
||||
};
|
||||
const isType = (type) => (value) => typeOf(value) === type;
|
||||
const isSimpleType = (type) => (value) => typeof value === type;
|
||||
const eq = (t) => (a) => t === a;
|
||||
const isArray$1 = isType('array');
|
||||
const isNull = eq(null);
|
||||
const isUndefined = eq(undefined);
|
||||
const isNullable = (a) => a === null || a === undefined;
|
||||
const isNonNullable = (a) => !isNullable(a);
|
||||
const isFunction = isSimpleType('function');
|
||||
|
||||
const constant = (value) => {
|
||||
return () => {
|
||||
return value;
|
||||
};
|
||||
};
|
||||
const never = constant(false);
|
||||
|
||||
/**
|
||||
* The `Optional` type represents a value (of any type) that potentially does
|
||||
* not exist. Any `Optional<T>` can either be a `Some<T>` (in which case the
|
||||
* value does exist) or a `None` (in which case the value does not exist). This
|
||||
* module defines a whole lot of FP-inspired utility functions for dealing with
|
||||
* `Optional` objects.
|
||||
*
|
||||
* Comparison with null or undefined:
|
||||
* - We don't get fancy null coalescing operators with `Optional`
|
||||
* - We do get fancy helper functions with `Optional`
|
||||
* - `Optional` support nesting, and allow for the type to still be nullable (or
|
||||
* another `Optional`)
|
||||
* - There is no option to turn off strict-optional-checks like there is for
|
||||
* strict-null-checks
|
||||
*/
|
||||
class Optional {
|
||||
tag;
|
||||
value;
|
||||
// Sneaky optimisation: every instance of Optional.none is identical, so just
|
||||
// reuse the same object
|
||||
static singletonNone = new Optional(false);
|
||||
// The internal representation has a `tag` and a `value`, but both are
|
||||
// private: able to be console.logged, but not able to be accessed by code
|
||||
constructor(tag, value) {
|
||||
this.tag = tag;
|
||||
this.value = value;
|
||||
}
|
||||
// --- Identities ---
|
||||
/**
|
||||
* Creates a new `Optional<T>` that **does** contain a value.
|
||||
*/
|
||||
static some(value) {
|
||||
return new Optional(true, value);
|
||||
}
|
||||
/**
|
||||
* Create a new `Optional<T>` that **does not** contain a value. `T` can be
|
||||
* any type because we don't actually have a `T`.
|
||||
*/
|
||||
static none() {
|
||||
return Optional.singletonNone;
|
||||
}
|
||||
/**
|
||||
* Perform a transform on an `Optional` type. Regardless of whether this
|
||||
* `Optional` contains a value or not, `fold` will return a value of type `U`.
|
||||
* If this `Optional` does not contain a value, the `U` will be created by
|
||||
* calling `onNone`. If this `Optional` does contain a value, the `U` will be
|
||||
* created by calling `onSome`.
|
||||
*
|
||||
* For the FP enthusiasts in the room, this function:
|
||||
* 1. Could be used to implement all of the functions below
|
||||
* 2. Forms a catamorphism
|
||||
*/
|
||||
fold(onNone, onSome) {
|
||||
if (this.tag) {
|
||||
return onSome(this.value);
|
||||
}
|
||||
else {
|
||||
return onNone();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Determine if this `Optional` object contains a value.
|
||||
*/
|
||||
isSome() {
|
||||
return this.tag;
|
||||
}
|
||||
/**
|
||||
* Determine if this `Optional` object **does not** contain a value.
|
||||
*/
|
||||
isNone() {
|
||||
return !this.tag;
|
||||
}
|
||||
// --- Functor (name stolen from Haskell / maths) ---
|
||||
/**
|
||||
* Perform a transform on an `Optional` object, **if** there is a value. If
|
||||
* you provide a function to turn a T into a U, this is the function you use
|
||||
* to turn an `Optional<T>` into an `Optional<U>`. If this **does** contain
|
||||
* a value then the output will also contain a value (that value being the
|
||||
* output of `mapper(this.value)`), and if this **does not** contain a value
|
||||
* then neither will the output.
|
||||
*/
|
||||
map(mapper) {
|
||||
if (this.tag) {
|
||||
return Optional.some(mapper(this.value));
|
||||
}
|
||||
else {
|
||||
return Optional.none();
|
||||
}
|
||||
}
|
||||
// --- Monad (name stolen from Haskell / maths) ---
|
||||
/**
|
||||
* Perform a transform on an `Optional` object, **if** there is a value.
|
||||
* Unlike `map`, here the transform itself also returns an `Optional`.
|
||||
*/
|
||||
bind(binder) {
|
||||
if (this.tag) {
|
||||
return binder(this.value);
|
||||
}
|
||||
else {
|
||||
return Optional.none();
|
||||
}
|
||||
}
|
||||
// --- Traversable (name stolen from Haskell / maths) ---
|
||||
/**
|
||||
* For a given predicate, this function finds out if there **exists** a value
|
||||
* inside this `Optional` object that meets the predicate. In practice, this
|
||||
* means that for `Optional`s that do not contain a value it returns false (as
|
||||
* no predicate-meeting value exists).
|
||||
*/
|
||||
exists(predicate) {
|
||||
return this.tag && predicate(this.value);
|
||||
}
|
||||
/**
|
||||
* For a given predicate, this function finds out if **all** the values inside
|
||||
* this `Optional` object meet the predicate. In practice, this means that
|
||||
* for `Optional`s that do not contain a value it returns true (as all 0
|
||||
* objects do meet the predicate).
|
||||
*/
|
||||
forall(predicate) {
|
||||
return !this.tag || predicate(this.value);
|
||||
}
|
||||
filter(predicate) {
|
||||
if (!this.tag || predicate(this.value)) {
|
||||
return this;
|
||||
}
|
||||
else {
|
||||
return Optional.none();
|
||||
}
|
||||
}
|
||||
// --- Getters ---
|
||||
/**
|
||||
* Get the value out of the inside of the `Optional` object, using a default
|
||||
* `replacement` value if the provided `Optional` object does not contain a
|
||||
* value.
|
||||
*/
|
||||
getOr(replacement) {
|
||||
return this.tag ? this.value : replacement;
|
||||
}
|
||||
/**
|
||||
* Get the value out of the inside of the `Optional` object, using a default
|
||||
* `replacement` value if the provided `Optional` object does not contain a
|
||||
* value. Unlike `getOr`, in this method the `replacement` object is also
|
||||
* `Optional` - meaning that this method will always return an `Optional`.
|
||||
*/
|
||||
or(replacement) {
|
||||
return this.tag ? this : replacement;
|
||||
}
|
||||
/**
|
||||
* Get the value out of the inside of the `Optional` object, using a default
|
||||
* `replacement` value if the provided `Optional` object does not contain a
|
||||
* value. Unlike `getOr`, in this method the `replacement` value is
|
||||
* "thunked" - that is to say that you don't pass a value to `getOrThunk`, you
|
||||
* pass a function which (if called) will **return** the `value` you want to
|
||||
* use.
|
||||
*/
|
||||
getOrThunk(thunk) {
|
||||
return this.tag ? this.value : thunk();
|
||||
}
|
||||
/**
|
||||
* Get the value out of the inside of the `Optional` object, using a default
|
||||
* `replacement` value if the provided Optional object does not contain a
|
||||
* value.
|
||||
*
|
||||
* Unlike `or`, in this method the `replacement` value is "thunked" - that is
|
||||
* to say that you don't pass a value to `orThunk`, you pass a function which
|
||||
* (if called) will **return** the `value` you want to use.
|
||||
*
|
||||
* Unlike `getOrThunk`, in this method the `replacement` value is also
|
||||
* `Optional`, meaning that this method will always return an `Optional`.
|
||||
*/
|
||||
orThunk(thunk) {
|
||||
return this.tag ? this : thunk();
|
||||
}
|
||||
/**
|
||||
* Get the value out of the inside of the `Optional` object, throwing an
|
||||
* exception if the provided `Optional` object does not contain a value.
|
||||
*
|
||||
* WARNING:
|
||||
* You should only be using this function if you know that the `Optional`
|
||||
* object **is not** empty (otherwise you're throwing exceptions in production
|
||||
* code, which is bad).
|
||||
*
|
||||
* In tests this is more acceptable.
|
||||
*
|
||||
* Prefer other methods to this, such as `.each`.
|
||||
*/
|
||||
getOrDie(message) {
|
||||
if (!this.tag) {
|
||||
throw new Error(message ?? 'Called getOrDie on None');
|
||||
}
|
||||
else {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
// --- Interop with null and undefined ---
|
||||
/**
|
||||
* Creates an `Optional` value from a nullable (or undefined-able) input.
|
||||
* Null, or undefined, is converted to `None`, and anything else is converted
|
||||
* to `Some`.
|
||||
*/
|
||||
static from(value) {
|
||||
return isNonNullable(value) ? Optional.some(value) : Optional.none();
|
||||
}
|
||||
/**
|
||||
* Converts an `Optional` to a nullable type, by getting the value if it
|
||||
* exists, or returning `null` if it does not.
|
||||
*/
|
||||
getOrNull() {
|
||||
return this.tag ? this.value : null;
|
||||
}
|
||||
/**
|
||||
* Converts an `Optional` to an undefined-able type, by getting the value if
|
||||
* it exists, or returning `undefined` if it does not.
|
||||
*/
|
||||
getOrUndefined() {
|
||||
return this.value;
|
||||
}
|
||||
// --- Utilities ---
|
||||
/**
|
||||
* If the `Optional` contains a value, perform an action on that value.
|
||||
* Unlike the rest of the methods on this type, `.each` has side-effects. If
|
||||
* you want to transform an `Optional<T>` **into** something, then this is not
|
||||
* the method for you. If you want to use an `Optional<T>` to **do**
|
||||
* something, then this is the method for you - provided you're okay with not
|
||||
* doing anything in the case where the `Optional` doesn't have a value inside
|
||||
* it. If you're not sure whether your use-case fits into transforming
|
||||
* **into** something or **doing** something, check whether it has a return
|
||||
* value. If it does, you should be performing a transform.
|
||||
*/
|
||||
each(worker) {
|
||||
if (this.tag) {
|
||||
worker(this.value);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Turn the `Optional` object into an array that contains all of the values
|
||||
* stored inside the `Optional`. In practice, this means the output will have
|
||||
* either 0 or 1 elements.
|
||||
*/
|
||||
toArray() {
|
||||
return this.tag ? [this.value] : [];
|
||||
}
|
||||
/**
|
||||
* Turn the `Optional` object into a string for debugging or printing. Not
|
||||
* recommended for production code, but good for debugging. Also note that
|
||||
* these days an `Optional` object can be logged to the console directly, and
|
||||
* its inner value (if it exists) will be visible.
|
||||
*/
|
||||
toString() {
|
||||
return this.tag ? `some(${this.value})` : 'none()';
|
||||
}
|
||||
}
|
||||
|
||||
const nativeSlice = Array.prototype.slice;
|
||||
const nativePush = Array.prototype.push;
|
||||
const map = (xs, f) => {
|
||||
// pre-allocating array size when it's guaranteed to be known
|
||||
// http://jsperf.com/push-allocated-vs-dynamic/22
|
||||
const len = xs.length;
|
||||
const r = new Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
const x = xs[i];
|
||||
r[i] = f(x, i);
|
||||
}
|
||||
return r;
|
||||
};
|
||||
// Unwound implementing other functions in terms of each.
|
||||
// The code size is roughly the same, and it should allow for better optimisation.
|
||||
// const each = function<T, U>(xs: T[], f: (x: T, i?: number, xs?: T[]) => void): void {
|
||||
const each = (xs, f) => {
|
||||
for (let i = 0, len = xs.length; i < len; i++) {
|
||||
const x = xs[i];
|
||||
f(x, i);
|
||||
}
|
||||
};
|
||||
const findUntil = (xs, pred, until) => {
|
||||
for (let i = 0, len = xs.length; i < len; i++) {
|
||||
const x = xs[i];
|
||||
if (pred(x, i)) {
|
||||
return Optional.some(x);
|
||||
}
|
||||
else if (until(x, i)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return Optional.none();
|
||||
};
|
||||
const find = (xs, pred) => {
|
||||
return findUntil(xs, pred, never);
|
||||
};
|
||||
const flatten = (xs) => {
|
||||
// Note, this is possible because push supports multiple arguments:
|
||||
// http://jsperf.com/concat-push/6
|
||||
// Note that in the past, concat() would silently work (very slowly) for array-like objects.
|
||||
// With this change it will throw an error.
|
||||
const r = [];
|
||||
for (let i = 0, len = xs.length; i < len; ++i) {
|
||||
// Ensure that each value is an array itself
|
||||
if (!isArray$1(xs[i])) {
|
||||
throw new Error('Arr.flatten item ' + i + ' was not an array, input: ' + xs);
|
||||
}
|
||||
nativePush.apply(r, xs[i]);
|
||||
}
|
||||
return r;
|
||||
};
|
||||
const bind = (xs, f) => flatten(map(xs, f));
|
||||
isFunction(Array.from) ? Array.from : (x) => nativeSlice.call(x);
|
||||
|
||||
const Cell = (initial) => {
|
||||
let value = initial;
|
||||
const get = () => {
|
||||
return value;
|
||||
};
|
||||
const set = (v) => {
|
||||
value = v;
|
||||
};
|
||||
return {
|
||||
get,
|
||||
set
|
||||
};
|
||||
};
|
||||
|
||||
const contains = (str, substr, start = 0, end) => {
|
||||
const idx = str.indexOf(substr, start);
|
||||
if (idx !== -1) {
|
||||
return isUndefined(end) ? true : idx + substr.length <= end;
|
||||
}
|
||||
else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const fromCodePoint = String.fromCodePoint;
|
||||
|
||||
// Run a function fn after rate ms. If another invocation occurs
|
||||
// during the time it is waiting, reschedule the function again
|
||||
// with the new arguments.
|
||||
const last = (fn, rate) => {
|
||||
let timer = null;
|
||||
const cancel = () => {
|
||||
if (!isNull(timer)) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
const throttle = (...args) => {
|
||||
cancel();
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
fn.apply(null, args);
|
||||
}, rate);
|
||||
};
|
||||
return {
|
||||
cancel,
|
||||
throttle
|
||||
};
|
||||
};
|
||||
|
||||
var global = tinymce.util.Tools.resolve('tinymce.util.Tools');
|
||||
|
||||
const option = (name) => (editor) => editor.options.get(name);
|
||||
const register$2 = (editor) => {
|
||||
const registerOption = editor.options.register;
|
||||
const charMapProcessor = (value) => isFunction(value) || isArray$1(value);
|
||||
registerOption('charmap', {
|
||||
processor: charMapProcessor,
|
||||
});
|
||||
registerOption('charmap_append', {
|
||||
processor: charMapProcessor
|
||||
});
|
||||
};
|
||||
const getCharMap$1 = option('charmap');
|
||||
const getCharMapAppend = option('charmap_append');
|
||||
|
||||
const isArray = global.isArray;
|
||||
const UserDefined = 'User Defined';
|
||||
const getDefaultCharMap = () => {
|
||||
return [
|
||||
// TODO: Merge categories with TBIO
|
||||
// {
|
||||
// name: 'Unknown',
|
||||
// characters : [
|
||||
// [160, 'no-break space'],
|
||||
// [173, 'soft hyphen'],
|
||||
// [34, 'quotation mark']
|
||||
// ]
|
||||
// },
|
||||
{ name: 'Currency',
|
||||
characters: [
|
||||
[36, 'dollar sign'],
|
||||
[162, 'cent sign'],
|
||||
[8364, 'euro sign'],
|
||||
[163, 'pound sign'],
|
||||
[165, 'yen sign'],
|
||||
[164, 'currency sign'],
|
||||
[8352, 'euro-currency sign'],
|
||||
[8353, 'colon sign'],
|
||||
[8354, 'cruzeiro sign'],
|
||||
[8355, 'french franc sign'],
|
||||
[8356, 'lira sign'],
|
||||
[8357, 'mill sign'],
|
||||
[8358, 'naira sign'],
|
||||
[8359, 'peseta sign'],
|
||||
[8360, 'rupee sign'],
|
||||
[8361, 'won sign'],
|
||||
[8362, 'new sheqel sign'],
|
||||
[8363, 'dong sign'],
|
||||
[8365, 'kip sign'],
|
||||
[8366, 'tugrik sign'],
|
||||
[8367, 'drachma sign'],
|
||||
[8368, 'german penny symbol'],
|
||||
[8369, 'peso sign'],
|
||||
[8370, 'guarani sign'],
|
||||
[8371, 'austral sign'],
|
||||
[8372, 'hryvnia sign'],
|
||||
[8373, 'cedi sign'],
|
||||
[8374, 'livre tournois sign'],
|
||||
[8375, 'spesmilo sign'],
|
||||
[8376, 'tenge sign'],
|
||||
[8377, 'indian rupee sign'],
|
||||
[8378, 'turkish lira sign'],
|
||||
[8379, 'nordic mark sign'],
|
||||
[8380, 'manat sign'],
|
||||
[8381, 'ruble sign'],
|
||||
[20870, 'yen character'],
|
||||
[20803, 'yuan character'],
|
||||
[22291, 'yuan character, in hong kong and taiwan'],
|
||||
[22278, 'yen/yuan character variant one']
|
||||
]
|
||||
},
|
||||
{ name: 'Text',
|
||||
characters: [
|
||||
[169, 'copyright sign'],
|
||||
[174, 'registered sign'],
|
||||
[8482, 'trade mark sign'],
|
||||
[8240, 'per mille sign'],
|
||||
[181, 'micro sign'],
|
||||
[183, 'middle dot'],
|
||||
[8226, 'bullet'],
|
||||
[8230, 'three dot leader'],
|
||||
[8242, 'minutes / feet'],
|
||||
[8243, 'seconds / inches'],
|
||||
[167, 'section sign'],
|
||||
[182, 'paragraph sign'],
|
||||
[223, 'sharp s / ess-zed']
|
||||
]
|
||||
},
|
||||
{ name: 'Quotations',
|
||||
characters: [
|
||||
[8249, 'single left-pointing angle quotation mark'],
|
||||
[8250, 'single right-pointing angle quotation mark'],
|
||||
[171, 'left pointing guillemet'],
|
||||
[187, 'right pointing guillemet'],
|
||||
[8216, 'left single quotation mark'],
|
||||
[8217, 'right single quotation mark'],
|
||||
[8220, 'left double quotation mark'],
|
||||
[8221, 'right double quotation mark'],
|
||||
[8218, 'single low-9 quotation mark'],
|
||||
[8222, 'double low-9 quotation mark'],
|
||||
[60, 'less-than sign'],
|
||||
[62, 'greater-than sign'],
|
||||
[8804, 'less-than or equal to'],
|
||||
[8805, 'greater-than or equal to'],
|
||||
[8211, 'en dash'],
|
||||
[8212, 'em dash'],
|
||||
[175, 'macron'],
|
||||
[8254, 'overline'],
|
||||
[164, 'currency sign'],
|
||||
[166, 'broken bar'],
|
||||
[168, 'diaeresis'],
|
||||
[161, 'inverted exclamation mark'],
|
||||
[191, 'turned question mark'],
|
||||
[710, 'circumflex accent'],
|
||||
[732, 'small tilde'],
|
||||
[176, 'degree sign'],
|
||||
[8722, 'minus sign'],
|
||||
[177, 'plus-minus sign'],
|
||||
[247, 'division sign'],
|
||||
[8260, 'fraction slash'],
|
||||
[215, 'multiplication sign'],
|
||||
[185, 'superscript one'],
|
||||
[178, 'superscript two'],
|
||||
[179, 'superscript three'],
|
||||
[188, 'fraction one quarter'],
|
||||
[189, 'fraction one half'],
|
||||
[190, 'fraction three quarters']
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Mathematical',
|
||||
characters: [
|
||||
[402, 'function / florin'],
|
||||
[8747, 'integral'],
|
||||
[8721, 'n-ary sumation'],
|
||||
[8734, 'infinity'],
|
||||
[8730, 'square root'],
|
||||
[8764, 'similar to'],
|
||||
[8773, 'approximately equal to'],
|
||||
[8776, 'almost equal to'],
|
||||
[8800, 'not equal to'],
|
||||
[8801, 'identical to'],
|
||||
[8712, 'element of'],
|
||||
[8713, 'not an element of'],
|
||||
[8715, 'contains as member'],
|
||||
[8719, 'n-ary product'],
|
||||
[8743, 'logical and'],
|
||||
[8744, 'logical or'],
|
||||
[172, 'not sign'],
|
||||
[8745, 'intersection'],
|
||||
[8746, 'union'],
|
||||
[8706, 'partial differential'],
|
||||
[8704, 'for all'],
|
||||
[8707, 'there exists'],
|
||||
[8709, 'diameter'],
|
||||
[8711, 'backward difference'],
|
||||
[8727, 'asterisk operator'],
|
||||
[8733, 'proportional to'],
|
||||
[8736, 'angle']
|
||||
]
|
||||
},
|
||||
// TODO: Merge categories with TBIO
|
||||
// {
|
||||
// name: 'Undefined',
|
||||
// characters: [
|
||||
// [180, 'acute accent'],
|
||||
// [184, 'cedilla'],
|
||||
// [170, 'feminine ordinal indicator'],
|
||||
// [186, 'masculine ordinal indicator'],
|
||||
// [8224, 'dagger'],
|
||||
// [8225, 'double dagger']
|
||||
// ]
|
||||
// },
|
||||
{
|
||||
name: 'Extended Latin',
|
||||
characters: [
|
||||
[192, 'A - grave'],
|
||||
[193, 'A - acute'],
|
||||
[194, 'A - circumflex'],
|
||||
[195, 'A - tilde'],
|
||||
[196, 'A - diaeresis'],
|
||||
[197, 'A - ring above'],
|
||||
[256, 'A - macron'],
|
||||
[198, 'ligature AE'],
|
||||
[199, 'C - cedilla'],
|
||||
[200, 'E - grave'],
|
||||
[201, 'E - acute'],
|
||||
[202, 'E - circumflex'],
|
||||
[203, 'E - diaeresis'],
|
||||
[274, 'E - macron'],
|
||||
[204, 'I - grave'],
|
||||
[205, 'I - acute'],
|
||||
[206, 'I - circumflex'],
|
||||
[207, 'I - diaeresis'],
|
||||
[298, 'I - macron'],
|
||||
[208, 'ETH'],
|
||||
[209, 'N - tilde'],
|
||||
[210, 'O - grave'],
|
||||
[211, 'O - acute'],
|
||||
[212, 'O - circumflex'],
|
||||
[213, 'O - tilde'],
|
||||
[214, 'O - diaeresis'],
|
||||
[216, 'O - slash'],
|
||||
[332, 'O - macron'],
|
||||
[338, 'ligature OE'],
|
||||
[352, 'S - caron'],
|
||||
[217, 'U - grave'],
|
||||
[218, 'U - acute'],
|
||||
[219, 'U - circumflex'],
|
||||
[220, 'U - diaeresis'],
|
||||
[362, 'U - macron'],
|
||||
[221, 'Y - acute'],
|
||||
[376, 'Y - diaeresis'],
|
||||
[562, 'Y - macron'],
|
||||
[222, 'THORN'],
|
||||
[224, 'a - grave'],
|
||||
[225, 'a - acute'],
|
||||
[226, 'a - circumflex'],
|
||||
[227, 'a - tilde'],
|
||||
[228, 'a - diaeresis'],
|
||||
[229, 'a - ring above'],
|
||||
[257, 'a - macron'],
|
||||
[230, 'ligature ae'],
|
||||
[231, 'c - cedilla'],
|
||||
[232, 'e - grave'],
|
||||
[233, 'e - acute'],
|
||||
[234, 'e - circumflex'],
|
||||
[235, 'e - diaeresis'],
|
||||
[275, 'e - macron'],
|
||||
[236, 'i - grave'],
|
||||
[237, 'i - acute'],
|
||||
[238, 'i - circumflex'],
|
||||
[239, 'i - diaeresis'],
|
||||
[299, 'i - macron'],
|
||||
[240, 'eth'],
|
||||
[241, 'n - tilde'],
|
||||
[242, 'o - grave'],
|
||||
[243, 'o - acute'],
|
||||
[244, 'o - circumflex'],
|
||||
[245, 'o - tilde'],
|
||||
[246, 'o - diaeresis'],
|
||||
[248, 'o slash'],
|
||||
[333, 'o macron'],
|
||||
[339, 'ligature oe'],
|
||||
[353, 's - caron'],
|
||||
[249, 'u - grave'],
|
||||
[250, 'u - acute'],
|
||||
[251, 'u - circumflex'],
|
||||
[252, 'u - diaeresis'],
|
||||
[363, 'u - macron'],
|
||||
[253, 'y - acute'],
|
||||
[254, 'thorn'],
|
||||
[255, 'y - diaeresis'],
|
||||
[563, 'y - macron'],
|
||||
[913, 'Alpha'],
|
||||
[914, 'Beta'],
|
||||
[915, 'Gamma'],
|
||||
[916, 'Delta'],
|
||||
[917, 'Epsilon'],
|
||||
[918, 'Zeta'],
|
||||
[919, 'Eta'],
|
||||
[920, 'Theta'],
|
||||
[921, 'Iota'],
|
||||
[922, 'Kappa'],
|
||||
[923, 'Lambda'],
|
||||
[924, 'Mu'],
|
||||
[925, 'Nu'],
|
||||
[926, 'Xi'],
|
||||
[927, 'Omicron'],
|
||||
[928, 'Pi'],
|
||||
[929, 'Rho'],
|
||||
[931, 'Sigma'],
|
||||
[932, 'Tau'],
|
||||
[933, 'Upsilon'],
|
||||
[934, 'Phi'],
|
||||
[935, 'Chi'],
|
||||
[936, 'Psi'],
|
||||
[937, 'Omega'],
|
||||
[945, 'alpha'],
|
||||
[946, 'beta'],
|
||||
[947, 'gamma'],
|
||||
[948, 'delta'],
|
||||
[949, 'epsilon'],
|
||||
[950, 'zeta'],
|
||||
[951, 'eta'],
|
||||
[952, 'theta'],
|
||||
[953, 'iota'],
|
||||
[954, 'kappa'],
|
||||
[955, 'lambda'],
|
||||
[956, 'mu'],
|
||||
[957, 'nu'],
|
||||
[958, 'xi'],
|
||||
[959, 'omicron'],
|
||||
[960, 'pi'],
|
||||
[961, 'rho'],
|
||||
[962, 'final sigma'],
|
||||
[963, 'sigma'],
|
||||
[964, 'tau'],
|
||||
[965, 'upsilon'],
|
||||
[966, 'phi'],
|
||||
[967, 'chi'],
|
||||
[968, 'psi'],
|
||||
[969, 'omega']
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Symbols',
|
||||
characters: [
|
||||
[8501, 'alef symbol'],
|
||||
[982, 'pi symbol'],
|
||||
[8476, 'real part symbol'],
|
||||
[978, 'upsilon - hook symbol'],
|
||||
[8472, 'Weierstrass p'],
|
||||
[8465, 'imaginary part']
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Arrows',
|
||||
characters: [
|
||||
[8592, 'leftwards arrow'],
|
||||
[8593, 'upwards arrow'],
|
||||
[8594, 'rightwards arrow'],
|
||||
[8595, 'downwards arrow'],
|
||||
[8596, 'left right arrow'],
|
||||
[8629, 'carriage return'],
|
||||
[8656, 'leftwards double arrow'],
|
||||
[8657, 'upwards double arrow'],
|
||||
[8658, 'rightwards double arrow'],
|
||||
[8659, 'downwards double arrow'],
|
||||
[8660, 'left right double arrow'],
|
||||
[8756, 'therefore'],
|
||||
[8834, 'subset of'],
|
||||
[8835, 'superset of'],
|
||||
[8836, 'not a subset of'],
|
||||
[8838, 'subset of or equal to'],
|
||||
[8839, 'superset of or equal to'],
|
||||
[8853, 'circled plus'],
|
||||
[8855, 'circled times'],
|
||||
[8869, 'perpendicular'],
|
||||
[8901, 'dot operator'],
|
||||
[8968, 'left ceiling'],
|
||||
[8969, 'right ceiling'],
|
||||
[8970, 'left floor'],
|
||||
[8971, 'right floor'],
|
||||
[9001, 'left-pointing angle bracket'],
|
||||
[9002, 'right-pointing angle bracket'],
|
||||
[9674, 'lozenge'],
|
||||
[9824, 'black spade suit'],
|
||||
[9827, 'black club suit'],
|
||||
[9829, 'black heart suit'],
|
||||
[9830, 'black diamond suit'],
|
||||
[8194, 'en space'],
|
||||
[8195, 'em space'],
|
||||
[8201, 'thin space'],
|
||||
[8204, 'zero width non-joiner'],
|
||||
[8205, 'zero width joiner'],
|
||||
[8206, 'left-to-right mark'],
|
||||
[8207, 'right-to-left mark']
|
||||
]
|
||||
}
|
||||
];
|
||||
};
|
||||
const charmapFilter = (charmap) => {
|
||||
return global.grep(charmap, (item) => {
|
||||
return isArray(item) && item.length === 2;
|
||||
});
|
||||
};
|
||||
const getCharsFromOption = (optionValue) => {
|
||||
if (isArray(optionValue)) {
|
||||
return charmapFilter(optionValue);
|
||||
}
|
||||
if (typeof optionValue === 'function') {
|
||||
return optionValue();
|
||||
}
|
||||
return [];
|
||||
};
|
||||
const extendCharMap = (editor, charmap) => {
|
||||
const userCharMap = getCharMap$1(editor);
|
||||
if (userCharMap) {
|
||||
charmap = [{ name: UserDefined, characters: getCharsFromOption(userCharMap) }];
|
||||
}
|
||||
const userCharMapAppend = getCharMapAppend(editor);
|
||||
if (userCharMapAppend) {
|
||||
const userDefinedGroup = global.grep(charmap, (cg) => cg.name === UserDefined);
|
||||
if (userDefinedGroup.length) {
|
||||
userDefinedGroup[0].characters = [...userDefinedGroup[0].characters, ...getCharsFromOption(userCharMapAppend)];
|
||||
return charmap;
|
||||
}
|
||||
return charmap.concat({ name: UserDefined, characters: getCharsFromOption(userCharMapAppend) });
|
||||
}
|
||||
return charmap;
|
||||
};
|
||||
const getCharMap = (editor) => {
|
||||
const groups = extendCharMap(editor, getDefaultCharMap());
|
||||
return groups.length > 1 ? [
|
||||
{
|
||||
name: 'All',
|
||||
characters: bind(groups, (g) => g.characters)
|
||||
}
|
||||
].concat(groups) : groups;
|
||||
};
|
||||
|
||||
const get = (editor) => {
|
||||
const getCharMap$1 = () => {
|
||||
return getCharMap(editor);
|
||||
};
|
||||
const insertChar$1 = (chr) => {
|
||||
insertChar(editor, chr);
|
||||
};
|
||||
return {
|
||||
getCharMap: getCharMap$1,
|
||||
insertChar: insertChar$1
|
||||
};
|
||||
};
|
||||
|
||||
const charMatches = (charCode, name, lowerCasePattern) => {
|
||||
if (contains(fromCodePoint(charCode).toLowerCase(), lowerCasePattern)) {
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
return contains(name.toLowerCase(), lowerCasePattern) || contains(name.toLowerCase().replace(/\s+/g, ''), lowerCasePattern);
|
||||
}
|
||||
};
|
||||
const scan = (group, pattern) => {
|
||||
const matches = [];
|
||||
const lowerCasePattern = pattern.toLowerCase();
|
||||
each(group.characters, (g) => {
|
||||
if (charMatches(g[0], g[1], lowerCasePattern)) {
|
||||
matches.push(g);
|
||||
}
|
||||
});
|
||||
return map(matches, (m) => ({
|
||||
text: m[1],
|
||||
value: fromCodePoint(m[0]),
|
||||
icon: fromCodePoint(m[0])
|
||||
}));
|
||||
};
|
||||
|
||||
const patternName = 'pattern';
|
||||
const open = (editor, charMap) => {
|
||||
const makeGroupItems = () => [
|
||||
{
|
||||
label: 'Search',
|
||||
type: 'input',
|
||||
name: patternName
|
||||
},
|
||||
{
|
||||
type: 'collection',
|
||||
name: 'results'
|
||||
// TODO TINY-3229 implement collection columns properly
|
||||
// columns: 'auto'
|
||||
}
|
||||
];
|
||||
const makeTabs = () => map(charMap, (charGroup) => ({
|
||||
title: charGroup.name,
|
||||
name: charGroup.name,
|
||||
items: makeGroupItems()
|
||||
}));
|
||||
const makePanel = () => ({ type: 'panel', items: makeGroupItems() });
|
||||
const makeTabPanel = () => ({ type: 'tabpanel', tabs: makeTabs() });
|
||||
const currentTab = charMap.length === 1 ? Cell(UserDefined) : Cell('All');
|
||||
const scanAndSet = (dialogApi, pattern) => {
|
||||
find(charMap, (group) => group.name === currentTab.get()).each((f) => {
|
||||
const items = scan(f, pattern);
|
||||
dialogApi.setData({
|
||||
results: items
|
||||
});
|
||||
});
|
||||
};
|
||||
const SEARCH_DELAY = 40;
|
||||
const updateFilter = last((dialogApi) => {
|
||||
const pattern = dialogApi.getData().pattern;
|
||||
scanAndSet(dialogApi, pattern);
|
||||
}, SEARCH_DELAY);
|
||||
const body = charMap.length === 1 ? makePanel() : makeTabPanel();
|
||||
const initialData = {
|
||||
pattern: '',
|
||||
results: scan(charMap[0], '')
|
||||
};
|
||||
const bridgeSpec = {
|
||||
title: 'Special Character',
|
||||
size: 'normal',
|
||||
body,
|
||||
buttons: [
|
||||
{
|
||||
type: 'cancel',
|
||||
name: 'close',
|
||||
text: 'Close',
|
||||
primary: true
|
||||
}
|
||||
],
|
||||
initialData,
|
||||
onAction: (api, details) => {
|
||||
if (details.name === 'results') {
|
||||
insertChar(editor, details.value);
|
||||
api.close();
|
||||
}
|
||||
},
|
||||
onTabChange: (dialogApi, details) => {
|
||||
currentTab.set(details.newTabName);
|
||||
updateFilter.throttle(dialogApi);
|
||||
},
|
||||
onChange: (dialogApi, changeData) => {
|
||||
if (changeData.name === patternName) {
|
||||
updateFilter.throttle(dialogApi);
|
||||
}
|
||||
}
|
||||
};
|
||||
const dialogApi = editor.windowManager.open(bridgeSpec);
|
||||
dialogApi.focus(patternName);
|
||||
};
|
||||
|
||||
const register$1 = (editor, charMap) => {
|
||||
editor.addCommand('mceShowCharmap', () => {
|
||||
open(editor, charMap);
|
||||
});
|
||||
};
|
||||
|
||||
const init = (editor, all) => {
|
||||
editor.ui.registry.addAutocompleter('charmap', {
|
||||
trigger: ':',
|
||||
columns: 'auto',
|
||||
minChars: 2,
|
||||
fetch: (pattern, _maxResults) => new Promise((resolve, _reject) => {
|
||||
resolve(scan(all, pattern));
|
||||
}),
|
||||
onAction: (autocompleteApi, rng, value) => {
|
||||
editor.selection.setRng(rng);
|
||||
editor.insertContent(value);
|
||||
autocompleteApi.hide();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onSetupEditable = (editor) => (api) => {
|
||||
const nodeChanged = () => {
|
||||
api.setEnabled(editor.selection.isEditable());
|
||||
};
|
||||
editor.on('NodeChange', nodeChanged);
|
||||
nodeChanged();
|
||||
return () => {
|
||||
editor.off('NodeChange', nodeChanged);
|
||||
};
|
||||
};
|
||||
const register = (editor) => {
|
||||
const onAction = () => editor.execCommand('mceShowCharmap');
|
||||
editor.ui.registry.addButton('charmap', {
|
||||
icon: 'insert-character',
|
||||
tooltip: 'Special character',
|
||||
onAction,
|
||||
onSetup: onSetupEditable(editor)
|
||||
});
|
||||
editor.ui.registry.addMenuItem('charmap', {
|
||||
icon: 'insert-character',
|
||||
text: 'Special character...',
|
||||
onAction,
|
||||
onSetup: onSetupEditable(editor)
|
||||
});
|
||||
};
|
||||
|
||||
var Plugin = () => {
|
||||
global$1.add('charmap', (editor) => {
|
||||
register$2(editor);
|
||||
const charMap = getCharMap(editor);
|
||||
register$1(editor, charMap);
|
||||
register(editor);
|
||||
init(editor, charMap[0]);
|
||||
return get(editor);
|
||||
});
|
||||
};
|
||||
|
||||
Plugin();
|
||||
/** *****
|
||||
* DO NOT EXPORT ANYTHING
|
||||
*
|
||||
* IF YOU DO ROLLUP WILL LEAVE A GLOBAL ON THE PAGE
|
||||
*******/
|
||||
|
||||
})();
|
||||
+4
-1
File diff suppressed because one or more lines are too long
@@ -1,7 +0,0 @@
|
||||
// Exports the "code" plugin for usage with module loaders
|
||||
// Usage:
|
||||
// CommonJS:
|
||||
// require('tinymce/plugins/code')
|
||||
// ES2015:
|
||||
// import 'tinymce/plugins/code'
|
||||
require('./plugin.js');
|
||||
@@ -1,98 +0,0 @@
|
||||
/**
|
||||
* TinyMCE version 8.5.1 (2026-05-19)
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var global = tinymce.util.Tools.resolve('tinymce.PluginManager');
|
||||
|
||||
const setContent = (editor, html) => {
|
||||
// We get a lovely "Wrong document" error in IE 11 if we
|
||||
// don't move the focus to the editor before creating an undo
|
||||
// transaction since it tries to make a bookmark for the current selection
|
||||
editor.focus();
|
||||
editor.undoManager.transact(() => {
|
||||
editor.setContent(html);
|
||||
});
|
||||
editor.selection.setCursorLocation();
|
||||
editor.nodeChanged();
|
||||
};
|
||||
const getContent = (editor) => {
|
||||
return editor.getContent({ source_view: true });
|
||||
};
|
||||
|
||||
const open = (editor) => {
|
||||
const editorContent = getContent(editor);
|
||||
editor.windowManager.open({
|
||||
title: 'Source Code',
|
||||
size: 'large',
|
||||
body: {
|
||||
type: 'panel',
|
||||
items: [
|
||||
{
|
||||
type: 'textarea',
|
||||
name: 'code',
|
||||
spellcheck: false,
|
||||
}
|
||||
]
|
||||
},
|
||||
buttons: [
|
||||
{
|
||||
type: 'cancel',
|
||||
name: 'cancel',
|
||||
text: 'Cancel'
|
||||
},
|
||||
{
|
||||
type: 'submit',
|
||||
name: 'save',
|
||||
text: 'Save',
|
||||
primary: true
|
||||
}
|
||||
],
|
||||
initialData: {
|
||||
code: editorContent
|
||||
},
|
||||
onSubmit: (api) => {
|
||||
setContent(editor, api.getData().code);
|
||||
api.close();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const register$1 = (editor) => {
|
||||
editor.addCommand('mceCodeEditor', () => {
|
||||
open(editor);
|
||||
});
|
||||
};
|
||||
|
||||
const register = (editor) => {
|
||||
const onAction = () => editor.execCommand('mceCodeEditor');
|
||||
editor.ui.registry.addButton('code', {
|
||||
icon: 'sourcecode',
|
||||
tooltip: 'Source code',
|
||||
onAction
|
||||
});
|
||||
editor.ui.registry.addMenuItem('code', {
|
||||
icon: 'sourcecode',
|
||||
text: 'Source code',
|
||||
onAction
|
||||
});
|
||||
};
|
||||
|
||||
var Plugin = () => {
|
||||
global.add('code', (editor) => {
|
||||
register$1(editor);
|
||||
register(editor);
|
||||
return {};
|
||||
});
|
||||
};
|
||||
|
||||
Plugin();
|
||||
/** *****
|
||||
* DO NOT EXPORT ANYTHING
|
||||
*
|
||||
* IF YOU DO ROLLUP WILL LEAVE A GLOBAL ON THE PAGE
|
||||
*******/
|
||||
|
||||
})();
|
||||
+4
-1
@@ -1 +1,4 @@
|
||||
!function(){"use strict";tinymce.util.Tools.resolve("tinymce.PluginManager").add("code",e=>((e=>{e.addCommand("mceCodeEditor",()=>{(e=>{const o=(e=>e.getContent({source_view:!0}))(e);e.windowManager.open({title:"Source Code",size:"large",body:{type:"panel",items:[{type:"textarea",name:"code",spellcheck:!1}]},buttons:[{type:"cancel",name:"cancel",text:"Cancel"},{type:"submit",name:"save",text:"Save",primary:!0}],initialData:{code:o},onSubmit:o=>{((e,o)=>{e.focus(),e.undoManager.transact(()=>{e.setContent(o)}),e.selection.setCursorLocation(),e.nodeChanged()})(e,o.getData().code),o.close()}})})(e)})})(e),(e=>{const o=()=>e.execCommand("mceCodeEditor");e.ui.registry.addButton("code",{icon:"sourcecode",tooltip:"Source code",onAction:o}),e.ui.registry.addMenuItem("code",{icon:"sourcecode",text:"Source code",onAction:o})})(e),{}))}();
|
||||
/**
|
||||
* TinyMCE version 7.3.0 (2024-08-07)
|
||||
*/
|
||||
!function(){"use strict";tinymce.util.Tools.resolve("tinymce.PluginManager").add("code",(e=>((e=>{e.addCommand("mceCodeEditor",(()=>{(e=>{const o=(e=>e.getContent({source_view:!0}))(e);e.windowManager.open({title:"Source Code",size:"large",body:{type:"panel",items:[{type:"textarea",name:"code"}]},buttons:[{type:"cancel",name:"cancel",text:"Cancel"},{type:"submit",name:"save",text:"Save",primary:!0}],initialData:{code:o},onSubmit:o=>{((e,o)=>{e.focus(),e.undoManager.transact((()=>{e.setContent(o)})),e.selection.setCursorLocation(),e.nodeChanged()})(e,o.getData().code),o.close()}})})(e)}))})(e),(e=>{const o=()=>e.execCommand("mceCodeEditor");e.ui.registry.addButton("code",{icon:"sourcecode",tooltip:"Source code",onAction:o}),e.ui.registry.addMenuItem("code",{icon:"sourcecode",text:"Source code",onAction:o})})(e),{})))}();
|
||||
@@ -1,7 +0,0 @@
|
||||
// Exports the "codesample" plugin for usage with module loaders
|
||||
// Usage:
|
||||
// CommonJS:
|
||||
// require('tinymce/plugins/codesample')
|
||||
// ES2015:
|
||||
// import 'tinymce/plugins/codesample'
|
||||
require('./plugin.js');
|
||||
File diff suppressed because it is too large
Load Diff
+3
-8
File diff suppressed because one or more lines are too long
@@ -1,7 +0,0 @@
|
||||
// Exports the "directionality" plugin for usage with module loaders
|
||||
// Usage:
|
||||
// CommonJS:
|
||||
// require('tinymce/plugins/directionality')
|
||||
// ES2015:
|
||||
// import 'tinymce/plugins/directionality'
|
||||
require('./plugin.js');
|
||||
@@ -1,670 +0,0 @@
|
||||
/**
|
||||
* TinyMCE version 8.5.1 (2026-05-19)
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var global = tinymce.util.Tools.resolve('tinymce.PluginManager');
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-wrapper-object-types */
|
||||
const hasProto = (v, constructor, predicate) => {
|
||||
if (predicate(v, constructor.prototype)) {
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
// String-based fallback time
|
||||
return v.constructor?.name === constructor.name;
|
||||
}
|
||||
};
|
||||
const typeOf = (x) => {
|
||||
const t = typeof x;
|
||||
if (x === null) {
|
||||
return 'null';
|
||||
}
|
||||
else if (t === 'object' && Array.isArray(x)) {
|
||||
return 'array';
|
||||
}
|
||||
else if (t === 'object' && hasProto(x, String, (o, proto) => proto.isPrototypeOf(o))) {
|
||||
return 'string';
|
||||
}
|
||||
else {
|
||||
return t;
|
||||
}
|
||||
};
|
||||
const isType$1 = (type) => (value) => typeOf(value) === type;
|
||||
const isSimpleType = (type) => (value) => typeof value === type;
|
||||
const isString = isType$1('string');
|
||||
const isBoolean = isSimpleType('boolean');
|
||||
const isNullable = (a) => a === null || a === undefined;
|
||||
const isNonNullable = (a) => !isNullable(a);
|
||||
const isFunction = isSimpleType('function');
|
||||
const isNumber = isSimpleType('number');
|
||||
|
||||
/** Compose two unary functions. Similar to compose, but avoids using Function.prototype.apply. */
|
||||
const compose1 = (fbc, fab) => (a) => fbc(fab(a));
|
||||
const constant = (value) => {
|
||||
return () => {
|
||||
return value;
|
||||
};
|
||||
};
|
||||
const tripleEquals = (a, b) => {
|
||||
return a === b;
|
||||
};
|
||||
const never = constant(false);
|
||||
|
||||
/**
|
||||
* The `Optional` type represents a value (of any type) that potentially does
|
||||
* not exist. Any `Optional<T>` can either be a `Some<T>` (in which case the
|
||||
* value does exist) or a `None` (in which case the value does not exist). This
|
||||
* module defines a whole lot of FP-inspired utility functions for dealing with
|
||||
* `Optional` objects.
|
||||
*
|
||||
* Comparison with null or undefined:
|
||||
* - We don't get fancy null coalescing operators with `Optional`
|
||||
* - We do get fancy helper functions with `Optional`
|
||||
* - `Optional` support nesting, and allow for the type to still be nullable (or
|
||||
* another `Optional`)
|
||||
* - There is no option to turn off strict-optional-checks like there is for
|
||||
* strict-null-checks
|
||||
*/
|
||||
class Optional {
|
||||
tag;
|
||||
value;
|
||||
// Sneaky optimisation: every instance of Optional.none is identical, so just
|
||||
// reuse the same object
|
||||
static singletonNone = new Optional(false);
|
||||
// The internal representation has a `tag` and a `value`, but both are
|
||||
// private: able to be console.logged, but not able to be accessed by code
|
||||
constructor(tag, value) {
|
||||
this.tag = tag;
|
||||
this.value = value;
|
||||
}
|
||||
// --- Identities ---
|
||||
/**
|
||||
* Creates a new `Optional<T>` that **does** contain a value.
|
||||
*/
|
||||
static some(value) {
|
||||
return new Optional(true, value);
|
||||
}
|
||||
/**
|
||||
* Create a new `Optional<T>` that **does not** contain a value. `T` can be
|
||||
* any type because we don't actually have a `T`.
|
||||
*/
|
||||
static none() {
|
||||
return Optional.singletonNone;
|
||||
}
|
||||
/**
|
||||
* Perform a transform on an `Optional` type. Regardless of whether this
|
||||
* `Optional` contains a value or not, `fold` will return a value of type `U`.
|
||||
* If this `Optional` does not contain a value, the `U` will be created by
|
||||
* calling `onNone`. If this `Optional` does contain a value, the `U` will be
|
||||
* created by calling `onSome`.
|
||||
*
|
||||
* For the FP enthusiasts in the room, this function:
|
||||
* 1. Could be used to implement all of the functions below
|
||||
* 2. Forms a catamorphism
|
||||
*/
|
||||
fold(onNone, onSome) {
|
||||
if (this.tag) {
|
||||
return onSome(this.value);
|
||||
}
|
||||
else {
|
||||
return onNone();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Determine if this `Optional` object contains a value.
|
||||
*/
|
||||
isSome() {
|
||||
return this.tag;
|
||||
}
|
||||
/**
|
||||
* Determine if this `Optional` object **does not** contain a value.
|
||||
*/
|
||||
isNone() {
|
||||
return !this.tag;
|
||||
}
|
||||
// --- Functor (name stolen from Haskell / maths) ---
|
||||
/**
|
||||
* Perform a transform on an `Optional` object, **if** there is a value. If
|
||||
* you provide a function to turn a T into a U, this is the function you use
|
||||
* to turn an `Optional<T>` into an `Optional<U>`. If this **does** contain
|
||||
* a value then the output will also contain a value (that value being the
|
||||
* output of `mapper(this.value)`), and if this **does not** contain a value
|
||||
* then neither will the output.
|
||||
*/
|
||||
map(mapper) {
|
||||
if (this.tag) {
|
||||
return Optional.some(mapper(this.value));
|
||||
}
|
||||
else {
|
||||
return Optional.none();
|
||||
}
|
||||
}
|
||||
// --- Monad (name stolen from Haskell / maths) ---
|
||||
/**
|
||||
* Perform a transform on an `Optional` object, **if** there is a value.
|
||||
* Unlike `map`, here the transform itself also returns an `Optional`.
|
||||
*/
|
||||
bind(binder) {
|
||||
if (this.tag) {
|
||||
return binder(this.value);
|
||||
}
|
||||
else {
|
||||
return Optional.none();
|
||||
}
|
||||
}
|
||||
// --- Traversable (name stolen from Haskell / maths) ---
|
||||
/**
|
||||
* For a given predicate, this function finds out if there **exists** a value
|
||||
* inside this `Optional` object that meets the predicate. In practice, this
|
||||
* means that for `Optional`s that do not contain a value it returns false (as
|
||||
* no predicate-meeting value exists).
|
||||
*/
|
||||
exists(predicate) {
|
||||
return this.tag && predicate(this.value);
|
||||
}
|
||||
/**
|
||||
* For a given predicate, this function finds out if **all** the values inside
|
||||
* this `Optional` object meet the predicate. In practice, this means that
|
||||
* for `Optional`s that do not contain a value it returns true (as all 0
|
||||
* objects do meet the predicate).
|
||||
*/
|
||||
forall(predicate) {
|
||||
return !this.tag || predicate(this.value);
|
||||
}
|
||||
filter(predicate) {
|
||||
if (!this.tag || predicate(this.value)) {
|
||||
return this;
|
||||
}
|
||||
else {
|
||||
return Optional.none();
|
||||
}
|
||||
}
|
||||
// --- Getters ---
|
||||
/**
|
||||
* Get the value out of the inside of the `Optional` object, using a default
|
||||
* `replacement` value if the provided `Optional` object does not contain a
|
||||
* value.
|
||||
*/
|
||||
getOr(replacement) {
|
||||
return this.tag ? this.value : replacement;
|
||||
}
|
||||
/**
|
||||
* Get the value out of the inside of the `Optional` object, using a default
|
||||
* `replacement` value if the provided `Optional` object does not contain a
|
||||
* value. Unlike `getOr`, in this method the `replacement` object is also
|
||||
* `Optional` - meaning that this method will always return an `Optional`.
|
||||
*/
|
||||
or(replacement) {
|
||||
return this.tag ? this : replacement;
|
||||
}
|
||||
/**
|
||||
* Get the value out of the inside of the `Optional` object, using a default
|
||||
* `replacement` value if the provided `Optional` object does not contain a
|
||||
* value. Unlike `getOr`, in this method the `replacement` value is
|
||||
* "thunked" - that is to say that you don't pass a value to `getOrThunk`, you
|
||||
* pass a function which (if called) will **return** the `value` you want to
|
||||
* use.
|
||||
*/
|
||||
getOrThunk(thunk) {
|
||||
return this.tag ? this.value : thunk();
|
||||
}
|
||||
/**
|
||||
* Get the value out of the inside of the `Optional` object, using a default
|
||||
* `replacement` value if the provided Optional object does not contain a
|
||||
* value.
|
||||
*
|
||||
* Unlike `or`, in this method the `replacement` value is "thunked" - that is
|
||||
* to say that you don't pass a value to `orThunk`, you pass a function which
|
||||
* (if called) will **return** the `value` you want to use.
|
||||
*
|
||||
* Unlike `getOrThunk`, in this method the `replacement` value is also
|
||||
* `Optional`, meaning that this method will always return an `Optional`.
|
||||
*/
|
||||
orThunk(thunk) {
|
||||
return this.tag ? this : thunk();
|
||||
}
|
||||
/**
|
||||
* Get the value out of the inside of the `Optional` object, throwing an
|
||||
* exception if the provided `Optional` object does not contain a value.
|
||||
*
|
||||
* WARNING:
|
||||
* You should only be using this function if you know that the `Optional`
|
||||
* object **is not** empty (otherwise you're throwing exceptions in production
|
||||
* code, which is bad).
|
||||
*
|
||||
* In tests this is more acceptable.
|
||||
*
|
||||
* Prefer other methods to this, such as `.each`.
|
||||
*/
|
||||
getOrDie(message) {
|
||||
if (!this.tag) {
|
||||
throw new Error(message ?? 'Called getOrDie on None');
|
||||
}
|
||||
else {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
// --- Interop with null and undefined ---
|
||||
/**
|
||||
* Creates an `Optional` value from a nullable (or undefined-able) input.
|
||||
* Null, or undefined, is converted to `None`, and anything else is converted
|
||||
* to `Some`.
|
||||
*/
|
||||
static from(value) {
|
||||
return isNonNullable(value) ? Optional.some(value) : Optional.none();
|
||||
}
|
||||
/**
|
||||
* Converts an `Optional` to a nullable type, by getting the value if it
|
||||
* exists, or returning `null` if it does not.
|
||||
*/
|
||||
getOrNull() {
|
||||
return this.tag ? this.value : null;
|
||||
}
|
||||
/**
|
||||
* Converts an `Optional` to an undefined-able type, by getting the value if
|
||||
* it exists, or returning `undefined` if it does not.
|
||||
*/
|
||||
getOrUndefined() {
|
||||
return this.value;
|
||||
}
|
||||
// --- Utilities ---
|
||||
/**
|
||||
* If the `Optional` contains a value, perform an action on that value.
|
||||
* Unlike the rest of the methods on this type, `.each` has side-effects. If
|
||||
* you want to transform an `Optional<T>` **into** something, then this is not
|
||||
* the method for you. If you want to use an `Optional<T>` to **do**
|
||||
* something, then this is the method for you - provided you're okay with not
|
||||
* doing anything in the case where the `Optional` doesn't have a value inside
|
||||
* it. If you're not sure whether your use-case fits into transforming
|
||||
* **into** something or **doing** something, check whether it has a return
|
||||
* value. If it does, you should be performing a transform.
|
||||
*/
|
||||
each(worker) {
|
||||
if (this.tag) {
|
||||
worker(this.value);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Turn the `Optional` object into an array that contains all of the values
|
||||
* stored inside the `Optional`. In practice, this means the output will have
|
||||
* either 0 or 1 elements.
|
||||
*/
|
||||
toArray() {
|
||||
return this.tag ? [this.value] : [];
|
||||
}
|
||||
/**
|
||||
* Turn the `Optional` object into a string for debugging or printing. Not
|
||||
* recommended for production code, but good for debugging. Also note that
|
||||
* these days an `Optional` object can be logged to the console directly, and
|
||||
* its inner value (if it exists) will be visible.
|
||||
*/
|
||||
toString() {
|
||||
return this.tag ? `some(${this.value})` : 'none()';
|
||||
}
|
||||
}
|
||||
|
||||
const nativeSlice = Array.prototype.slice;
|
||||
const map = (xs, f) => {
|
||||
// pre-allocating array size when it's guaranteed to be known
|
||||
// http://jsperf.com/push-allocated-vs-dynamic/22
|
||||
const len = xs.length;
|
||||
const r = new Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
const x = xs[i];
|
||||
r[i] = f(x, i);
|
||||
}
|
||||
return r;
|
||||
};
|
||||
// Unwound implementing other functions in terms of each.
|
||||
// The code size is roughly the same, and it should allow for better optimisation.
|
||||
// const each = function<T, U>(xs: T[], f: (x: T, i?: number, xs?: T[]) => void): void {
|
||||
const each = (xs, f) => {
|
||||
for (let i = 0, len = xs.length; i < len; i++) {
|
||||
const x = xs[i];
|
||||
f(x, i);
|
||||
}
|
||||
};
|
||||
const filter = (xs, pred) => {
|
||||
const r = [];
|
||||
for (let i = 0, len = xs.length; i < len; i++) {
|
||||
const x = xs[i];
|
||||
if (pred(x, i)) {
|
||||
r.push(x);
|
||||
}
|
||||
}
|
||||
return r;
|
||||
};
|
||||
isFunction(Array.from) ? Array.from : (x) => nativeSlice.call(x);
|
||||
|
||||
/**
|
||||
* **Is** the value stored inside this Optional object equal to `rhs`?
|
||||
*/
|
||||
const is$1 = (lhs, rhs, comparator = tripleEquals) => lhs.exists((left) => comparator(left, rhs));
|
||||
|
||||
const blank = (r) => (s) => s.replace(r, '');
|
||||
/** removes all leading and trailing spaces */
|
||||
const trim = blank(/^\s+|\s+$/g);
|
||||
const isNotEmpty = (s) => s.length > 0;
|
||||
|
||||
const fromHtml = (html, scope) => {
|
||||
const doc = scope || document;
|
||||
const div = doc.createElement('div');
|
||||
div.innerHTML = html;
|
||||
if (!div.hasChildNodes() || div.childNodes.length > 1) {
|
||||
const message = 'HTML does not have a single root node';
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(message, html);
|
||||
throw new Error(message);
|
||||
}
|
||||
return fromDom(div.childNodes[0]);
|
||||
};
|
||||
const fromTag = (tag, scope) => {
|
||||
const doc = scope || document;
|
||||
const node = doc.createElement(tag);
|
||||
return fromDom(node);
|
||||
};
|
||||
const fromText = (text, scope) => {
|
||||
const doc = scope || document;
|
||||
const node = doc.createTextNode(text);
|
||||
return fromDom(node);
|
||||
};
|
||||
const fromDom = (node) => {
|
||||
// TODO: Consider removing this check, but left atm for safety
|
||||
if (node === null || node === undefined) {
|
||||
throw new Error('Node cannot be null or undefined');
|
||||
}
|
||||
return {
|
||||
dom: node
|
||||
};
|
||||
};
|
||||
const fromPoint = (docElm, x, y) => Optional.from(docElm.dom.elementFromPoint(x, y)).map(fromDom);
|
||||
// tslint:disable-next-line:variable-name
|
||||
const SugarElement = {
|
||||
fromHtml,
|
||||
fromTag,
|
||||
fromText,
|
||||
fromDom,
|
||||
fromPoint
|
||||
};
|
||||
|
||||
const DOCUMENT_FRAGMENT = 11;
|
||||
const ELEMENT = 1;
|
||||
const TEXT = 3;
|
||||
|
||||
const is = (element, selector) => {
|
||||
const dom = element.dom;
|
||||
if (dom.nodeType !== ELEMENT) {
|
||||
return false;
|
||||
}
|
||||
else {
|
||||
const elem = dom;
|
||||
if (elem.matches !== undefined) {
|
||||
return elem.matches(selector);
|
||||
}
|
||||
else if (elem.msMatchesSelector !== undefined) {
|
||||
return elem.msMatchesSelector(selector);
|
||||
}
|
||||
else if (elem.webkitMatchesSelector !== undefined) {
|
||||
return elem.webkitMatchesSelector(selector);
|
||||
}
|
||||
else if (elem.mozMatchesSelector !== undefined) {
|
||||
// cast to any as mozMatchesSelector doesn't exist in TS DOM lib
|
||||
return elem.mozMatchesSelector(selector);
|
||||
}
|
||||
else {
|
||||
throw new Error('Browser lacks native selectors');
|
||||
} // unfortunately we can't throw this on startup :(
|
||||
}
|
||||
};
|
||||
|
||||
const name = (element) => {
|
||||
const r = element.dom.nodeName;
|
||||
return r.toLowerCase();
|
||||
};
|
||||
const type = (element) => element.dom.nodeType;
|
||||
const isType = (t) => (element) => type(element) === t;
|
||||
const isElement = isType(ELEMENT);
|
||||
const isText = isType(TEXT);
|
||||
const isDocumentFragment = isType(DOCUMENT_FRAGMENT);
|
||||
const isTag = (tag) => (e) => isElement(e) && name(e) === tag;
|
||||
|
||||
const children$2 = (element) => map(element.dom.childNodes, SugarElement.fromDom);
|
||||
|
||||
/**
|
||||
* Is the element a ShadowRoot?
|
||||
*
|
||||
* Note: this is insufficient to test if any element is a shadow root, but it is sufficient to differentiate between
|
||||
* a Document and a ShadowRoot.
|
||||
*/
|
||||
const isShadowRoot = (dos) => isDocumentFragment(dos) && isNonNullable(dos.dom.host);
|
||||
const getRootNode = (e) => SugarElement.fromDom(e.dom.getRootNode());
|
||||
/** If this element is in a ShadowRoot, return it. */
|
||||
const getShadowRoot = (e) => {
|
||||
const r = getRootNode(e);
|
||||
return isShadowRoot(r) ? Optional.some(r) : Optional.none();
|
||||
};
|
||||
/** Return the host of a ShadowRoot.
|
||||
*
|
||||
* This function will throw if Shadow DOM is unsupported in the browser, or if the host is null.
|
||||
* If you actually have a ShadowRoot, this shouldn't happen.
|
||||
*/
|
||||
const getShadowHost = (e) => SugarElement.fromDom(e.dom.host);
|
||||
|
||||
const rawSet = (dom, key, value) => {
|
||||
/*
|
||||
* JQuery coerced everything to a string, and silently did nothing on text node/null/undefined.
|
||||
*
|
||||
* We fail on those invalid cases, only allowing numbers and booleans.
|
||||
*/
|
||||
if (isString(value) || isBoolean(value) || isNumber(value)) {
|
||||
dom.setAttribute(key, value + '');
|
||||
}
|
||||
else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Invalid call to Attribute.set. Key ', key, ':: Value ', value, ':: Element ', dom);
|
||||
throw new Error('Attribute value was not simple');
|
||||
}
|
||||
};
|
||||
const set = (element, key, value) => {
|
||||
rawSet(element.dom, key, value);
|
||||
};
|
||||
const get$1 = (element, key) => {
|
||||
const v = element.dom.getAttribute(key);
|
||||
// undefined is the more appropriate value for JS, and this matches JQuery
|
||||
return v === null ? undefined : v;
|
||||
};
|
||||
const getOpt = (element, key) => Optional.from(get$1(element, key));
|
||||
const remove$1 = (element, key) => {
|
||||
element.dom.removeAttribute(key);
|
||||
};
|
||||
|
||||
// some elements, such as mathml, don't have style attributes
|
||||
// others, such as angular elements, have style attributes that aren't a CSSStyleDeclaration
|
||||
const isSupported = (dom) => dom.style !== undefined && isFunction(dom.style.getPropertyValue);
|
||||
|
||||
// Node.contains() is very, very, very good performance
|
||||
// http://jsperf.com/closest-vs-contains/5
|
||||
const inBody = (element) => {
|
||||
// Technically this is only required on IE, where contains() returns false for text nodes.
|
||||
// But it's cheap enough to run everywhere and Sugar doesn't have platform detection (yet).
|
||||
const dom = isText(element) ? element.dom.parentNode : element.dom;
|
||||
// use ownerDocument.body to ensure this works inside iframes.
|
||||
// Normally contains is bad because an element "contains" itself, but here we want that.
|
||||
if (dom === undefined || dom === null || dom.ownerDocument === null) {
|
||||
return false;
|
||||
}
|
||||
const doc = dom.ownerDocument;
|
||||
return getShadowRoot(SugarElement.fromDom(dom)).fold(() => doc.body.contains(dom), compose1(inBody, getShadowHost));
|
||||
};
|
||||
|
||||
const internalRemove = (dom, property) => {
|
||||
/*
|
||||
* IE9 and above - MDN doesn't have details, but here's a couple of random internet claims
|
||||
*
|
||||
* http://help.dottoro.com/ljopsjck.php
|
||||
* http://stackoverflow.com/a/7901886/7546
|
||||
*/
|
||||
if (isSupported(dom)) {
|
||||
dom.style.removeProperty(property);
|
||||
}
|
||||
};
|
||||
/*
|
||||
* NOTE: For certain properties, this returns the "used value" which is subtly different to the "computed value" (despite calling getComputedStyle).
|
||||
* Blame CSS 2.0.
|
||||
*
|
||||
* https://developer.mozilla.org/en-US/docs/Web/CSS/used_value
|
||||
*/
|
||||
const get = (element, property) => {
|
||||
const dom = element.dom;
|
||||
/*
|
||||
* IE9 and above per
|
||||
* https://developer.mozilla.org/en/docs/Web/API/window.getComputedStyle
|
||||
*
|
||||
* Not in numerosity, because it doesn't memoize and looking this up dynamically in performance critical code would be horrendous.
|
||||
*
|
||||
* JQuery has some magic here for IE popups, but we don't really need that.
|
||||
* It also uses element.ownerDocument.defaultView to handle iframes but that hasn't been required since FF 3.6.
|
||||
*/
|
||||
const styles = window.getComputedStyle(dom);
|
||||
const r = styles.getPropertyValue(property);
|
||||
// jquery-ism: If r is an empty string, check that the element is not in a document. If it isn't, return the raw value.
|
||||
// Turns out we do this a lot.
|
||||
return (r === '' && !inBody(element)) ? getUnsafeProperty(dom, property) : r;
|
||||
};
|
||||
// removed: support for dom().style[property] where prop is camel case instead of normal property name
|
||||
// empty string is what the browsers (IE11 and Chrome) return when the propertyValue doesn't exists.
|
||||
const getUnsafeProperty = (dom, property) => isSupported(dom) ? dom.style.getPropertyValue(property) : '';
|
||||
const remove = (element, property) => {
|
||||
const dom = element.dom;
|
||||
internalRemove(dom, property);
|
||||
if (is$1(getOpt(element, 'style').map(trim), '')) {
|
||||
// No more styles left, remove the style attribute as well
|
||||
remove$1(element, 'style');
|
||||
}
|
||||
};
|
||||
|
||||
const getDirection = (element) => get(element, 'direction') === 'rtl' ? 'rtl' : 'ltr';
|
||||
|
||||
const ancestor$1 = (scope, predicate, isRoot) => {
|
||||
let element = scope.dom;
|
||||
const stop = isFunction(isRoot) ? isRoot : never;
|
||||
while (element.parentNode) {
|
||||
element = element.parentNode;
|
||||
const el = SugarElement.fromDom(element);
|
||||
if (predicate(el)) {
|
||||
return Optional.some(el);
|
||||
}
|
||||
else if (stop(el)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return Optional.none();
|
||||
};
|
||||
|
||||
const ancestor = (scope, selector, isRoot) => ancestor$1(scope, (e) => is(e, selector), isRoot);
|
||||
|
||||
const children$1 = (scope, predicate) => filter(children$2(scope), predicate);
|
||||
|
||||
const children = (scope, selector) =>
|
||||
// It may surprise you to learn this is exactly what JQuery does
|
||||
// TODO: Avoid all the wrapping and unwrapping
|
||||
children$1(scope, (e) => is(e, selector));
|
||||
|
||||
// if the block is a list item, we need to get the parent of the list itself
|
||||
const getNormalizedBlock = (element, isListItem) => {
|
||||
const normalizedElement = isListItem ? ancestor(element, 'ol,ul') : Optional.some(element);
|
||||
return normalizedElement.getOr(element);
|
||||
};
|
||||
const isListItem = isTag('li');
|
||||
const setDirOnElements = (dom, blocks, dir) => {
|
||||
each(blocks, (block) => {
|
||||
const blockElement = SugarElement.fromDom(block);
|
||||
const isBlockElementListItem = isListItem(blockElement);
|
||||
const normalizedBlock = getNormalizedBlock(blockElement, isBlockElementListItem);
|
||||
const attrDir = get$1(normalizedBlock, 'dir');
|
||||
const styleDir = Optional.from(dom.getStyle(normalizedBlock.dom, 'direction', false))
|
||||
.filter(isNotEmpty);
|
||||
if (attrDir === dir || styleDir.exists((d) => d === dir)) {
|
||||
// If already set to given dir, toggle dir off
|
||||
remove$1(normalizedBlock, 'dir');
|
||||
remove(normalizedBlock, 'direction');
|
||||
}
|
||||
else if (attrDir !== dir) {
|
||||
set(normalizedBlock, 'dir', dir); // Set the dir attribute to the desired direction
|
||||
if (styleDir.exists((d) => d !== dir) || getDirection(normalizedBlock) !== dir) {
|
||||
// Also set the inline direction style if it was already set or if necessary to override inherited direction
|
||||
dom.setStyle(normalizedBlock.dom, 'direction', dir);
|
||||
}
|
||||
}
|
||||
// Remove dir attr and direction style from list children
|
||||
if (isBlockElementListItem) {
|
||||
const listItems = children(normalizedBlock, 'li[dir],li[style]');
|
||||
each(listItems, (listItem) => {
|
||||
remove$1(listItem, 'dir');
|
||||
dom.setStyle(listItem.dom, 'direction', null);
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
const setDir = (editor, dir) => {
|
||||
if (editor.selection.isEditable()) {
|
||||
setDirOnElements(editor.dom, editor.selection.getSelectedBlocks(), dir);
|
||||
editor.nodeChanged();
|
||||
}
|
||||
};
|
||||
|
||||
const register$1 = (editor) => {
|
||||
editor.addCommand('mceDirectionLTR', () => {
|
||||
setDir(editor, 'ltr');
|
||||
});
|
||||
editor.addCommand('mceDirectionRTL', () => {
|
||||
setDir(editor, 'rtl');
|
||||
});
|
||||
};
|
||||
|
||||
const register = (editor) => {
|
||||
const setupHandler = (dir) => (api) => {
|
||||
const nodeChangeHandler = (e) => {
|
||||
const block = editor.dom.isBlock(e.element) ? e.element : editor.dom.getParent(e.element, editor.dom.isBlock);
|
||||
if (block) {
|
||||
const direction = editor.dom.getStyle(block, 'direction') || editor.dom.getAttrib(block, 'dir');
|
||||
api.setActive(direction === dir);
|
||||
}
|
||||
};
|
||||
editor.on('NodeChange', nodeChangeHandler);
|
||||
return () => editor.off('NodeChange', nodeChangeHandler);
|
||||
};
|
||||
editor.ui.registry.addToggleButton('ltr', {
|
||||
tooltip: 'Left to right',
|
||||
icon: 'ltr',
|
||||
context: 'editable',
|
||||
onAction: () => editor.execCommand('mceDirectionLTR'),
|
||||
onSetup: setupHandler('ltr'),
|
||||
});
|
||||
editor.ui.registry.addToggleButton('rtl', {
|
||||
tooltip: 'Right to left',
|
||||
icon: 'rtl',
|
||||
context: 'editable',
|
||||
onAction: () => editor.execCommand('mceDirectionRTL'),
|
||||
onSetup: setupHandler('rtl')
|
||||
});
|
||||
};
|
||||
|
||||
var Plugin = () => {
|
||||
global.add('directionality', (editor) => {
|
||||
register$1(editor);
|
||||
register(editor);
|
||||
});
|
||||
};
|
||||
|
||||
Plugin();
|
||||
/** *****
|
||||
* DO NOT EXPORT ANYTHING
|
||||
*
|
||||
* IF YOU DO ROLLUP WILL LEAVE A GLOBAL ON THE PAGE
|
||||
*******/
|
||||
|
||||
})();
|
||||
+4
-1
@@ -1 +1,4 @@
|
||||
!function(){"use strict";var t=tinymce.util.Tools.resolve("tinymce.PluginManager");const e=t=>e=>typeof e===t,r=t=>"string"===(t=>{const e=typeof t;return null===t?"null":"object"===e&&Array.isArray(t)?"array":"object"===e&&(r=o=t,(n=String).prototype.isPrototypeOf(r)||o.constructor?.name===n.name)?"string":e;var r,o,n})(t),o=e("boolean"),n=t=>!(t=>null==t)(t),i=e("function"),s=e("number"),l=(t,e)=>t===e,a=()=>false;class c{tag;value;static singletonNone=new c(!1);constructor(t,e){this.tag=t,this.value=e}static some(t){return new c(!0,t)}static none(){return c.singletonNone}fold(t,e){return this.tag?e(this.value):t()}isSome(){return this.tag}isNone(){return!this.tag}map(t){return this.tag?c.some(t(this.value)):c.none()}bind(t){return this.tag?t(this.value):c.none()}exists(t){return this.tag&&t(this.value)}forall(t){return!this.tag||t(this.value)}filter(t){return!this.tag||t(this.value)?this:c.none()}getOr(t){return this.tag?this.value:t}or(t){return this.tag?this:t}getOrThunk(t){return this.tag?this.value:t()}orThunk(t){return this.tag?this:t()}getOrDie(t){if(this.tag)return this.value;throw new Error(t??"Called getOrDie on None")}static from(t){return n(t)?c.some(t):c.none()}getOrNull(){return this.tag?this.value:null}getOrUndefined(){return this.value}each(t){this.tag&&t(this.value)}toArray(){return this.tag?[this.value]:[]}toString(){return this.tag?`some(${this.value})`:"none()"}}Array.prototype.slice;const u=(t,e)=>{for(let r=0,o=t.length;r<o;r++)e(t[r],r)};i(Array.from)&&Array.from;const d=(m=/^\s+|\s+$/g,t=>t.replace(m,""));var m;const h=t=>t.length>0,g=t=>{if(null==t)throw new Error("Node cannot be null or undefined");return{dom:t}},f=g,y=(t,e)=>{const r=t.dom;if(1!==r.nodeType)return!1;{const t=r;if(void 0!==t.matches)return t.matches(e);if(void 0!==t.msMatchesSelector)return t.msMatchesSelector(e);if(void 0!==t.webkitMatchesSelector)return t.webkitMatchesSelector(e);if(void 0!==t.mozMatchesSelector)return t.mozMatchesSelector(e);throw new Error("Browser lacks native selectors")}},v=t=>e=>(t=>t.dom.nodeType)(e)===t,p=v(1),w=v(3),b=v(11),S=t=>f(t.dom.host),A=(t,e)=>{const r=t.dom.getAttribute(e);return null===r?void 0:r},N=(t,e)=>{t.dom.removeAttribute(e)},T=t=>void 0!==t.style&&i(t.style.getPropertyValue),C=t=>{const e=w(t)?t.dom.parentNode:t.dom;if(null==e||null===e.ownerDocument)return!1;const r=e.ownerDocument;return(t=>{const e=(t=>f(t.dom.getRootNode()))(t);return b(r=e)&&n(r.dom.host)?c.some(e):c.none();var r})(f(e)).fold(()=>r.body.contains(e),(o=C,i=S,t=>o(i(t))));var o,i},k=t=>"rtl"===((t,e)=>{const r=t.dom,o=window.getComputedStyle(r).getPropertyValue(e);return""!==o||C(t)?o:((t,e)=>T(t)?t.style.getPropertyValue(e):"")(r,e)})(t,"direction")?"rtl":"ltr",x=(t,e)=>(t=>((t,e)=>{const r=[];for(let o=0,n=t.length;o<n;o++){const n=t[o];e(n,o)&&r.push(n)}return r})(((t,e)=>{const r=t.length,o=new Array(r);for(let n=0;n<r;n++){const r=t[n];o[n]=e(r,n)}return o})(t.dom.childNodes,f),t=>y(t,e)))(t),D=t=>p(t)&&"li"===t.dom.nodeName.toLowerCase();const O=(t,e,n)=>{u(e,e=>{const m=f(e),g=D(m),v=((t,e)=>{return(e?(r=t,o="ol,ul",((t,e,r)=>{let o=t.dom;const n=i(r)?r:a;for(;o.parentNode;){o=o.parentNode;const t=f(o);if(e(t))return c.some(t);if(n(t))break}return c.none()})(r,t=>y(t,o),n)):c.some(t)).getOr(t);var r,o,n})(m,g),p=A(v,"dir"),w=c.from(t.getStyle(v.dom,"direction",!1)).filter(h);if(p===n||w.exists(t=>t===n)?(N(v,"dir"),S="direction",((t,e)=>{T(t)&&t.style.removeProperty(e)})((b=v).dom,S),((t,e,r=l)=>t.exists(t=>r(t,e)))((t=>c.from(A(t,"style")))(b).map(d),"")&&N(b,"style")):p!==n&&(((t,e,n)=>{((t,e,n)=>{if(!(r(n)||o(n)||s(n)))throw console.error("Invalid call to Attribute.set. Key ",e,":: Value ",n,":: Element ",t),new Error("Attribute value was not simple");t.setAttribute(e,n+"")})(t.dom,e,n)})(v,"dir",n),(w.exists(t=>t!==n)||k(v)!==n)&&t.setStyle(v.dom,"direction",n)),g){const e=x(v,"li[dir],li[style]");u(e,e=>{N(e,"dir"),t.setStyle(e.dom,"direction",null)})}var b,S})},M=(t,e)=>{t.selection.isEditable()&&(O(t.dom,t.selection.getSelectedBlocks(),e),t.nodeChanged())};t.add("directionality",t=>{(t=>{t.addCommand("mceDirectionLTR",()=>{M(t,"ltr")}),t.addCommand("mceDirectionRTL",()=>{M(t,"rtl")})})(t),(t=>{const e=e=>r=>{const o=o=>{const n=t.dom.isBlock(o.element)?o.element:t.dom.getParent(o.element,t.dom.isBlock);if(n){const o=t.dom.getStyle(n,"direction")||t.dom.getAttrib(n,"dir");r.setActive(o===e)}};return t.on("NodeChange",o),()=>t.off("NodeChange",o)};t.ui.registry.addToggleButton("ltr",{tooltip:"Left to right",icon:"ltr",context:"editable",onAction:()=>t.execCommand("mceDirectionLTR"),onSetup:e("ltr")}),t.ui.registry.addToggleButton("rtl",{tooltip:"Right to left",icon:"rtl",context:"editable",onAction:()=>t.execCommand("mceDirectionRTL"),onSetup:e("rtl")})})(t)})}();
|
||||
/**
|
||||
* TinyMCE version 7.3.0 (2024-08-07)
|
||||
*/
|
||||
!function(){"use strict";var t=tinymce.util.Tools.resolve("tinymce.PluginManager");const e=t=>e=>typeof e===t,o=t=>"string"===(t=>{const e=typeof t;return null===t?"null":"object"===e&&Array.isArray(t)?"array":"object"===e&&(o=r=t,(n=String).prototype.isPrototypeOf(o)||(null===(i=r.constructor)||void 0===i?void 0:i.name)===n.name)?"string":e;var o,r,n,i})(t),r=e("boolean"),n=t=>!(t=>null==t)(t),i=e("function"),s=e("number"),l=(!1,()=>false);class a{constructor(t,e){this.tag=t,this.value=e}static some(t){return new a(!0,t)}static none(){return a.singletonNone}fold(t,e){return this.tag?e(this.value):t()}isSome(){return this.tag}isNone(){return!this.tag}map(t){return this.tag?a.some(t(this.value)):a.none()}bind(t){return this.tag?t(this.value):a.none()}exists(t){return this.tag&&t(this.value)}forall(t){return!this.tag||t(this.value)}filter(t){return!this.tag||t(this.value)?this:a.none()}getOr(t){return this.tag?this.value:t}or(t){return this.tag?this:t}getOrThunk(t){return this.tag?this.value:t()}orThunk(t){return this.tag?this:t()}getOrDie(t){if(this.tag)return this.value;throw new Error(null!=t?t:"Called getOrDie on None")}static from(t){return n(t)?a.some(t):a.none()}getOrNull(){return this.tag?this.value:null}getOrUndefined(){return this.value}each(t){this.tag&&t(this.value)}toArray(){return this.tag?[this.value]:[]}toString(){return this.tag?`some(${this.value})`:"none()"}}a.singletonNone=new a(!1);const u=(t,e)=>{for(let o=0,r=t.length;o<r;o++)e(t[o],o)},c=t=>{if(null==t)throw new Error("Node cannot be null or undefined");return{dom:t}},d=c,h=(t,e)=>{const o=t.dom;if(1!==o.nodeType)return!1;{const t=o;if(void 0!==t.matches)return t.matches(e);if(void 0!==t.msMatchesSelector)return t.msMatchesSelector(e);if(void 0!==t.webkitMatchesSelector)return t.webkitMatchesSelector(e);if(void 0!==t.mozMatchesSelector)return t.mozMatchesSelector(e);throw new Error("Browser lacks native selectors")}};"undefined"!=typeof window?window:Function("return this;")();const m=t=>e=>(t=>t.dom.nodeType)(e)===t,g=m(1),f=m(3),v=m(11),y=(t,e)=>{t.dom.removeAttribute(e)},p=t=>d(t.dom.host),w=t=>{const e=f(t)?t.dom.parentNode:t.dom;if(null==e||null===e.ownerDocument)return!1;const o=e.ownerDocument;return(t=>{const e=(t=>d(t.dom.getRootNode()))(t);return v(o=e)&&n(o.dom.host)?a.some(e):a.none();var o})(d(e)).fold((()=>o.body.contains(e)),(r=w,i=p,t=>r(i(t))));var r,i},b=t=>"rtl"===((t,e)=>{const o=t.dom,r=window.getComputedStyle(o).getPropertyValue(e);return""!==r||w(t)?r:((t,e)=>(t=>void 0!==t.style&&i(t.style.getPropertyValue))(t)?t.style.getPropertyValue(e):"")(o,e)})(t,"direction")?"rtl":"ltr",S=(t,e)=>((t,o)=>((t,e)=>{const o=[];for(let r=0,n=t.length;r<n;r++){const n=t[r];e(n,r)&&o.push(n)}return o})(((t,e)=>{const o=t.length,r=new Array(o);for(let n=0;n<o;n++){const o=t[n];r[n]=e(o,n)}return r})(t.dom.childNodes,d),(t=>h(t,e))))(t),N=("li",t=>g(t)&&"li"===t.dom.nodeName.toLowerCase());const A=(t,e,n)=>{u(e,(e=>{const c=d(e),m=N(c),f=((t,e)=>{return(e?(o=t,r="ol,ul",((t,e,o)=>{let n=t.dom;const s=i(o)?o:l;for(;n.parentNode;){n=n.parentNode;const t=d(n);if(h(t,r))return a.some(t);if(s(t))break}return a.none()})(o,0,n)):a.some(t)).getOr(t);var o,r,n})(c,m);var v;(v=f,(t=>a.from(t.dom.parentNode).map(d))(v).filter(g)).each((e=>{if(t.setStyle(f.dom,"direction",null),b(e)===n?y(f,"dir"):((t,e,n)=>{((t,e,n)=>{if(!(o(n)||r(n)||s(n)))throw console.error("Invalid call to Attribute.set. Key ",e,":: Value ",n,":: Element ",t),new Error("Attribute value was not simple");t.setAttribute(e,n+"")})(t.dom,e,n)})(f,"dir",n),b(f)!==n&&t.setStyle(f.dom,"direction",n),m){const e=S(f,"li[dir],li[style]");u(e,(e=>{y(e,"dir"),t.setStyle(e.dom,"direction",null)}))}}))}))},T=(t,e)=>{t.selection.isEditable()&&(A(t.dom,t.selection.getSelectedBlocks(),e),t.nodeChanged())},C=(t,e)=>o=>{const r=r=>{const n=d(r.element);o.setActive(b(n)===e),o.setEnabled(t.selection.isEditable())};return t.on("NodeChange",r),o.setEnabled(t.selection.isEditable()),()=>t.off("NodeChange",r)};t.add("directionality",(t=>{(t=>{t.addCommand("mceDirectionLTR",(()=>{T(t,"ltr")})),t.addCommand("mceDirectionRTL",(()=>{T(t,"rtl")}))})(t),(t=>{t.ui.registry.addToggleButton("ltr",{tooltip:"Left to right",icon:"ltr",onAction:()=>t.execCommand("mceDirectionLTR"),onSetup:C(t,"ltr")}),t.ui.registry.addToggleButton("rtl",{tooltip:"Right to left",icon:"rtl",onAction:()=>t.execCommand("mceDirectionRTL"),onSetup:C(t,"rtl")})})(t)}))}();
|
||||
@@ -1,7 +0,0 @@
|
||||
// Exports the "emoticons" plugin for usage with module loaders
|
||||
// Usage:
|
||||
// CommonJS:
|
||||
// require('tinymce/plugins/emoticons')
|
||||
// ES2015:
|
||||
// import 'tinymce/plugins/emoticons'
|
||||
require('./plugin.js');
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,809 +0,0 @@
|
||||
/**
|
||||
* TinyMCE version 8.5.1 (2026-05-19)
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var global$1 = tinymce.util.Tools.resolve('tinymce.PluginManager');
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-wrapper-object-types */
|
||||
const isSimpleType = (type) => (value) => typeof value === type;
|
||||
const eq = (t) => (a) => t === a;
|
||||
const isNull = eq(null);
|
||||
const isUndefined = eq(undefined);
|
||||
const isNullable = (a) => a === null || a === undefined;
|
||||
const isNonNullable = (a) => !isNullable(a);
|
||||
const isFunction = isSimpleType('function');
|
||||
|
||||
const noop = () => { };
|
||||
const constant = (value) => {
|
||||
return () => {
|
||||
return value;
|
||||
};
|
||||
};
|
||||
const never = constant(false);
|
||||
|
||||
/**
|
||||
* The `Optional` type represents a value (of any type) that potentially does
|
||||
* not exist. Any `Optional<T>` can either be a `Some<T>` (in which case the
|
||||
* value does exist) or a `None` (in which case the value does not exist). This
|
||||
* module defines a whole lot of FP-inspired utility functions for dealing with
|
||||
* `Optional` objects.
|
||||
*
|
||||
* Comparison with null or undefined:
|
||||
* - We don't get fancy null coalescing operators with `Optional`
|
||||
* - We do get fancy helper functions with `Optional`
|
||||
* - `Optional` support nesting, and allow for the type to still be nullable (or
|
||||
* another `Optional`)
|
||||
* - There is no option to turn off strict-optional-checks like there is for
|
||||
* strict-null-checks
|
||||
*/
|
||||
class Optional {
|
||||
tag;
|
||||
value;
|
||||
// Sneaky optimisation: every instance of Optional.none is identical, so just
|
||||
// reuse the same object
|
||||
static singletonNone = new Optional(false);
|
||||
// The internal representation has a `tag` and a `value`, but both are
|
||||
// private: able to be console.logged, but not able to be accessed by code
|
||||
constructor(tag, value) {
|
||||
this.tag = tag;
|
||||
this.value = value;
|
||||
}
|
||||
// --- Identities ---
|
||||
/**
|
||||
* Creates a new `Optional<T>` that **does** contain a value.
|
||||
*/
|
||||
static some(value) {
|
||||
return new Optional(true, value);
|
||||
}
|
||||
/**
|
||||
* Create a new `Optional<T>` that **does not** contain a value. `T` can be
|
||||
* any type because we don't actually have a `T`.
|
||||
*/
|
||||
static none() {
|
||||
return Optional.singletonNone;
|
||||
}
|
||||
/**
|
||||
* Perform a transform on an `Optional` type. Regardless of whether this
|
||||
* `Optional` contains a value or not, `fold` will return a value of type `U`.
|
||||
* If this `Optional` does not contain a value, the `U` will be created by
|
||||
* calling `onNone`. If this `Optional` does contain a value, the `U` will be
|
||||
* created by calling `onSome`.
|
||||
*
|
||||
* For the FP enthusiasts in the room, this function:
|
||||
* 1. Could be used to implement all of the functions below
|
||||
* 2. Forms a catamorphism
|
||||
*/
|
||||
fold(onNone, onSome) {
|
||||
if (this.tag) {
|
||||
return onSome(this.value);
|
||||
}
|
||||
else {
|
||||
return onNone();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Determine if this `Optional` object contains a value.
|
||||
*/
|
||||
isSome() {
|
||||
return this.tag;
|
||||
}
|
||||
/**
|
||||
* Determine if this `Optional` object **does not** contain a value.
|
||||
*/
|
||||
isNone() {
|
||||
return !this.tag;
|
||||
}
|
||||
// --- Functor (name stolen from Haskell / maths) ---
|
||||
/**
|
||||
* Perform a transform on an `Optional` object, **if** there is a value. If
|
||||
* you provide a function to turn a T into a U, this is the function you use
|
||||
* to turn an `Optional<T>` into an `Optional<U>`. If this **does** contain
|
||||
* a value then the output will also contain a value (that value being the
|
||||
* output of `mapper(this.value)`), and if this **does not** contain a value
|
||||
* then neither will the output.
|
||||
*/
|
||||
map(mapper) {
|
||||
if (this.tag) {
|
||||
return Optional.some(mapper(this.value));
|
||||
}
|
||||
else {
|
||||
return Optional.none();
|
||||
}
|
||||
}
|
||||
// --- Monad (name stolen from Haskell / maths) ---
|
||||
/**
|
||||
* Perform a transform on an `Optional` object, **if** there is a value.
|
||||
* Unlike `map`, here the transform itself also returns an `Optional`.
|
||||
*/
|
||||
bind(binder) {
|
||||
if (this.tag) {
|
||||
return binder(this.value);
|
||||
}
|
||||
else {
|
||||
return Optional.none();
|
||||
}
|
||||
}
|
||||
// --- Traversable (name stolen from Haskell / maths) ---
|
||||
/**
|
||||
* For a given predicate, this function finds out if there **exists** a value
|
||||
* inside this `Optional` object that meets the predicate. In practice, this
|
||||
* means that for `Optional`s that do not contain a value it returns false (as
|
||||
* no predicate-meeting value exists).
|
||||
*/
|
||||
exists(predicate) {
|
||||
return this.tag && predicate(this.value);
|
||||
}
|
||||
/**
|
||||
* For a given predicate, this function finds out if **all** the values inside
|
||||
* this `Optional` object meet the predicate. In practice, this means that
|
||||
* for `Optional`s that do not contain a value it returns true (as all 0
|
||||
* objects do meet the predicate).
|
||||
*/
|
||||
forall(predicate) {
|
||||
return !this.tag || predicate(this.value);
|
||||
}
|
||||
filter(predicate) {
|
||||
if (!this.tag || predicate(this.value)) {
|
||||
return this;
|
||||
}
|
||||
else {
|
||||
return Optional.none();
|
||||
}
|
||||
}
|
||||
// --- Getters ---
|
||||
/**
|
||||
* Get the value out of the inside of the `Optional` object, using a default
|
||||
* `replacement` value if the provided `Optional` object does not contain a
|
||||
* value.
|
||||
*/
|
||||
getOr(replacement) {
|
||||
return this.tag ? this.value : replacement;
|
||||
}
|
||||
/**
|
||||
* Get the value out of the inside of the `Optional` object, using a default
|
||||
* `replacement` value if the provided `Optional` object does not contain a
|
||||
* value. Unlike `getOr`, in this method the `replacement` object is also
|
||||
* `Optional` - meaning that this method will always return an `Optional`.
|
||||
*/
|
||||
or(replacement) {
|
||||
return this.tag ? this : replacement;
|
||||
}
|
||||
/**
|
||||
* Get the value out of the inside of the `Optional` object, using a default
|
||||
* `replacement` value if the provided `Optional` object does not contain a
|
||||
* value. Unlike `getOr`, in this method the `replacement` value is
|
||||
* "thunked" - that is to say that you don't pass a value to `getOrThunk`, you
|
||||
* pass a function which (if called) will **return** the `value` you want to
|
||||
* use.
|
||||
*/
|
||||
getOrThunk(thunk) {
|
||||
return this.tag ? this.value : thunk();
|
||||
}
|
||||
/**
|
||||
* Get the value out of the inside of the `Optional` object, using a default
|
||||
* `replacement` value if the provided Optional object does not contain a
|
||||
* value.
|
||||
*
|
||||
* Unlike `or`, in this method the `replacement` value is "thunked" - that is
|
||||
* to say that you don't pass a value to `orThunk`, you pass a function which
|
||||
* (if called) will **return** the `value` you want to use.
|
||||
*
|
||||
* Unlike `getOrThunk`, in this method the `replacement` value is also
|
||||
* `Optional`, meaning that this method will always return an `Optional`.
|
||||
*/
|
||||
orThunk(thunk) {
|
||||
return this.tag ? this : thunk();
|
||||
}
|
||||
/**
|
||||
* Get the value out of the inside of the `Optional` object, throwing an
|
||||
* exception if the provided `Optional` object does not contain a value.
|
||||
*
|
||||
* WARNING:
|
||||
* You should only be using this function if you know that the `Optional`
|
||||
* object **is not** empty (otherwise you're throwing exceptions in production
|
||||
* code, which is bad).
|
||||
*
|
||||
* In tests this is more acceptable.
|
||||
*
|
||||
* Prefer other methods to this, such as `.each`.
|
||||
*/
|
||||
getOrDie(message) {
|
||||
if (!this.tag) {
|
||||
throw new Error(message ?? 'Called getOrDie on None');
|
||||
}
|
||||
else {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
// --- Interop with null and undefined ---
|
||||
/**
|
||||
* Creates an `Optional` value from a nullable (or undefined-able) input.
|
||||
* Null, or undefined, is converted to `None`, and anything else is converted
|
||||
* to `Some`.
|
||||
*/
|
||||
static from(value) {
|
||||
return isNonNullable(value) ? Optional.some(value) : Optional.none();
|
||||
}
|
||||
/**
|
||||
* Converts an `Optional` to a nullable type, by getting the value if it
|
||||
* exists, or returning `null` if it does not.
|
||||
*/
|
||||
getOrNull() {
|
||||
return this.tag ? this.value : null;
|
||||
}
|
||||
/**
|
||||
* Converts an `Optional` to an undefined-able type, by getting the value if
|
||||
* it exists, or returning `undefined` if it does not.
|
||||
*/
|
||||
getOrUndefined() {
|
||||
return this.value;
|
||||
}
|
||||
// --- Utilities ---
|
||||
/**
|
||||
* If the `Optional` contains a value, perform an action on that value.
|
||||
* Unlike the rest of the methods on this type, `.each` has side-effects. If
|
||||
* you want to transform an `Optional<T>` **into** something, then this is not
|
||||
* the method for you. If you want to use an `Optional<T>` to **do**
|
||||
* something, then this is the method for you - provided you're okay with not
|
||||
* doing anything in the case where the `Optional` doesn't have a value inside
|
||||
* it. If you're not sure whether your use-case fits into transforming
|
||||
* **into** something or **doing** something, check whether it has a return
|
||||
* value. If it does, you should be performing a transform.
|
||||
*/
|
||||
each(worker) {
|
||||
if (this.tag) {
|
||||
worker(this.value);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Turn the `Optional` object into an array that contains all of the values
|
||||
* stored inside the `Optional`. In practice, this means the output will have
|
||||
* either 0 or 1 elements.
|
||||
*/
|
||||
toArray() {
|
||||
return this.tag ? [this.value] : [];
|
||||
}
|
||||
/**
|
||||
* Turn the `Optional` object into a string for debugging or printing. Not
|
||||
* recommended for production code, but good for debugging. Also note that
|
||||
* these days an `Optional` object can be logged to the console directly, and
|
||||
* its inner value (if it exists) will be visible.
|
||||
*/
|
||||
toString() {
|
||||
return this.tag ? `some(${this.value})` : 'none()';
|
||||
}
|
||||
}
|
||||
|
||||
const nativeSlice = Array.prototype.slice;
|
||||
const exists = (xs, pred) => {
|
||||
for (let i = 0, len = xs.length; i < len; i++) {
|
||||
const x = xs[i];
|
||||
if (pred(x, i)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const map$1 = (xs, f) => {
|
||||
// pre-allocating array size when it's guaranteed to be known
|
||||
// http://jsperf.com/push-allocated-vs-dynamic/22
|
||||
const len = xs.length;
|
||||
const r = new Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
const x = xs[i];
|
||||
r[i] = f(x, i);
|
||||
}
|
||||
return r;
|
||||
};
|
||||
// Unwound implementing other functions in terms of each.
|
||||
// The code size is roughly the same, and it should allow for better optimisation.
|
||||
// const each = function<T, U>(xs: T[], f: (x: T, i?: number, xs?: T[]) => void): void {
|
||||
const each$1 = (xs, f) => {
|
||||
for (let i = 0, len = xs.length; i < len; i++) {
|
||||
const x = xs[i];
|
||||
f(x, i);
|
||||
}
|
||||
};
|
||||
isFunction(Array.from) ? Array.from : (x) => nativeSlice.call(x);
|
||||
|
||||
// There are many variations of Object iteration that are faster than the 'for-in' style:
|
||||
// http://jsperf.com/object-keys-iteration/107
|
||||
//
|
||||
// Use the native keys if it is available (IE9+), otherwise fall back to manually filtering
|
||||
const keys = Object.keys;
|
||||
const hasOwnProperty = Object.hasOwnProperty;
|
||||
const each = (obj, f) => {
|
||||
const props = keys(obj);
|
||||
for (let k = 0, len = props.length; k < len; k++) {
|
||||
const i = props[k];
|
||||
const x = obj[i];
|
||||
f(x, i);
|
||||
}
|
||||
};
|
||||
const map = (obj, f) => {
|
||||
return tupleMap(obj, (x, i) => ({
|
||||
k: i,
|
||||
v: f(x, i)
|
||||
}));
|
||||
};
|
||||
const tupleMap = (obj, f) => {
|
||||
const r = {};
|
||||
each(obj, (x, i) => {
|
||||
const tuple = f(x, i);
|
||||
r[tuple.k] = tuple.v;
|
||||
});
|
||||
return r;
|
||||
};
|
||||
const has = (obj, key) => hasOwnProperty.call(obj, key);
|
||||
|
||||
const Cell = (initial) => {
|
||||
let value = initial;
|
||||
const get = () => {
|
||||
return value;
|
||||
};
|
||||
const set = (v) => {
|
||||
value = v;
|
||||
};
|
||||
return {
|
||||
get,
|
||||
set
|
||||
};
|
||||
};
|
||||
|
||||
const shallow = (old, nu) => {
|
||||
return nu;
|
||||
};
|
||||
const baseMerge = (merger) => {
|
||||
return (...objects) => {
|
||||
if (objects.length === 0) {
|
||||
throw new Error(`Can't merge zero objects`);
|
||||
}
|
||||
const ret = {};
|
||||
for (let j = 0; j < objects.length; j++) {
|
||||
const curObject = objects[j];
|
||||
for (const key in curObject) {
|
||||
if (has(curObject, key)) {
|
||||
ret[key] = merger(ret[key], curObject[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ret;
|
||||
};
|
||||
};
|
||||
const merge = baseMerge(shallow);
|
||||
|
||||
const singleton = (doRevoke) => {
|
||||
const subject = Cell(Optional.none());
|
||||
const revoke = () => subject.get().each(doRevoke);
|
||||
const clear = () => {
|
||||
revoke();
|
||||
subject.set(Optional.none());
|
||||
};
|
||||
const isSet = () => subject.get().isSome();
|
||||
const get = () => subject.get();
|
||||
const set = (s) => {
|
||||
revoke();
|
||||
subject.set(Optional.some(s));
|
||||
};
|
||||
return {
|
||||
clear,
|
||||
isSet,
|
||||
get,
|
||||
set
|
||||
};
|
||||
};
|
||||
const value = () => {
|
||||
const subject = singleton(noop);
|
||||
const on = (f) => subject.get().each(f);
|
||||
return {
|
||||
...subject,
|
||||
on
|
||||
};
|
||||
};
|
||||
|
||||
const checkRange = (str, substr, start) => substr === '' || str.length >= substr.length && str.substr(start, start + substr.length) === substr;
|
||||
const contains = (str, substr, start = 0, end) => {
|
||||
const idx = str.indexOf(substr, start);
|
||||
if (idx !== -1) {
|
||||
return isUndefined(end) ? true : idx + substr.length <= end;
|
||||
}
|
||||
else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
/** Does 'str' start with 'prefix'?
|
||||
* Note: all strings start with the empty string.
|
||||
* More formally, for all strings x, startsWith(x, "").
|
||||
* This is so that for all strings x and y, startsWith(y + x, y)
|
||||
*/
|
||||
const startsWith = (str, prefix) => {
|
||||
return checkRange(str, prefix, 0);
|
||||
};
|
||||
|
||||
// Run a function fn after rate ms. If another invocation occurs
|
||||
// during the time it is waiting, reschedule the function again
|
||||
// with the new arguments.
|
||||
const last = (fn, rate) => {
|
||||
let timer = null;
|
||||
const cancel = () => {
|
||||
if (!isNull(timer)) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
const throttle = (...args) => {
|
||||
cancel();
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
fn.apply(null, args);
|
||||
}, rate);
|
||||
};
|
||||
return {
|
||||
cancel,
|
||||
throttle
|
||||
};
|
||||
};
|
||||
|
||||
const insertEmoticon = (editor, ch) => {
|
||||
editor.insertContent(ch);
|
||||
};
|
||||
|
||||
var global = tinymce.util.Tools.resolve('tinymce.Resource');
|
||||
|
||||
const DEFAULT_ID = 'tinymce.plugins.emoticons';
|
||||
const option = (name) => (editor) => editor.options.get(name);
|
||||
const register$2 = (editor, pluginUrl) => {
|
||||
const registerOption = editor.options.register;
|
||||
registerOption('emoticons_database', {
|
||||
processor: 'string',
|
||||
default: 'emojis'
|
||||
});
|
||||
registerOption('emoticons_database_url', {
|
||||
processor: 'string',
|
||||
default: `${pluginUrl}/js/${getEmojiDatabase(editor)}${editor.suffix}.js`
|
||||
});
|
||||
registerOption('emoticons_database_id', {
|
||||
processor: 'string',
|
||||
default: DEFAULT_ID
|
||||
});
|
||||
registerOption('emoticons_append', {
|
||||
processor: 'object',
|
||||
default: {}
|
||||
});
|
||||
registerOption('emoticons_images_url', {
|
||||
processor: 'string',
|
||||
default: 'https://cdnjs.cloudflare.com/ajax/libs/twemoji/15.1.0/72x72/'
|
||||
});
|
||||
};
|
||||
const getEmojiDatabase = option('emoticons_database');
|
||||
const getEmojiDatabaseUrl = option('emoticons_database_url');
|
||||
const getEmojiDatabaseId = option('emoticons_database_id');
|
||||
const getAppendedEmoji = option('emoticons_append');
|
||||
const getEmojiImageUrl = option('emoticons_images_url');
|
||||
|
||||
const ALL_CATEGORY = 'All';
|
||||
const categoryNameMap = {
|
||||
symbols: 'Symbols',
|
||||
people: 'People',
|
||||
animals_and_nature: 'Animals and Nature',
|
||||
food_and_drink: 'Food and Drink',
|
||||
activity: 'Activity',
|
||||
travel_and_places: 'Travel and Places',
|
||||
objects: 'Objects',
|
||||
flags: 'Flags',
|
||||
user: 'User Defined'
|
||||
};
|
||||
const translateCategory = (categories, name) => has(categories, name) ? categories[name] : name;
|
||||
const getUserDefinedEmoji = (editor) => {
|
||||
const userDefinedEmoticons = getAppendedEmoji(editor);
|
||||
return map(userDefinedEmoticons, (value) =>
|
||||
// Set some sane defaults for the custom emoji entry
|
||||
({ keywords: [], category: 'user', ...value }));
|
||||
};
|
||||
// TODO: Consider how to share this loading across different editors
|
||||
const initDatabase = (editor, databaseUrl, databaseId) => {
|
||||
const categories = value();
|
||||
const all = value();
|
||||
const emojiImagesUrl = getEmojiImageUrl(editor);
|
||||
const getEmoji = (lib) => {
|
||||
// Note: This is a little hacky, but the database doesn't provide a way for us to tell what sort of database is being used
|
||||
if (startsWith(lib.char, '<img')) {
|
||||
return lib.char.replace(/src="([^"]+)"/, (match, url) => `src="${emojiImagesUrl}${url}"`);
|
||||
}
|
||||
else {
|
||||
return lib.char;
|
||||
}
|
||||
};
|
||||
const processEmojis = (emojis) => {
|
||||
const cats = {};
|
||||
const everything = [];
|
||||
each(emojis, (lib, title) => {
|
||||
const entry = {
|
||||
// Omitting fitzpatrick_scale
|
||||
title,
|
||||
keywords: lib.keywords,
|
||||
char: getEmoji(lib),
|
||||
category: translateCategory(categoryNameMap, lib.category)
|
||||
};
|
||||
const current = cats[entry.category] !== undefined ? cats[entry.category] : [];
|
||||
cats[entry.category] = current.concat([entry]);
|
||||
everything.push(entry);
|
||||
});
|
||||
categories.set(cats);
|
||||
all.set(everything);
|
||||
};
|
||||
editor.on('init', () => {
|
||||
global.load(databaseId, databaseUrl).then((emojis) => {
|
||||
const userEmojis = getUserDefinedEmoji(editor);
|
||||
processEmojis(merge(emojis, userEmojis));
|
||||
}, (err) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Failed to load emojis: ${err}`);
|
||||
categories.set({});
|
||||
all.set([]);
|
||||
});
|
||||
});
|
||||
const listCategory = (category) => {
|
||||
if (category === ALL_CATEGORY) {
|
||||
return listAll();
|
||||
}
|
||||
return categories.get().bind((cats) => Optional.from(cats[category])).getOr([]);
|
||||
};
|
||||
const listAll = () => all.get().getOr([]);
|
||||
const listCategories = () =>
|
||||
// TODO: Category key order should be adjusted to match the standard
|
||||
[ALL_CATEGORY].concat(keys(categories.get().getOr({})));
|
||||
const waitForLoad = () => {
|
||||
if (hasLoaded()) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
else {
|
||||
return new Promise((resolve, reject) => {
|
||||
let numRetries = 15;
|
||||
const interval = setInterval(() => {
|
||||
if (hasLoaded()) {
|
||||
clearInterval(interval);
|
||||
resolve(true);
|
||||
}
|
||||
else {
|
||||
numRetries--;
|
||||
if (numRetries < 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Could not load emojis from url: ' + databaseUrl);
|
||||
clearInterval(interval);
|
||||
reject(false);
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
};
|
||||
const hasLoaded = () => categories.isSet() && all.isSet();
|
||||
return {
|
||||
listCategories,
|
||||
hasLoaded,
|
||||
waitForLoad,
|
||||
listAll,
|
||||
listCategory
|
||||
};
|
||||
};
|
||||
|
||||
const emojiMatches = (emoji, lowerCasePattern) => contains(emoji.title.toLowerCase(), lowerCasePattern) ||
|
||||
exists(emoji.keywords, (k) => contains(k.toLowerCase(), lowerCasePattern));
|
||||
const emojisFrom = (list, pattern, maxResults) => {
|
||||
const matches = [];
|
||||
const lowerCasePattern = pattern.toLowerCase();
|
||||
const reachedLimit = maxResults.fold(() => never, (max) => (size) => size >= max);
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
// TODO: more intelligent search by showing title matches at the top, keyword matches after that (use two arrays and concat at the end)
|
||||
if (pattern.length === 0 || emojiMatches(list[i], lowerCasePattern)) {
|
||||
matches.push({
|
||||
value: list[i].char,
|
||||
text: list[i].title,
|
||||
icon: list[i].char
|
||||
});
|
||||
if (reachedLimit(matches.length)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
};
|
||||
|
||||
const patternName = 'pattern';
|
||||
const open = (editor, database) => {
|
||||
const initialState = {
|
||||
pattern: '',
|
||||
results: emojisFrom(database.listAll(), '', Optional.some(300))
|
||||
};
|
||||
const currentTab = Cell(ALL_CATEGORY);
|
||||
const scan = (dialogApi) => {
|
||||
const dialogData = dialogApi.getData();
|
||||
const category = currentTab.get();
|
||||
const candidates = database.listCategory(category);
|
||||
const results = emojisFrom(candidates, dialogData[patternName], category === ALL_CATEGORY ? Optional.some(300) : Optional.none());
|
||||
dialogApi.setData({
|
||||
results
|
||||
});
|
||||
};
|
||||
const updateFilter = last((dialogApi) => {
|
||||
scan(dialogApi);
|
||||
}, 200);
|
||||
const searchField = {
|
||||
label: 'Search',
|
||||
type: 'input',
|
||||
name: patternName
|
||||
};
|
||||
const resultsField = {
|
||||
type: 'collection',
|
||||
name: 'results'
|
||||
// TODO TINY-3229 implement collection columns properly
|
||||
// columns: 'auto'
|
||||
};
|
||||
const getInitialState = () => {
|
||||
const body = {
|
||||
type: 'tabpanel',
|
||||
// All tabs have the same fields.
|
||||
tabs: map$1(database.listCategories(), (cat) => ({
|
||||
title: cat,
|
||||
name: cat,
|
||||
items: [searchField, resultsField]
|
||||
}))
|
||||
};
|
||||
return {
|
||||
title: 'Emojis',
|
||||
size: 'normal',
|
||||
body,
|
||||
initialData: initialState,
|
||||
onTabChange: (dialogApi, details) => {
|
||||
currentTab.set(details.newTabName);
|
||||
updateFilter.throttle(dialogApi);
|
||||
},
|
||||
onChange: updateFilter.throttle,
|
||||
onAction: (dialogApi, actionData) => {
|
||||
if (actionData.name === 'results') {
|
||||
insertEmoticon(editor, actionData.value);
|
||||
dialogApi.close();
|
||||
}
|
||||
},
|
||||
buttons: [
|
||||
{
|
||||
type: 'cancel',
|
||||
text: 'Close',
|
||||
primary: true
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
||||
const dialogApi = editor.windowManager.open(getInitialState());
|
||||
dialogApi.focus(patternName);
|
||||
if (!database.hasLoaded()) {
|
||||
dialogApi.block('Loading emojis...');
|
||||
database.waitForLoad().then(() => {
|
||||
dialogApi.redial(getInitialState());
|
||||
updateFilter.throttle(dialogApi);
|
||||
dialogApi.focus(patternName);
|
||||
dialogApi.unblock();
|
||||
}).catch((_err) => {
|
||||
dialogApi.redial({
|
||||
title: 'Emojis',
|
||||
body: {
|
||||
type: 'panel',
|
||||
items: [
|
||||
{
|
||||
type: 'alertbanner',
|
||||
level: 'error',
|
||||
icon: 'warning',
|
||||
text: 'Could not load emojis'
|
||||
}
|
||||
]
|
||||
},
|
||||
buttons: [
|
||||
{
|
||||
type: 'cancel',
|
||||
text: 'Close',
|
||||
primary: true
|
||||
}
|
||||
],
|
||||
initialData: {
|
||||
pattern: '',
|
||||
results: []
|
||||
}
|
||||
});
|
||||
dialogApi.focus(patternName);
|
||||
dialogApi.unblock();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const register$1 = (editor, database) => {
|
||||
editor.addCommand('mceEmoticons', () => open(editor, database));
|
||||
};
|
||||
|
||||
const setup = (editor) => {
|
||||
editor.on('PreInit', () => {
|
||||
editor.parser.addAttributeFilter('data-emoticon', (nodes) => {
|
||||
each$1(nodes, (node) => {
|
||||
node.attr('data-mce-resize', 'false');
|
||||
node.attr('data-mce-placeholder', '1');
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const init = (editor, database) => {
|
||||
editor.ui.registry.addAutocompleter('emoticons', {
|
||||
trigger: ':',
|
||||
columns: 'auto',
|
||||
minChars: 2,
|
||||
fetch: (pattern, maxResults) => database.waitForLoad().then(() => {
|
||||
const candidates = database.listAll();
|
||||
return emojisFrom(candidates, pattern, Optional.some(maxResults));
|
||||
}),
|
||||
onAction: (autocompleteApi, rng, value) => {
|
||||
editor.selection.setRng(rng);
|
||||
editor.insertContent(value);
|
||||
autocompleteApi.hide();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onSetupEditable = (editor) => (api) => {
|
||||
const nodeChanged = () => {
|
||||
api.setEnabled(editor.selection.isEditable());
|
||||
};
|
||||
editor.on('NodeChange', nodeChanged);
|
||||
nodeChanged();
|
||||
return () => {
|
||||
editor.off('NodeChange', nodeChanged);
|
||||
};
|
||||
};
|
||||
const register = (editor) => {
|
||||
const onAction = () => editor.execCommand('mceEmoticons');
|
||||
editor.ui.registry.addButton('emoticons', {
|
||||
tooltip: 'Emojis',
|
||||
icon: 'emoji',
|
||||
onAction,
|
||||
onSetup: onSetupEditable(editor)
|
||||
});
|
||||
editor.ui.registry.addMenuItem('emoticons', {
|
||||
text: 'Emojis...',
|
||||
icon: 'emoji',
|
||||
onAction,
|
||||
onSetup: onSetupEditable(editor)
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* This class contains all core logic for the emoticons plugin.
|
||||
*
|
||||
* @class tinymce.emoticons.Plugin
|
||||
* @private
|
||||
*/
|
||||
var Plugin = () => {
|
||||
global$1.add('emoticons', (editor, pluginUrl) => {
|
||||
register$2(editor, pluginUrl);
|
||||
const databaseUrl = getEmojiDatabaseUrl(editor);
|
||||
const databaseId = getEmojiDatabaseId(editor);
|
||||
const database = initDatabase(editor, databaseUrl, databaseId);
|
||||
register$1(editor, database);
|
||||
register(editor);
|
||||
init(editor, database);
|
||||
setup(editor);
|
||||
return {
|
||||
getAllEmojis: () => database.waitForLoad().then(() => database.listAll())
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
Plugin();
|
||||
/** *****
|
||||
* DO NOT EXPORT ANYTHING
|
||||
*
|
||||
* IF YOU DO ROLLUP WILL LEAVE A GLOBAL ON THE PAGE
|
||||
*******/
|
||||
|
||||
})();
|
||||
+4
-1
File diff suppressed because one or more lines are too long
@@ -1,7 +0,0 @@
|
||||
// Exports the "fullscreen" plugin for usage with module loaders
|
||||
// Usage:
|
||||
// CommonJS:
|
||||
// require('tinymce/plugins/fullscreen')
|
||||
// ES2015:
|
||||
// import 'tinymce/plugins/fullscreen'
|
||||
require('./plugin.js');
|
||||
File diff suppressed because it is too large
Load Diff
+4
-1
File diff suppressed because one or more lines are too long
@@ -1,7 +0,0 @@
|
||||
// Exports the "help" plugin for usage with module loaders
|
||||
// Usage:
|
||||
// CommonJS:
|
||||
// require('tinymce/plugins/help')
|
||||
// ES2015:
|
||||
// import 'tinymce/plugins/help'
|
||||
require('./plugin.js');
|
||||
@@ -1,93 +1,93 @@
|
||||
tinymce.Resource.add('tinymce.html-i18n.help-keynav.ar',
|
||||
'<h1>بدء التنقل بواسطة لوحة المفاتيح</h1>\n' +
|
||||
'\n' +
|
||||
'<dl>\n' +
|
||||
' <dt>التركيز على شريط القوائم</dt>\n' +
|
||||
' <dd>نظاما التشغيل Windows أو Linux: Alt + F9</dd>\n' +
|
||||
' <dd>نظام التشغيل macOS: ⌥F9</dd>\n' +
|
||||
' <dt>التركيز على شريط الأدوات</dt>\n' +
|
||||
' <dd>نظاما التشغيل Windows أو Linux: Alt + F10</dd>\n' +
|
||||
' <dd>نظام التشغيل macOS: ⌥F10</dd>\n' +
|
||||
' <dt>التركيز على التذييل</dt>\n' +
|
||||
' <dd>نظاما التشغيل Windows أو Linux: Alt + F11</dd>\n' +
|
||||
' <dd>نظام التشغيل macOS: ⌥F11</dd>\n' +
|
||||
' <dt>تركيز الإشعارات</dt>\n' +
|
||||
' <dd>نظاما التشغيل Windows أو Linux: Alt + F12</dd>\n' +
|
||||
' <dd>نظام التشغيل macOS: ⌥F12</dd>\n' +
|
||||
' <dt>التركيز على شريط أدوات السياق</dt>\n' +
|
||||
' <dd>أنظمة التشغيل Windows أو Linux أو macOS: Ctrl+F9</dd>\n' +
|
||||
'</dl>\n' +
|
||||
'\n' +
|
||||
'<p>سيبدأ التنقل عند عنصر واجهة المستخدم الأول، والذي سيتم تمييزه أو تسطيره في حالة العنصر الأول في\n' +
|
||||
' مسار عنصر التذييل.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>التنقل بين أقسام واجهة المستخدم</h1>\n' +
|
||||
'\n' +
|
||||
'<p>للانتقال من أحد أقسام واجهة المستخدم إلى القسم التالي، اضغط على <strong>Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>للانتقال من أحد أقسام واجهة المستخدم إلى القسم السابق، اضغط على <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>ترتيب علامات <strong>Tab</strong> لأقسام واجهة المستخدم هذه هو:</p>\n' +
|
||||
'\n' +
|
||||
'<ol>\n' +
|
||||
' <li>شريط القوائم</li>\n' +
|
||||
' <li>كل مجموعة شريط الأدوات</li>\n' +
|
||||
' <li>الشريط الجانبي</li>\n' +
|
||||
' <li>مسار العنصر في التذييل</li>\n' +
|
||||
' <li>زر تبديل عدد الكلمات في التذييل</li>\n' +
|
||||
' <li>رابط إدراج العلامة التجارية في التذييل</li>\n' +
|
||||
' <li>مؤشر تغيير حجم المحرر في التذييل</li>\n' +
|
||||
'</ol>\n' +
|
||||
'\n' +
|
||||
'<p>إذا لم يكن قسم واجهة المستخدم موجودًا، فسيتم تخطيه.</p>\n' +
|
||||
'\n' +
|
||||
'<p>إذا كان التذييل يحتوي على التركيز على التنقل بواسطة لوحة المفاتيح، ولا يوجد شريط جانبي مرئي، فإن الضغط على <strong>Shift+Tab</strong>\n' +
|
||||
' ينقل التركيز إلى مجموعة شريط الأدوات الأولى، وليس الأخيرة.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>التنقل بين أقسام واجهة المستخدم</h1>\n' +
|
||||
'\n' +
|
||||
'<p>للانتقال من أحد عناصر واجهة المستخدم إلى العنصر التالي، اضغط على مفتاح <strong>السهم</strong> المناسب.</p>\n' +
|
||||
'\n' +
|
||||
'<p>مفتاحا السهمين <strong>اليسار</strong> و<strong>اليمين</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>التنقل بين القوائم في شريط القوائم.</li>\n' +
|
||||
' <li>فتح قائمة فرعية في القائمة.</li>\n' +
|
||||
' <li>التنقل بين الأزرار في مجموعة شريط الأدوات.</li>\n' +
|
||||
' <li>التنقل بين العناصر في مسار عنصر التذييل.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p>مفتاحا السهمين <strong>لأسفل</strong> و<strong>لأعلى</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>التنقل بين عناصر القائمة في القائمة.</li>\n' +
|
||||
' <li>التنقل بين العناصر في قائمة شريط الأدوات المنبثقة.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p>دورة مفاتيح <strong>الأسهم</strong> داخل قسم واجهة المستخدم التي تم التركيز عليها.</p>\n' +
|
||||
'\n' +
|
||||
'<p>لإغلاق قائمة مفتوحة أو قائمة فرعية مفتوحة أو قائمة منبثقة مفتوحة، اضغط على مفتاح <strong>Esc</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>إذا كان التركيز الحالي على "الجزء العلوي" من قسم معين لواجهة المستخدم، فإن الضغط على مفتاح <strong>Esc</strong> يؤدي أيضًا إلى الخروج\n' +
|
||||
' من التنقل بواسطة لوحة المفاتيح بالكامل.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>تنفيذ عنصر قائمة أو زر شريط أدوات</h1>\n' +
|
||||
'\n' +
|
||||
'<p>عندما يتم تمييز عنصر القائمة المطلوب أو زر شريط الأدوات، اضغط على زر <strong>Return</strong>، أو <strong>Enter</strong>،\n' +
|
||||
' أو <strong>مفتاح المسافة</strong> لتنفيذ العنصر.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>التنقل في مربعات الحوار غير المبوبة</h1>\n' +
|
||||
'\n' +
|
||||
'<p>في مربعات الحوار غير المبوبة، يتم التركيز على المكون التفاعلي الأول عند فتح مربع الحوار.</p>\n' +
|
||||
'\n' +
|
||||
'<p>التنقل بين مكونات الحوار التفاعلي بالضغط على زر <strong>Tab</strong> أو <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>التنقل في مربعات الحوار المبوبة</h1>\n' +
|
||||
'\n' +
|
||||
'<p>في مربعات الحوار المبوبة، يتم التركيز على الزر الأول في قائمة علامات التبويب عند فتح مربع الحوار.</p>\n' +
|
||||
'\n' +
|
||||
'<p>التنقل بين المكونات التفاعلية لعلامة التبويب لمربع الحوار هذه بالضغط على زر <strong>Tab</strong> أو\n' +
|
||||
' <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>التبديل إلى علامة تبويب أخرى لمربع الحوار من خلال التركيز على قائمة علامة التبويب ثم الضغط على زر <strong>السهم</strong> المناسب\n' +
|
||||
' مفتاح للتنقل بين علامات التبويب المتاحة.</p>\n');
|
||||
'<h1>بدء التنقل بواسطة لوحة المفاتيح</h1>\n' +
|
||||
'\n' +
|
||||
'<dl>\n' +
|
||||
' <dt>التركيز على شريط القوائم</dt>\n' +
|
||||
' <dd>نظاما التشغيل Windows أو Linux: Alt + F9</dd>\n' +
|
||||
' <dd>نظام التشغيل macOS: ⌥F9</dd>\n' +
|
||||
' <dt>التركيز على شريط الأدوات</dt>\n' +
|
||||
' <dd>نظاما التشغيل Windows أو Linux: Alt + F10</dd>\n' +
|
||||
' <dd>نظام التشغيل macOS: ⌥F10</dd>\n' +
|
||||
' <dt>التركيز على التذييل</dt>\n' +
|
||||
' <dd>نظاما التشغيل Windows أو Linux: Alt + F11</dd>\n' +
|
||||
' <dd>نظام التشغيل macOS: ⌥F11</dd>\n' +
|
||||
' <dt>تركيز الإشعارات</dt>\n' +
|
||||
' <dd>نظاما التشغيل Windows أو Linux: Alt + F12</dd>\n' +
|
||||
' <dd>نظام التشغيل macOS: ⌥F12</dd>\n' +
|
||||
' <dt>التركيز على شريط أدوات السياق</dt>\n' +
|
||||
' <dd>أنظمة التشغيل Windows أو Linux أو macOS: Ctrl+F9</dd>\n' +
|
||||
'</dl>\n' +
|
||||
'\n' +
|
||||
'<p>سيبدأ التنقل عند عنصر واجهة المستخدم الأول، والذي سيتم تمييزه أو تسطيره في حالة العنصر الأول في\n' +
|
||||
' مسار عنصر التذييل.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>التنقل بين أقسام واجهة المستخدم</h1>\n' +
|
||||
'\n' +
|
||||
'<p>للانتقال من أحد أقسام واجهة المستخدم إلى القسم التالي، اضغط على <strong>Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>للانتقال من أحد أقسام واجهة المستخدم إلى القسم السابق، اضغط على <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>ترتيب علامات <strong>Tab</strong> لأقسام واجهة المستخدم هذه هو:</p>\n' +
|
||||
'\n' +
|
||||
'<ol>\n' +
|
||||
' <li>شريط القوائم</li>\n' +
|
||||
' <li>كل مجموعة شريط الأدوات</li>\n' +
|
||||
' <li>الشريط الجانبي</li>\n' +
|
||||
' <li>مسار العنصر في التذييل</li>\n' +
|
||||
' <li>زر تبديل عدد الكلمات في التذييل</li>\n' +
|
||||
' <li>رابط إدراج العلامة التجارية في التذييل</li>\n' +
|
||||
' <li>مؤشر تغيير حجم المحرر في التذييل</li>\n' +
|
||||
'</ol>\n' +
|
||||
'\n' +
|
||||
'<p>إذا لم يكن قسم واجهة المستخدم موجودًا، فسيتم تخطيه.</p>\n' +
|
||||
'\n' +
|
||||
'<p>إذا كان التذييل يحتوي على التركيز على التنقل بواسطة لوحة المفاتيح، ولا يوجد شريط جانبي مرئي، فإن الضغط على <strong>Shift+Tab</strong>\n' +
|
||||
' ينقل التركيز إلى مجموعة شريط الأدوات الأولى، وليس الأخيرة.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>التنقل بين أقسام واجهة المستخدم</h1>\n' +
|
||||
'\n' +
|
||||
'<p>للانتقال من أحد عناصر واجهة المستخدم إلى العنصر التالي، اضغط على مفتاح <strong>السهم</strong> المناسب.</p>\n' +
|
||||
'\n' +
|
||||
'<p>مفتاحا السهمين <strong>اليسار</strong> و<strong>اليمين</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>التنقل بين القوائم في شريط القوائم.</li>\n' +
|
||||
' <li>فتح قائمة فرعية في القائمة.</li>\n' +
|
||||
' <li>التنقل بين الأزرار في مجموعة شريط الأدوات.</li>\n' +
|
||||
' <li>التنقل بين العناصر في مسار عنصر التذييل.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p>مفتاحا السهمين <strong>لأسفل</strong> و<strong>لأعلى</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>التنقل بين عناصر القائمة في القائمة.</li>\n' +
|
||||
' <li>التنقل بين العناصر في قائمة شريط الأدوات المنبثقة.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p>دورة مفاتيح <strong>الأسهم</strong> داخل قسم واجهة المستخدم التي تم التركيز عليها.</p>\n' +
|
||||
'\n' +
|
||||
'<p>لإغلاق قائمة مفتوحة أو قائمة فرعية مفتوحة أو قائمة منبثقة مفتوحة، اضغط على مفتاح <strong>Esc</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>إذا كان التركيز الحالي على "الجزء العلوي" من قسم معين لواجهة المستخدم، فإن الضغط على مفتاح <strong>Esc</strong> يؤدي أيضًا إلى الخروج\n' +
|
||||
' من التنقل بواسطة لوحة المفاتيح بالكامل.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>تنفيذ عنصر قائمة أو زر شريط أدوات</h1>\n' +
|
||||
'\n' +
|
||||
'<p>عندما يتم تمييز عنصر القائمة المطلوب أو زر شريط الأدوات، اضغط على زر <strong>Return</strong>، أو <strong>Enter</strong>،\n' +
|
||||
' أو <strong>مفتاح المسافة</strong> لتنفيذ العنصر.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>التنقل في مربعات الحوار غير المبوبة</h1>\n' +
|
||||
'\n' +
|
||||
'<p>في مربعات الحوار غير المبوبة، يتم التركيز على المكون التفاعلي الأول عند فتح مربع الحوار.</p>\n' +
|
||||
'\n' +
|
||||
'<p>التنقل بين مكونات الحوار التفاعلي بالضغط على زر <strong>Tab</strong> أو <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>التنقل في مربعات الحوار المبوبة</h1>\n' +
|
||||
'\n' +
|
||||
'<p>في مربعات الحوار المبوبة، يتم التركيز على الزر الأول في قائمة علامات التبويب عند فتح مربع الحوار.</p>\n' +
|
||||
'\n' +
|
||||
'<p>التنقل بين المكونات التفاعلية لعلامة التبويب لمربع الحوار هذه بالضغط على زر <strong>Tab</strong> أو\n' +
|
||||
' <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>التبديل إلى علامة تبويب أخرى لمربع الحوار من خلال التركيز على قائمة علامة التبويب ثم الضغط على زر <strong>السهم</strong> المناسب\n' +
|
||||
' مفتاح للتنقل بين علامات التبويب المتاحة.</p>\n');
|
||||
@@ -1,93 +0,0 @@
|
||||
tinymce.Resource.add('tinymce.html-i18n.help-keynav.bg-BG',
|
||||
'<h1>Начало на навигацията с клавиатурата</h1>\n' +
|
||||
'\n' +
|
||||
'<dl>\n' +
|
||||
' <dt>Фокусиране върху лентата с менюта</dt>\n' +
|
||||
' <dd>Windows или Linux: Alt+F9</dd>\n' +
|
||||
' <dd>macOS: ⌥F9</dd>\n' +
|
||||
' <dt>Фокусиране върху лентата с инструменти</dt>\n' +
|
||||
' <dd>Windows или Linux: Alt+F10</dd>\n' +
|
||||
' <dd>macOS: ⌥F10</dd>\n' +
|
||||
' <dt>Фокусиране върху долния колонтитул</dt>\n' +
|
||||
' <dd>Windows или Linux: Alt+F11</dd>\n' +
|
||||
' <dd>macOS: ⌥F11</dd>\n' +
|
||||
' <dt>Фокусиране на известието</dt>\n' +
|
||||
' <dd>Windows или Linux: Alt+F12</dd>\n' +
|
||||
' <dd>macOS: ⌥F12</dd>\n' +
|
||||
' <dt>Фокусиране върху контекстуалната лента с инструменти</dt>\n' +
|
||||
' <dd>Windows, Linux или macOS: Ctrl+F9</dd>\n' +
|
||||
'</dl>\n' +
|
||||
'\n' +
|
||||
'<p>Навигацията ще започне с първия елемент на ПИ, който ще бъде маркиран или подчертан в случая на първия елемент в\n' +
|
||||
' пътя до елемента в долния колонтитул.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Навигиране между раздели на ПИ</h1>\n' +
|
||||
'\n' +
|
||||
'<p>За да преминете от един раздел на ПИ към следващия, натиснете <strong>Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>За да преминете от един раздел на ПИ към предишния, натиснете <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Редът за <strong>обхождане с табулация</strong> на тези раздели на ПИ е:</p>\n' +
|
||||
'\n' +
|
||||
'<ol>\n' +
|
||||
' <li>Лентата с менюта</li>\n' +
|
||||
' <li>Всяка група на лентата с инструменти</li>\n' +
|
||||
' <li>Страничната лента</li>\n' +
|
||||
' <li>Пътят до елемента в долния колонтитул</li>\n' +
|
||||
' <li>Бутонът за превключване на броя на думите в долния колонтитул</li>\n' +
|
||||
' <li>Връзката за търговска марка в долния колонтитул</li>\n' +
|
||||
' <li>Манипулаторът за преоразмеряване на редактора в долния колонтитул</li>\n' +
|
||||
'</ol>\n' +
|
||||
'\n' +
|
||||
'<p>Ако някой раздел на ПИ липсва, той се пропуска.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Ако долният колонтитул има фокус за навигация с клавиатурата и няма странична лента, натискането на <strong>Shift+Tab</strong>\n' +
|
||||
' премества фокуса към първата група на лентата с инструменти, а не към последната.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Навигиране в разделите на ПИ</h1>\n' +
|
||||
'\n' +
|
||||
'<p>За да преминете от един елемент на ПИ към следващия, натиснете съответния клавиш със <strong>стрелка</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>С клавишите със стрелка <strong>наляво</strong> и <strong>надясно</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>се придвижвате между менютата в лентата с менюто;</li>\n' +
|
||||
' <li>отваряте подменю в меню;</li>\n' +
|
||||
' <li>се придвижвате между бутоните в група на лентата с инструменти;</li>\n' +
|
||||
' <li>се придвижвате между елементи в пътя до елемент в долния колонтитул.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p>С клавишите със стрелка <strong>надолу</strong> и <strong>нагоре</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>се придвижвате между елементите от менюто в дадено меню;</li>\n' +
|
||||
' <li>се придвижвате между елементите в изскачащо меню на лентата с инструменти.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p>Клавишите със <strong>стрелки</strong> се придвижват в рамките на фокусирания раздел на ПИ.</p>\n' +
|
||||
'\n' +
|
||||
'<p>За да затворите отворено меню, подменю или изскачащо меню, натиснете клавиша <strong>Esc</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Ако текущият фокус е върху „горната част“ на конкретен раздел на ПИ, натискането на клавиша <strong>Esc</strong> също излиза\n' +
|
||||
' напълно от навигацията с клавиатурата.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Изпълнение на елемент от менюто или бутон от лентата с инструменти</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Когато желаният елемент от менюто или бутон от лентата с инструменти е маркиран, натиснете <strong>Return</strong>, <strong>Enter</strong>\n' +
|
||||
' или <strong>клавиша за интервал</strong>, за да изпълните елемента.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Навигиране в диалогови прозорци без раздели</h1>\n' +
|
||||
'\n' +
|
||||
'<p>В диалоговите прозорци без раздели първият интерактивен компонент се фокусира, когато се отвори диалоговият прозорец.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Навигирайте между интерактивните компоненти на диалоговия прозорец, като натиснете <strong>Tab</strong> или <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Навигиране в диалогови прозорци с раздели</h1>\n' +
|
||||
'\n' +
|
||||
'<p>В диалоговите прозорци с раздели първият бутон в менюто с раздели се фокусира, когато се отвори диалоговият прозорец.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Навигирайте между интерактивните компоненти на този диалогов раздел, като натиснете <strong>Tab</strong> или\n' +
|
||||
' <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Превключете към друг диалогов раздел, като фокусирате върху менюто с раздели и след това натиснете съответния клавиш със <strong>стрелка</strong>,\n' +
|
||||
' за да преминете през наличните раздели.</p>\n');
|
||||
@@ -1,93 +1,93 @@
|
||||
tinymce.Resource.add('tinymce.html-i18n.help-keynav.bg_BG',
|
||||
'<h1>Начало на навигацията с клавиатурата</h1>\n' +
|
||||
'\n' +
|
||||
'<dl>\n' +
|
||||
' <dt>Фокусиране върху лентата с менюта</dt>\n' +
|
||||
' <dd>Windows или Linux: Alt+F9</dd>\n' +
|
||||
' <dd>macOS: ⌥F9</dd>\n' +
|
||||
' <dt>Фокусиране върху лентата с инструменти</dt>\n' +
|
||||
' <dd>Windows или Linux: Alt+F10</dd>\n' +
|
||||
' <dd>macOS: ⌥F10</dd>\n' +
|
||||
' <dt>Фокусиране върху долния колонтитул</dt>\n' +
|
||||
' <dd>Windows или Linux: Alt+F11</dd>\n' +
|
||||
' <dd>macOS: ⌥F11</dd>\n' +
|
||||
' <dt>Фокусиране на известието</dt>\n' +
|
||||
' <dd>Windows или Linux: Alt+F12</dd>\n' +
|
||||
' <dd>macOS: ⌥F12</dd>\n' +
|
||||
' <dt>Фокусиране върху контекстуалната лента с инструменти</dt>\n' +
|
||||
' <dd>Windows, Linux или macOS: Ctrl+F9</dd>\n' +
|
||||
'</dl>\n' +
|
||||
'\n' +
|
||||
'<p>Навигацията ще започне с първия елемент на ПИ, който ще бъде маркиран или подчертан в случая на първия елемент в\n' +
|
||||
' пътя до елемента в долния колонтитул.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Навигиране между раздели на ПИ</h1>\n' +
|
||||
'\n' +
|
||||
'<p>За да преминете от един раздел на ПИ към следващия, натиснете <strong>Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>За да преминете от един раздел на ПИ към предишния, натиснете <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Редът за <strong>обхождане с табулация</strong> на тези раздели на ПИ е:</p>\n' +
|
||||
'\n' +
|
||||
'<ol>\n' +
|
||||
' <li>Лентата с менюта</li>\n' +
|
||||
' <li>Всяка група на лентата с инструменти</li>\n' +
|
||||
' <li>Страничната лента</li>\n' +
|
||||
' <li>Пътят до елемента в долния колонтитул</li>\n' +
|
||||
' <li>Бутонът за превключване на броя на думите в долния колонтитул</li>\n' +
|
||||
' <li>Връзката за търговска марка в долния колонтитул</li>\n' +
|
||||
' <li>Манипулаторът за преоразмеряване на редактора в долния колонтитул</li>\n' +
|
||||
'</ol>\n' +
|
||||
'\n' +
|
||||
'<p>Ако някой раздел на ПИ липсва, той се пропуска.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Ако долният колонтитул има фокус за навигация с клавиатурата и няма странична лента, натискането на <strong>Shift+Tab</strong>\n' +
|
||||
' премества фокуса към първата група на лентата с инструменти, а не към последната.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Навигиране в разделите на ПИ</h1>\n' +
|
||||
'\n' +
|
||||
'<p>За да преминете от един елемент на ПИ към следващия, натиснете съответния клавиш със <strong>стрелка</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>С клавишите със стрелка <strong>наляво</strong> и <strong>надясно</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>се придвижвате между менютата в лентата с менюто;</li>\n' +
|
||||
' <li>отваряте подменю в меню;</li>\n' +
|
||||
' <li>се придвижвате между бутоните в група на лентата с инструменти;</li>\n' +
|
||||
' <li>се придвижвате между елементи в пътя до елемент в долния колонтитул.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p>С клавишите със стрелка <strong>надолу</strong> и <strong>нагоре</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>се придвижвате между елементите от менюто в дадено меню;</li>\n' +
|
||||
' <li>се придвижвате между елементите в изскачащо меню на лентата с инструменти.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p>Клавишите със <strong>стрелки</strong> се придвижват в рамките на фокусирания раздел на ПИ.</p>\n' +
|
||||
'\n' +
|
||||
'<p>За да затворите отворено меню, подменю или изскачащо меню, натиснете клавиша <strong>Esc</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Ако текущият фокус е върху „горната част“ на конкретен раздел на ПИ, натискането на клавиша <strong>Esc</strong> също излиза\n' +
|
||||
' напълно от навигацията с клавиатурата.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Изпълнение на елемент от менюто или бутон от лентата с инструменти</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Когато желаният елемент от менюто или бутон от лентата с инструменти е маркиран, натиснете <strong>Return</strong>, <strong>Enter</strong>\n' +
|
||||
' или <strong>клавиша за интервал</strong>, за да изпълните елемента.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Навигиране в диалогови прозорци без раздели</h1>\n' +
|
||||
'\n' +
|
||||
'<p>В диалоговите прозорци без раздели първият интерактивен компонент се фокусира, когато се отвори диалоговият прозорец.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Навигирайте между интерактивните компоненти на диалоговия прозорец, като натиснете <strong>Tab</strong> или <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Навигиране в диалогови прозорци с раздели</h1>\n' +
|
||||
'\n' +
|
||||
'<p>В диалоговите прозорци с раздели първият бутон в менюто с раздели се фокусира, когато се отвори диалоговият прозорец.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Навигирайте между интерактивните компоненти на този диалогов раздел, като натиснете <strong>Tab</strong> или\n' +
|
||||
' <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Превключете към друг диалогов раздел, като фокусирате върху менюто с раздели и след това натиснете съответния клавиш със <strong>стрелка</strong>,\n' +
|
||||
' за да преминете през наличните раздели.</p>\n');
|
||||
'<h1>Начало на навигацията с клавиатурата</h1>\n' +
|
||||
'\n' +
|
||||
'<dl>\n' +
|
||||
' <dt>Фокусиране върху лентата с менюта</dt>\n' +
|
||||
' <dd>Windows или Linux: Alt+F9</dd>\n' +
|
||||
' <dd>macOS: ⌥F9</dd>\n' +
|
||||
' <dt>Фокусиране върху лентата с инструменти</dt>\n' +
|
||||
' <dd>Windows или Linux: Alt+F10</dd>\n' +
|
||||
' <dd>macOS: ⌥F10</dd>\n' +
|
||||
' <dt>Фокусиране върху долния колонтитул</dt>\n' +
|
||||
' <dd>Windows или Linux: Alt+F11</dd>\n' +
|
||||
' <dd>macOS: ⌥F11</dd>\n' +
|
||||
' <dt>Фокусиране на известието</dt>\n' +
|
||||
' <dd>Windows или Linux: Alt+F12</dd>\n' +
|
||||
' <dd>macOS: ⌥F12</dd>\n' +
|
||||
' <dt>Фокусиране върху контекстуалната лента с инструменти</dt>\n' +
|
||||
' <dd>Windows, Linux или macOS: Ctrl+F9</dd>\n' +
|
||||
'</dl>\n' +
|
||||
'\n' +
|
||||
'<p>Навигацията ще започне с първия елемент на ПИ, който ще бъде маркиран или подчертан в случая на първия елемент в\n' +
|
||||
' пътя до елемента в долния колонтитул.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Навигиране между раздели на ПИ</h1>\n' +
|
||||
'\n' +
|
||||
'<p>За да преминете от един раздел на ПИ към следващия, натиснете <strong>Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>За да преминете от един раздел на ПИ към предишния, натиснете <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Редът за <strong>обхождане с табулация</strong> на тези раздели на ПИ е:</p>\n' +
|
||||
'\n' +
|
||||
'<ol>\n' +
|
||||
' <li>Лентата с менюта</li>\n' +
|
||||
' <li>Всяка група на лентата с инструменти</li>\n' +
|
||||
' <li>Страничната лента</li>\n' +
|
||||
' <li>Пътят до елемента в долния колонтитул</li>\n' +
|
||||
' <li>Бутонът за превключване на броя на думите в долния колонтитул</li>\n' +
|
||||
' <li>Връзката за търговска марка в долния колонтитул</li>\n' +
|
||||
' <li>Манипулаторът за преоразмеряване на редактора в долния колонтитул</li>\n' +
|
||||
'</ol>\n' +
|
||||
'\n' +
|
||||
'<p>Ако някой раздел на ПИ липсва, той се пропуска.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Ако долният колонтитул има фокус за навигация с клавиатурата и няма странична лента, натискането на <strong>Shift+Tab</strong>\n' +
|
||||
' премества фокуса към първата група на лентата с инструменти, а не към последната.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Навигиране в разделите на ПИ</h1>\n' +
|
||||
'\n' +
|
||||
'<p>За да преминете от един елемент на ПИ към следващия, натиснете съответния клавиш със <strong>стрелка</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>С клавишите със стрелка <strong>наляво</strong> и <strong>надясно</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>се придвижвате между менютата в лентата с менюто;</li>\n' +
|
||||
' <li>отваряте подменю в меню;</li>\n' +
|
||||
' <li>се придвижвате между бутоните в група на лентата с инструменти;</li>\n' +
|
||||
' <li>се придвижвате между елементи в пътя до елемент в долния колонтитул.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p>С клавишите със стрелка <strong>надолу</strong> и <strong>нагоре</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>се придвижвате между елементите от менюто в дадено меню;</li>\n' +
|
||||
' <li>се придвижвате между елементите в изскачащо меню на лентата с инструменти.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p>Клавишите със <strong>стрелки</strong> се придвижват в рамките на фокусирания раздел на ПИ.</p>\n' +
|
||||
'\n' +
|
||||
'<p>За да затворите отворено меню, подменю или изскачащо меню, натиснете клавиша <strong>Esc</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Ако текущият фокус е върху „горната част“ на конкретен раздел на ПИ, натискането на клавиша <strong>Esc</strong> също излиза\n' +
|
||||
' напълно от навигацията с клавиатурата.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Изпълнение на елемент от менюто или бутон от лентата с инструменти</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Когато желаният елемент от менюто или бутон от лентата с инструменти е маркиран, натиснете <strong>Return</strong>, <strong>Enter</strong>\n' +
|
||||
' или <strong>клавиша за интервал</strong>, за да изпълните елемента.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Навигиране в диалогови прозорци без раздели</h1>\n' +
|
||||
'\n' +
|
||||
'<p>В диалоговите прозорци без раздели първият интерактивен компонент се фокусира, когато се отвори диалоговият прозорец.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Навигирайте между интерактивните компоненти на диалоговия прозорец, като натиснете <strong>Tab</strong> или <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Навигиране в диалогови прозорци с раздели</h1>\n' +
|
||||
'\n' +
|
||||
'<p>В диалоговите прозорци с раздели първият бутон в менюто с раздели се фокусира, когато се отвори диалоговият прозорец.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Навигирайте между интерактивните компоненти на този диалогов раздел, като натиснете <strong>Tab</strong> или\n' +
|
||||
' <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Превключете към друг диалогов раздел, като фокусирате върху менюто с раздели и след това натиснете съответния клавиш със <strong>стрелка</strong>,\n' +
|
||||
' за да преминете през наличните раздели.</p>\n');
|
||||
@@ -1,93 +1,93 @@
|
||||
tinymce.Resource.add('tinymce.html-i18n.help-keynav.ca',
|
||||
'<h1>Inici de la navegació amb el teclat</h1>\n' +
|
||||
'\n' +
|
||||
'<dl>\n' +
|
||||
' <dt>Enfocar la barra de menús</dt>\n' +
|
||||
' <dd>Windows o Linux: Alt+F9</dd>\n' +
|
||||
' <dd>macOS: ⌥F9</dd>\n' +
|
||||
" <dt>Enfocar la barra d'eines</dt>\n" +
|
||||
' <dd>Windows o Linux: Alt+F10</dd>\n' +
|
||||
' <dd>macOS: ⌥F10</dd>\n' +
|
||||
' <dt>Enfocar el peu de pàgina</dt>\n' +
|
||||
' <dd>Windows o Linux: Alt+F11</dd>\n' +
|
||||
' <dd>macOS: ⌥F11</dd>\n' +
|
||||
' <dt>Enfocar la notificació</dt>\n' +
|
||||
' <dd>Windows o Linux: Alt+F12</dd>\n' +
|
||||
' <dd>macOS: ⌥F12</dd>\n' +
|
||||
" <dt>Enfocar una barra d'eines contextual</dt>\n" +
|
||||
' <dd>Windows, Linux o macOS: Ctrl+F9</dd>\n' +
|
||||
'</dl>\n' +
|
||||
'\n' +
|
||||
"<p>La navegació començarà en el primer element de la interfície d'usuari, que es ressaltarà o subratllarà per al primer element a\n" +
|
||||
" la ruta de l'element de peu de pàgina.</p>\n" +
|
||||
'\n' +
|
||||
"<h1>Navegació entre seccions de la interfície d'usuari</h1>\n" +
|
||||
'\n' +
|
||||
"<p>Per desplaçar-vos des d'una secció de la interfície d'usuari a la següent, premeu la tecla <strong>Tab</strong>.</p>\n" +
|
||||
'\n' +
|
||||
"<p>Per desplaçar-vos des d'una secció de la interfície d'usuari a l'anterior, premeu les tecles <strong>Maj+Tab</strong>.</p>\n" +
|
||||
'\n' +
|
||||
"<p>L'ordre en prémer la tecla <strong>Tab</strong> d'aquestes secciones de la interfície d'usuari és:</p>\n" +
|
||||
'\n' +
|
||||
'<ol>\n' +
|
||||
' <li>Barra de menús</li>\n' +
|
||||
" <li>Cada grup de la barra d'eines</li>\n" +
|
||||
' <li>Barra lateral</li>\n' +
|
||||
" <li>Ruta de l'element del peu de pàgina</li>\n" +
|
||||
' <li>Botó de commutació de recompte de paraules al peu de pàgina</li>\n' +
|
||||
' <li>Enllaç de marca del peu de pàgina</li>\n' +
|
||||
" <li>Control de canvi de mida de l'editor al peu de pàgina</li>\n" +
|
||||
'</ol>\n' +
|
||||
'\n' +
|
||||
"<p>Si no hi ha una secció de la interfície d'usuari, s'ometrà.</p>\n" +
|
||||
'\n' +
|
||||
'<p>Si el peu de pàgina té el focus de navegació del teclat i no hi ha cap barra lateral visible, en prémer <strong>Maj+Tab</strong>\n' +
|
||||
" el focus es mou al primer grup de la barra d'eines, no l'últim.</p>\n" +
|
||||
'\n' +
|
||||
"<h1>Navegació dins de les seccions de la interfície d'usuari</h1>\n" +
|
||||
'\n' +
|
||||
"<p>Per desplaçar-vos des d'un element de la interfície d'usuari al següent, premeu la tecla de <strong>Fletxa</strong> adequada.</p>\n" +
|
||||
'\n' +
|
||||
'<p>Les tecles de fletxa <strong>Esquerra</strong> i <strong>Dreta</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>us permeten desplaçar-vos entre menús de la barra de menús.</li>\n' +
|
||||
' <li>obren un submenú en un menú.</li>\n' +
|
||||
" <li>us permeten desplaçar-vos entre botons d'un grup de la barra d'eines.</li>\n" +
|
||||
" <li>us permeten desplaçar-vos entre elements de la ruta d'elements del peu de pàgina.</li>\n" +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p>Les tecles de fletxa <strong>Avall</strong> i <strong>Amunt</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
" <li>us permeten desplaçar-vos entre elements de menú d'un menú.</li>\n" +
|
||||
" <li>us permeten desplaçar-vos entre elements d'un menú emergent de la barra d'eines.</li>\n" +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
"<p>Les tecles de <strong>Fletxa</strong> us permeten desplaçar-vos dins de la secció de la interfície d'usuari que té el focus.</p>\n" +
|
||||
'\n' +
|
||||
'<p>Per tancar un menú, un submenú o un menú emergent oberts, premeu la tecla <strong>Esc</strong>.</p>\n' +
|
||||
'\n' +
|
||||
"<p>Si el focus actual es troba a la ‘part superior’ d'una secció específica de la interfície d'usuari, en prémer la tecla <strong>Esc</strong> també es tanca\n" +
|
||||
' completament la navegació amb el teclat.</p>\n' +
|
||||
'\n' +
|
||||
"<h1>Execució d'un element de menú o d'un botó de la barra d'eines</h1>\n" +
|
||||
'\n' +
|
||||
"<p>Quan l'element del menú o el botó de la barra d'eines que desitgeu estigui ressaltat, premeu <strong>Retorn</strong>, <strong>Intro</strong>\n" +
|
||||
" o la <strong>barra d'espai</strong> per executar l'element.</p>\n" +
|
||||
'\n' +
|
||||
'<h1>Navegació per quadres de diàleg sense pestanyes</h1>\n' +
|
||||
'\n' +
|
||||
"<p>En els quadres de diàleg sense pestanyes, el primer component interactiu pren el focus quan s'obre el quadre diàleg.</p>\n" +
|
||||
'\n' +
|
||||
'<p>Premeu la tecla <strong>Tab</strong> o les tecles <strong>Maj+Tab</strong> per desplaçar-vos entre components interactius del quadre de diàleg.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Navegació per quadres de diàleg amb pestanyes</h1>\n' +
|
||||
'\n' +
|
||||
"<p>En els quadres de diàleg amb pestanyes, el primer botó del menú de la pestanya pren el focus quan s'obre el quadre diàleg.</p>\n" +
|
||||
'\n' +
|
||||
"<p>Per desplaçar-vos entre components interactius d'aquest quadre de diàleg, premeu la tecla <strong>Tab</strong> o\n" +
|
||||
' les tecles <strong>Maj+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
"<p>Canvieu a la pestanya d'un altre quadre de diàleg, tot enfocant el menú de la pestanya, i després premeu la tecla <strong>Fletxa</strong> adequada\n" +
|
||||
' per canviar entre les pestanyes disponibles.</p>\n');
|
||||
'<h1>Inici de la navegació amb el teclat</h1>\n' +
|
||||
'\n' +
|
||||
'<dl>\n' +
|
||||
' <dt>Enfocar la barra de menús</dt>\n' +
|
||||
' <dd>Windows o Linux: Alt+F9</dd>\n' +
|
||||
' <dd>macOS: ⌥F9</dd>\n' +
|
||||
" <dt>Enfocar la barra d'eines</dt>\n" +
|
||||
' <dd>Windows o Linux: Alt+F10</dd>\n' +
|
||||
' <dd>macOS: ⌥F10</dd>\n' +
|
||||
' <dt>Enfocar el peu de pàgina</dt>\n' +
|
||||
' <dd>Windows o Linux: Alt+F11</dd>\n' +
|
||||
' <dd>macOS: ⌥F11</dd>\n' +
|
||||
' <dt>Enfocar la notificació</dt>\n' +
|
||||
' <dd>Windows o Linux: Alt+F12</dd>\n' +
|
||||
' <dd>macOS: ⌥F12</dd>\n' +
|
||||
" <dt>Enfocar una barra d'eines contextual</dt>\n" +
|
||||
' <dd>Windows, Linux o macOS: Ctrl+F9</dd>\n' +
|
||||
'</dl>\n' +
|
||||
'\n' +
|
||||
"<p>La navegació començarà en el primer element de la interfície d'usuari, que es ressaltarà o subratllarà per al primer element a\n" +
|
||||
" la ruta de l'element de peu de pàgina.</p>\n" +
|
||||
'\n' +
|
||||
"<h1>Navegació entre seccions de la interfície d'usuari</h1>\n" +
|
||||
'\n' +
|
||||
"<p>Per desplaçar-vos des d'una secció de la interfície d'usuari a la següent, premeu la tecla <strong>Tab</strong>.</p>\n" +
|
||||
'\n' +
|
||||
"<p>Per desplaçar-vos des d'una secció de la interfície d'usuari a l'anterior, premeu les tecles <strong>Maj+Tab</strong>.</p>\n" +
|
||||
'\n' +
|
||||
"<p>L'ordre en prémer la tecla <strong>Tab</strong> d'aquestes secciones de la interfície d'usuari és:</p>\n" +
|
||||
'\n' +
|
||||
'<ol>\n' +
|
||||
' <li>Barra de menús</li>\n' +
|
||||
" <li>Cada grup de la barra d'eines</li>\n" +
|
||||
' <li>Barra lateral</li>\n' +
|
||||
" <li>Ruta de l'element del peu de pàgina</li>\n" +
|
||||
' <li>Botó de commutació de recompte de paraules al peu de pàgina</li>\n' +
|
||||
' <li>Enllaç de marca del peu de pàgina</li>\n' +
|
||||
" <li>Control de canvi de mida de l'editor al peu de pàgina</li>\n" +
|
||||
'</ol>\n' +
|
||||
'\n' +
|
||||
"<p>Si no hi ha una secció de la interfície d'usuari, s'ometrà.</p>\n" +
|
||||
'\n' +
|
||||
'<p>Si el peu de pàgina té el focus de navegació del teclat i no hi ha cap barra lateral visible, en prémer <strong>Maj+Tab</strong>\n' +
|
||||
" el focus es mou al primer grup de la barra d'eines, no l'últim.</p>\n" +
|
||||
'\n' +
|
||||
"<h1>Navegació dins de les seccions de la interfície d'usuari</h1>\n" +
|
||||
'\n' +
|
||||
"<p>Per desplaçar-vos des d'un element de la interfície d'usuari al següent, premeu la tecla de <strong>Fletxa</strong> adequada.</p>\n" +
|
||||
'\n' +
|
||||
'<p>Les tecles de fletxa <strong>Esquerra</strong> i <strong>Dreta</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>us permeten desplaçar-vos entre menús de la barra de menús.</li>\n' +
|
||||
' <li>obren un submenú en un menú.</li>\n' +
|
||||
" <li>us permeten desplaçar-vos entre botons d'un grup de la barra d'eines.</li>\n" +
|
||||
" <li>us permeten desplaçar-vos entre elements de la ruta d'elements del peu de pàgina.</li>\n" +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p>Les tecles de fletxa <strong>Avall</strong> i <strong>Amunt</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
" <li>us permeten desplaçar-vos entre elements de menú d'un menú.</li>\n" +
|
||||
" <li>us permeten desplaçar-vos entre elements d'un menú emergent de la barra d'eines.</li>\n" +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
"<p>Les tecles de <strong>Fletxa</strong> us permeten desplaçar-vos dins de la secció de la interfície d'usuari que té el focus.</p>\n" +
|
||||
'\n' +
|
||||
'<p>Per tancar un menú, un submenú o un menú emergent oberts, premeu la tecla <strong>Esc</strong>.</p>\n' +
|
||||
'\n' +
|
||||
"<p>Si el focus actual es troba a la ‘part superior’ d'una secció específica de la interfície d'usuari, en prémer la tecla <strong>Esc</strong> també es tanca\n" +
|
||||
' completament la navegació amb el teclat.</p>\n' +
|
||||
'\n' +
|
||||
"<h1>Execució d'un element de menú o d'un botó de la barra d'eines</h1>\n" +
|
||||
'\n' +
|
||||
"<p>Quan l'element del menú o el botó de la barra d'eines que desitgeu estigui ressaltat, premeu <strong>Retorn</strong>, <strong>Intro</strong>\n" +
|
||||
" o la <strong>barra d'espai</strong> per executar l'element.</p>\n" +
|
||||
'\n' +
|
||||
'<h1>Navegació per quadres de diàleg sense pestanyes</h1>\n' +
|
||||
'\n' +
|
||||
"<p>En els quadres de diàleg sense pestanyes, el primer component interactiu pren el focus quan s'obre el quadre diàleg.</p>\n" +
|
||||
'\n' +
|
||||
'<p>Premeu la tecla <strong>Tab</strong> o les tecles <strong>Maj+Tab</strong> per desplaçar-vos entre components interactius del quadre de diàleg.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Navegació per quadres de diàleg amb pestanyes</h1>\n' +
|
||||
'\n' +
|
||||
"<p>En els quadres de diàleg amb pestanyes, el primer botó del menú de la pestanya pren el focus quan s'obre el quadre diàleg.</p>\n" +
|
||||
'\n' +
|
||||
"<p>Per desplaçar-vos entre components interactius d'aquest quadre de diàleg, premeu la tecla <strong>Tab</strong> o\n" +
|
||||
' les tecles <strong>Maj+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
"<p>Canvieu a la pestanya d'un altre quadre de diàleg, tot enfocant el menú de la pestanya, i després premeu la tecla <strong>Fletxa</strong> adequada\n" +
|
||||
' per canviar entre les pestanyes disponibles.</p>\n');
|
||||
@@ -1,93 +1,93 @@
|
||||
tinymce.Resource.add('tinymce.html-i18n.help-keynav.cs',
|
||||
'<h1>Začínáme navigovat pomocí klávesnice</h1>\n' +
|
||||
'\n' +
|
||||
'<dl>\n' +
|
||||
' <dt>Přejít na řádek nabídek</dt>\n' +
|
||||
' <dd>Windows nebo Linux: Alt+F9</dd>\n' +
|
||||
' <dd>macOS: ⌥F9</dd>\n' +
|
||||
' <dt>Přejít na panel nástrojů</dt>\n' +
|
||||
' <dd>Windows nebo Linux: Alt+F10</dd>\n' +
|
||||
' <dd>macOS: ⌥F10</dd>\n' +
|
||||
' <dt>Přejít na zápatí</dt>\n' +
|
||||
' <dd>Windows nebo Linux: Alt+F11</dd>\n' +
|
||||
' <dd>macOS: ⌥F11</dd>\n' +
|
||||
' <dt>Přejít na oznámení</dt>\n' +
|
||||
' <dd>Windows nebo Linux: Alt+F12</dd>\n' +
|
||||
' <dd>macOS: ⌥F12</dd>\n' +
|
||||
' <dt>Přejít na kontextový panel nástrojů</dt>\n' +
|
||||
' <dd>Windows, Linux nebo macOS: Ctrl+F9</dd>\n' +
|
||||
'</dl>\n' +
|
||||
'\n' +
|
||||
'<p>Navigace začne u první položky uživatelského rozhraní, která bude zvýrazněna nebo v případě první položky\n' +
|
||||
' cesty k prvku zápatí podtržena.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Navigace mezi oddíly uživatelského rozhraní</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Stisknutím klávesy <strong>Tab</strong> se posunete z jednoho oddílu uživatelského rozhraní na další.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Stisknutím kláves <strong>Shift+Tab</strong> se posunete z jednoho oddílu uživatelského rozhraní na předchozí.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Pořadí přepínání mezi oddíly uživatelského rozhraní pomocí klávesy <strong>Tab</strong>:</p>\n' +
|
||||
'\n' +
|
||||
'<ol>\n' +
|
||||
' <li>Řádek nabídek</li>\n' +
|
||||
' <li>Každá skupina panelu nástrojů</li>\n' +
|
||||
' <li>Boční panel</li>\n' +
|
||||
' <li>Cesta k prvku v zápatí.</li>\n' +
|
||||
' <li>Tlačítko přepínače počtu slov v zápatí</li>\n' +
|
||||
' <li>Odkaz na informace o značce v zápatí</li>\n' +
|
||||
' <li>Úchyt pro změnu velikosti editoru v zápatí</li>\n' +
|
||||
'</ol>\n' +
|
||||
'\n' +
|
||||
'<p>Pokud nějaký oddíl uživatelského rozhraní není přítomen, je přeskočen.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Pokud je zápatí vybrané pro navigaci pomocí klávesnice a není zobrazen žádný boční panel, stisknutím kláves <strong>Shift+Tab</strong>\n' +
|
||||
' přejdete na první skupinu panelu nástrojů, nikoli na poslední.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Navigace v rámci oddílů uživatelského rozhraní</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Chcete-li se přesunout z jednoho prvku uživatelského rozhraní na další, stiskněte příslušnou klávesu s <strong>šipkou</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Klávesy s šipkou <strong>vlevo</strong> a <strong>vpravo</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>umožňují přesun mezi nabídkami na řádku nabídek;</li>\n' +
|
||||
' <li>otevírají podnabídku nabídky;</li>\n' +
|
||||
' <li>umožňují přesun mezi tlačítky ve skupině panelu nástrojů;</li>\n' +
|
||||
' <li>umožňují přesun mezi položkami cesty prvku v zápatí.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p>Klávesy se šipkou <strong>dolů</strong> a <strong>nahoru</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>umožňují přesun mezi položkami nabídky;</li>\n' +
|
||||
' <li>umožňují přesun mezi položkami místní nabídky panelu nástrojů.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p><strong>Šipky</strong> provádí přepínání v rámci vybraného oddílu uživatelského rozhraní.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Chcete-li zavřít otevřenou nabídku, podnabídku nebo místní nabídku, stiskněte klávesu <strong>Esc</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Pokud je aktuálně vybrána horní část oddílu uživatelského rozhraní, stisknutím klávesy <strong>Esc</strong> zcela ukončíte také\n' +
|
||||
' navigaci pomocí klávesnice.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Provedení příkazu položky nabídky nebo tlačítka panelu nástrojů</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Pokud je zvýrazněna požadovaná položka nabídky nebo tlačítko panelu nástrojů, stisknutím klávesy <strong>Return</strong>, <strong>Enter</strong>\n' +
|
||||
' nebo <strong>mezerníku</strong> provedete příslušný příkaz.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Navigace v dialogových oknech bez záložek</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Při otevření dialogových oken bez záložek přejdete na první interaktivní komponentu.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Přecházet mezi interaktivními komponentami dialogového okna můžete stisknutím klávesy <strong>Tab</strong> nebo kombinace <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Navigace v dialogových oknech se záložkami</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Při otevření dialogových oken se záložkami přejdete na první tlačítko v nabídce záložek.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Přecházet mezi interaktivními komponentami této záložky dialogového okna můžete stisknutím klávesy <strong>Tab</strong> nebo\n' +
|
||||
' kombinace <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Chcete-li přepnout na další záložku dialogového okna, přejděte na nabídku záložek a poté můžete stisknutím požadované <strong>šipky</strong>\n' +
|
||||
' přepínat mezi dostupnými záložkami.</p>\n');
|
||||
'<h1>Začínáme navigovat pomocí klávesnice</h1>\n' +
|
||||
'\n' +
|
||||
'<dl>\n' +
|
||||
' <dt>Přejít na řádek nabídek</dt>\n' +
|
||||
' <dd>Windows nebo Linux: Alt+F9</dd>\n' +
|
||||
' <dd>macOS: ⌥F9</dd>\n' +
|
||||
' <dt>Přejít na panel nástrojů</dt>\n' +
|
||||
' <dd>Windows nebo Linux: Alt+F10</dd>\n' +
|
||||
' <dd>macOS: ⌥F10</dd>\n' +
|
||||
' <dt>Přejít na zápatí</dt>\n' +
|
||||
' <dd>Windows nebo Linux: Alt+F11</dd>\n' +
|
||||
' <dd>macOS: ⌥F11</dd>\n' +
|
||||
' <dt>Přejít na oznámení</dt>\n' +
|
||||
' <dd>Windows nebo Linux: Alt+F12</dd>\n' +
|
||||
' <dd>macOS: ⌥F12</dd>\n' +
|
||||
' <dt>Přejít na kontextový panel nástrojů</dt>\n' +
|
||||
' <dd>Windows, Linux nebo macOS: Ctrl+F9</dd>\n' +
|
||||
'</dl>\n' +
|
||||
'\n' +
|
||||
'<p>Navigace začne u první položky uživatelského rozhraní, která bude zvýrazněna nebo v případě první položky\n' +
|
||||
' cesty k prvku zápatí podtržena.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Navigace mezi oddíly uživatelského rozhraní</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Stisknutím klávesy <strong>Tab</strong> se posunete z jednoho oddílu uživatelského rozhraní na další.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Stisknutím kláves <strong>Shift+Tab</strong> se posunete z jednoho oddílu uživatelského rozhraní na předchozí.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Pořadí přepínání mezi oddíly uživatelského rozhraní pomocí klávesy <strong>Tab</strong>:</p>\n' +
|
||||
'\n' +
|
||||
'<ol>\n' +
|
||||
' <li>Řádek nabídek</li>\n' +
|
||||
' <li>Každá skupina panelu nástrojů</li>\n' +
|
||||
' <li>Boční panel</li>\n' +
|
||||
' <li>Cesta k prvku v zápatí.</li>\n' +
|
||||
' <li>Tlačítko přepínače počtu slov v zápatí</li>\n' +
|
||||
' <li>Odkaz na informace o značce v zápatí</li>\n' +
|
||||
' <li>Úchyt pro změnu velikosti editoru v zápatí</li>\n' +
|
||||
'</ol>\n' +
|
||||
'\n' +
|
||||
'<p>Pokud nějaký oddíl uživatelského rozhraní není přítomen, je přeskočen.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Pokud je zápatí vybrané pro navigaci pomocí klávesnice a není zobrazen žádný boční panel, stisknutím kláves <strong>Shift+Tab</strong>\n' +
|
||||
' přejdete na první skupinu panelu nástrojů, nikoli na poslední.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Navigace v rámci oddílů uživatelského rozhraní</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Chcete-li se přesunout z jednoho prvku uživatelského rozhraní na další, stiskněte příslušnou klávesu s <strong>šipkou</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Klávesy s šipkou <strong>vlevo</strong> a <strong>vpravo</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>umožňují přesun mezi nabídkami na řádku nabídek;</li>\n' +
|
||||
' <li>otevírají podnabídku nabídky;</li>\n' +
|
||||
' <li>umožňují přesun mezi tlačítky ve skupině panelu nástrojů;</li>\n' +
|
||||
' <li>umožňují přesun mezi položkami cesty prvku v zápatí.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p>Klávesy se šipkou <strong>dolů</strong> a <strong>nahoru</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>umožňují přesun mezi položkami nabídky;</li>\n' +
|
||||
' <li>umožňují přesun mezi položkami místní nabídky panelu nástrojů.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p><strong>Šipky</strong> provádí přepínání v rámci vybraného oddílu uživatelského rozhraní.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Chcete-li zavřít otevřenou nabídku, podnabídku nebo místní nabídku, stiskněte klávesu <strong>Esc</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Pokud je aktuálně vybrána horní část oddílu uživatelského rozhraní, stisknutím klávesy <strong>Esc</strong> zcela ukončíte také\n' +
|
||||
' navigaci pomocí klávesnice.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Provedení příkazu položky nabídky nebo tlačítka panelu nástrojů</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Pokud je zvýrazněna požadovaná položka nabídky nebo tlačítko panelu nástrojů, stisknutím klávesy <strong>Return</strong>, <strong>Enter</strong>\n' +
|
||||
' nebo <strong>mezerníku</strong> provedete příslušný příkaz.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Navigace v dialogových oknech bez záložek</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Při otevření dialogových oken bez záložek přejdete na první interaktivní komponentu.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Přecházet mezi interaktivními komponentami dialogového okna můžete stisknutím klávesy <strong>Tab</strong> nebo kombinace <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Navigace v dialogových oknech se záložkami</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Při otevření dialogových oken se záložkami přejdete na první tlačítko v nabídce záložek.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Přecházet mezi interaktivními komponentami této záložky dialogového okna můžete stisknutím klávesy <strong>Tab</strong> nebo\n' +
|
||||
' kombinace <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Chcete-li přepnout na další záložku dialogového okna, přejděte na nabídku záložek a poté můžete stisknutím požadované <strong>šipky</strong>\n' +
|
||||
' přepínat mezi dostupnými záložkami.</p>\n');
|
||||
@@ -1,93 +1,93 @@
|
||||
tinymce.Resource.add('tinymce.html-i18n.help-keynav.da',
|
||||
'<h1>Start tastaturnavigation</h1>\n' +
|
||||
'\n' +
|
||||
'<dl>\n' +
|
||||
' <dt>Fokuser på menulinjen</dt>\n' +
|
||||
' <dd>Windows eller Linux: Alt+F9</dd>\n' +
|
||||
' <dd>macOS: ⌥F9</dd>\n' +
|
||||
' <dt>Fokuser på værktøjslinjen</dt>\n' +
|
||||
' <dd>Windows eller Linux: Alt+F10</dd>\n' +
|
||||
' <dd>macOS: ⌥F10</dd>\n' +
|
||||
' <dt>Fokuser på sidefoden</dt>\n' +
|
||||
' <dd>Windows eller Linux: Alt+F11</dd>\n' +
|
||||
' <dd>macOS: ⌥F11</dd>\n' +
|
||||
' <dt>Fokuser på meddelelsen</dt>\n' +
|
||||
' <dd>Windows eller Linux: Alt+F12</dd>\n' +
|
||||
' <dd>macOS: ⌥F12</dd>\n' +
|
||||
' <dt>Fokuser på kontekstuel værktøjslinje</dt>\n' +
|
||||
' <dd>Windows, Linux eller macOS: Ctrl+F9</dd>\n' +
|
||||
'</dl>\n' +
|
||||
'\n' +
|
||||
'<p>Navigationen starter ved det første UI-element, som fremhæves eller understreges hvad angår det første element i\n' +
|
||||
' sidefodens sti til elementet.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Naviger mellem UI-sektioner</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Gå fra én UI-sektion til den næste ved at trykke på <strong>Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Gå fra én UI-sektion til den forrige ved at trykke på <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p><strong>Tab</strong>-rækkefølgen af disse UI-sektioner er:</p>\n' +
|
||||
'\n' +
|
||||
'<ol>\n' +
|
||||
' <li>Menulinje</li>\n' +
|
||||
' <li>Hver værktøjsgruppe</li>\n' +
|
||||
' <li>Sidepanel</li>\n' +
|
||||
' <li>Sti til elementet i sidefoden</li>\n' +
|
||||
' <li>Til/fra-knap for ordoptælling i sidefoden</li>\n' +
|
||||
' <li>Brandinglink i sidefoden</li>\n' +
|
||||
' <li>Tilpasningshåndtag for editor i sidefoden</li>\n' +
|
||||
'</ol>\n' +
|
||||
'\n' +
|
||||
'<p>Hvis en UI-sektion ikke er til stede, springes den over.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Hvis sidefoden har fokus til tastaturnavigation, og der ikke er noget synligt sidepanel, kan der trykkes på <strong>Shift+Tab</strong>\n' +
|
||||
' for at flytte fokus til den første værktøjsgruppe, ikke den sidste.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Naviger inden for UI-sektioner</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Gå fra ét UI-element til det næste ved at trykke på den relevante <strong>piletast</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p><strong>Venstre</strong> og <strong>højre</strong> piletast</p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>flytter mellem menuerne i menulinjen.</li>\n' +
|
||||
' <li>åbner en undermenu i en menu.</li>\n' +
|
||||
' <li>flytter mellem knapperne i en værktøjsgruppe.</li>\n' +
|
||||
' <li>flytter mellem elementer i sidefodens sti til elementet.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p>Pil <strong>ned</strong> og <strong>op</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>flytter mellem menupunkterne i en menu.</li>\n' +
|
||||
' <li>flytter mellem punkterne i en genvejsmenu i værktøjslinjen.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p><strong>Piletasterne</strong> kører rundt inden for UI-sektionen, der fokuseres på.</p>\n' +
|
||||
'\n' +
|
||||
'<p>For at lukke en åben menu, en åben undermenu eller en åben genvejsmenu trykkes der på <strong>Esc</strong>-tasten.</p>\n' +
|
||||
'\n' +
|
||||
"<p>Hvis det aktuelle fokus er i 'toppen' af en bestemt UI-sektion, vil tryk på <strong>Esc</strong>-tasten også afslutte\n" +
|
||||
' tastaturnavigationen helt.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Udfør et menupunkt eller en værktøjslinjeknap</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Når det ønskede menupunkt eller den ønskede værktøjslinjeknap er fremhævet, trykkes der på <strong>Retur</strong>, <strong>Enter</strong>\n' +
|
||||
' eller <strong>mellemrumstasten</strong> for at udføre elementet.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Naviger i ikke-faneopdelte dialogbokse</h1>\n' +
|
||||
'\n' +
|
||||
'<p>I ikke-faneopdelte dialogbokse får den første interaktive komponent fokus, når dialogboksen åbnes.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Naviger mellem interaktive dialogbokskomponenter ved at trykke på <strong>Tab</strong> eller <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Naviger i faneopdelte dialogbokse</h1>\n' +
|
||||
'\n' +
|
||||
'<p>I faneopdelte dialogbokse får den første knap i fanemenuen fokus, når dialogboksen åbnes.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Naviger mellem interaktive komponenter i denne dialogboksfane ved at trykke på <strong>Tab</strong> eller\n' +
|
||||
' <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Skift til en anden dialogboksfane ved at fokusere på fanemenuen og derefter trykke på den relevante <strong>piletast</strong>\n' +
|
||||
' for at køre igennem de tilgængelige faner.</p>\n');
|
||||
'<h1>Start tastaturnavigation</h1>\n' +
|
||||
'\n' +
|
||||
'<dl>\n' +
|
||||
' <dt>Fokuser på menulinjen</dt>\n' +
|
||||
' <dd>Windows eller Linux: Alt+F9</dd>\n' +
|
||||
' <dd>macOS: ⌥F9</dd>\n' +
|
||||
' <dt>Fokuser på værktøjslinjen</dt>\n' +
|
||||
' <dd>Windows eller Linux: Alt+F10</dd>\n' +
|
||||
' <dd>macOS: ⌥F10</dd>\n' +
|
||||
' <dt>Fokuser på sidefoden</dt>\n' +
|
||||
' <dd>Windows eller Linux: Alt+F11</dd>\n' +
|
||||
' <dd>macOS: ⌥F11</dd>\n' +
|
||||
' <dt>Fokuser på meddelelsen</dt>\n' +
|
||||
' <dd>Windows eller Linux: Alt+F12</dd>\n' +
|
||||
' <dd>macOS: ⌥F12</dd>\n' +
|
||||
' <dt>Fokuser på kontekstuel værktøjslinje</dt>\n' +
|
||||
' <dd>Windows, Linux eller macOS: Ctrl+F9</dd>\n' +
|
||||
'</dl>\n' +
|
||||
'\n' +
|
||||
'<p>Navigationen starter ved det første UI-element, som fremhæves eller understreges hvad angår det første element i\n' +
|
||||
' sidefodens sti til elementet.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Naviger mellem UI-sektioner</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Gå fra én UI-sektion til den næste ved at trykke på <strong>Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Gå fra én UI-sektion til den forrige ved at trykke på <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p><strong>Tab</strong>-rækkefølgen af disse UI-sektioner er:</p>\n' +
|
||||
'\n' +
|
||||
'<ol>\n' +
|
||||
' <li>Menulinje</li>\n' +
|
||||
' <li>Hver værktøjsgruppe</li>\n' +
|
||||
' <li>Sidepanel</li>\n' +
|
||||
' <li>Sti til elementet i sidefoden</li>\n' +
|
||||
' <li>Til/fra-knap for ordoptælling i sidefoden</li>\n' +
|
||||
' <li>Brandinglink i sidefoden</li>\n' +
|
||||
' <li>Tilpasningshåndtag for editor i sidefoden</li>\n' +
|
||||
'</ol>\n' +
|
||||
'\n' +
|
||||
'<p>Hvis en UI-sektion ikke er til stede, springes den over.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Hvis sidefoden har fokus til tastaturnavigation, og der ikke er noget synligt sidepanel, kan der trykkes på <strong>Shift+Tab</strong>\n' +
|
||||
' for at flytte fokus til den første værktøjsgruppe, ikke den sidste.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Naviger inden for UI-sektioner</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Gå fra ét UI-element til det næste ved at trykke på den relevante <strong>piletast</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p><strong>Venstre</strong> og <strong>højre</strong> piletast</p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>flytter mellem menuerne i menulinjen.</li>\n' +
|
||||
' <li>åbner en undermenu i en menu.</li>\n' +
|
||||
' <li>flytter mellem knapperne i en værktøjsgruppe.</li>\n' +
|
||||
' <li>flytter mellem elementer i sidefodens sti til elementet.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p>Pil <strong>ned</strong> og <strong>op</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>flytter mellem menupunkterne i en menu.</li>\n' +
|
||||
' <li>flytter mellem punkterne i en genvejsmenu i værktøjslinjen.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p><strong>Piletasterne</strong> kører rundt inden for UI-sektionen, der fokuseres på.</p>\n' +
|
||||
'\n' +
|
||||
'<p>For at lukke en åben menu, en åben undermenu eller en åben genvejsmenu trykkes der på <strong>Esc</strong>-tasten.</p>\n' +
|
||||
'\n' +
|
||||
"<p>Hvis det aktuelle fokus er i 'toppen' af en bestemt UI-sektion, vil tryk på <strong>Esc</strong>-tasten også afslutte\n" +
|
||||
' tastaturnavigationen helt.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Udfør et menupunkt eller en værktøjslinjeknap</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Når det ønskede menupunkt eller den ønskede værktøjslinjeknap er fremhævet, trykkes der på <strong>Retur</strong>, <strong>Enter</strong>\n' +
|
||||
' eller <strong>mellemrumstasten</strong> for at udføre elementet.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Naviger i ikke-faneopdelte dialogbokse</h1>\n' +
|
||||
'\n' +
|
||||
'<p>I ikke-faneopdelte dialogbokse får den første interaktive komponent fokus, når dialogboksen åbnes.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Naviger mellem interaktive dialogbokskomponenter ved at trykke på <strong>Tab</strong> eller <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Naviger i faneopdelte dialogbokse</h1>\n' +
|
||||
'\n' +
|
||||
'<p>I faneopdelte dialogbokse får den første knap i fanemenuen fokus, når dialogboksen åbnes.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Naviger mellem interaktive komponenter i denne dialogboksfane ved at trykke på <strong>Tab</strong> eller\n' +
|
||||
' <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Skift til en anden dialogboksfane ved at fokusere på fanemenuen og derefter trykke på den relevante <strong>piletast</strong>\n' +
|
||||
' for at køre igennem de tilgængelige faner.</p>\n');
|
||||
@@ -1,93 +1,93 @@
|
||||
tinymce.Resource.add('tinymce.html-i18n.help-keynav.de',
|
||||
'<h1>Grundlagen der Tastaturnavigation</h1>\n' +
|
||||
'\n' +
|
||||
'<dl>\n' +
|
||||
' <dt>Fokus auf Menüleiste</dt>\n' +
|
||||
' <dd>Windows oder Linux: ALT+F9</dd>\n' +
|
||||
' <dd>macOS: ⌥F9</dd>\n' +
|
||||
' <dt>Fokus auf Symbolleiste</dt>\n' +
|
||||
' <dd>Windows oder Linux: ALT+F10</dd>\n' +
|
||||
' <dd>macOS: ⌥F10</dd>\n' +
|
||||
' <dt>Fokus auf Fußzeile</dt>\n' +
|
||||
' <dd>Windows oder Linux: ALT+F11</dd>\n' +
|
||||
' <dd>macOS: ⌥F11</dd>\n' +
|
||||
' <dt>Benachrichtigung fokussieren</dt>\n' +
|
||||
' <dd>Windows oder Linux: ALT+F12</dd>\n' +
|
||||
' <dd>macOS: ⌥F12</dd>\n' +
|
||||
' <dt>Fokus auf kontextbezogene Symbolleiste</dt>\n' +
|
||||
' <dd>Windows, Linux oder macOS: STRG+F9</dd>\n' +
|
||||
'</dl>\n' +
|
||||
'\n' +
|
||||
'<p>Die Navigation beginnt beim ersten Benutzeroberflächenelement, welches hervorgehoben ist. Falls sich das erste Element im Pfad der Fußzeile befindet,\n' +
|
||||
' ist es unterstrichen.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Zwischen Abschnitten der Benutzeroberfläche navigieren</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Um von einem Abschnitt der Benutzeroberfläche zum nächsten zu wechseln, drücken Sie <strong>TAB</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Um von einem Abschnitt der Benutzeroberfläche zum vorherigen zu wechseln, drücken Sie <strong>UMSCHALT+TAB</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Die Abschnitte der Benutzeroberfläche haben folgende <strong>TAB</strong>-Reihenfolge:</p>\n' +
|
||||
'\n' +
|
||||
'<ol>\n' +
|
||||
' <li>Menüleiste</li>\n' +
|
||||
' <li>Einzelne Gruppen der Symbolleiste</li>\n' +
|
||||
' <li>Randleiste</li>\n' +
|
||||
' <li>Elementpfad in der Fußzeile</li>\n' +
|
||||
' <li>Umschaltfläche „Wörter zählen“ in der Fußzeile</li>\n' +
|
||||
' <li>Branding-Link in der Fußzeile</li>\n' +
|
||||
' <li>Editor-Ziehpunkt zur Größenänderung in der Fußzeile</li>\n' +
|
||||
'</ol>\n' +
|
||||
'\n' +
|
||||
'<p>Falls ein Abschnitt der Benutzeroberflächen nicht vorhanden ist, wird er übersprungen.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Wenn in der Fußzeile die Tastaturnavigation fokussiert ist und keine Randleiste angezeigt wird, wechselt der Fokus durch Drücken von <strong>UMSCHALT+TAB</strong>\n' +
|
||||
' zur ersten Gruppe der Symbolleiste, nicht zur letzten.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Innerhalb von Abschnitten der Benutzeroberfläche navigieren</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Um von einem Element der Benutzeroberfläche zum nächsten zu wechseln, drücken Sie die entsprechende <strong>Pfeiltaste</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Die Pfeiltasten <strong>Links</strong> und <strong>Rechts</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>wechseln zwischen Menüs in der Menüleiste.</li>\n' +
|
||||
' <li>öffnen das Untermenü eines Menüs.</li>\n' +
|
||||
' <li>wechseln zwischen Schaltflächen in einer Gruppe der Symbolleiste.</li>\n' +
|
||||
' <li>wechseln zwischen Elementen im Elementpfad der Fußzeile.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p>Die Pfeiltasten <strong>Abwärts</strong> und <strong>Aufwärts</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>wechseln zwischen Menüelementen in einem Menü.</li>\n' +
|
||||
' <li>wechseln zwischen Elementen in einem Popupmenü der Symbolleiste.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p>Die <strong>Pfeiltasten</strong> rotieren innerhalb des fokussierten Abschnitts der Benutzeroberfläche.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Um ein geöffnetes Menü, ein geöffnetes Untermenü oder ein geöffnetes Popupmenü zu schließen, drücken Sie die <strong>ESC</strong>-Taste.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Wenn sich der aktuelle Fokus ganz oben in einem bestimmten Abschnitt der Benutzeroberfläche befindet, wird durch Drücken der <strong>ESC</strong>-Taste auch\n' +
|
||||
' die Tastaturnavigation beendet.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Ein Menüelement oder eine Symbolleistenschaltfläche ausführen</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Wenn das gewünschte Menüelement oder die gewünschte Symbolleistenschaltfläche hervorgehoben ist, drücken Sie <strong>Zurück</strong>, <strong>Eingabe</strong>\n' +
|
||||
' oder die <strong>Leertaste</strong>, um das Element auszuführen.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>In Dialogfeldern ohne Registerkarten navigieren</h1>\n' +
|
||||
'\n' +
|
||||
'<p>In Dialogfeldern ohne Registerkarten ist beim Öffnen eines Dialogfelds die erste interaktive Komponente fokussiert.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Navigieren Sie zwischen den interaktiven Komponenten eines Dialogfelds, indem Sie <strong>TAB</strong> oder <strong>UMSCHALT+TAB</strong> drücken.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>In Dialogfeldern mit Registerkarten navigieren</h1>\n' +
|
||||
'\n' +
|
||||
'<p>In Dialogfeldern mit Registerkarten ist beim Öffnen eines Dialogfelds die erste Schaltfläche eines Registerkartenmenüs fokussiert.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Navigieren Sie zwischen den interaktiven Komponenten auf dieser Registerkarte des Dialogfelds, indem Sie <strong>TAB</strong> oder\n' +
|
||||
' <strong>UMSCHALT+TAB</strong> drücken.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Wechseln Sie zu einer anderen Registerkarte des Dialogfelds, indem Sie den Fokus auf das Registerkartenmenü legen und dann die entsprechende <strong>Pfeiltaste</strong>\n' +
|
||||
' drücken, um durch die verfügbaren Registerkarten zu rotieren.</p>\n');
|
||||
'<h1>Grundlagen der Tastaturnavigation</h1>\n' +
|
||||
'\n' +
|
||||
'<dl>\n' +
|
||||
' <dt>Fokus auf Menüleiste</dt>\n' +
|
||||
' <dd>Windows oder Linux: ALT+F9</dd>\n' +
|
||||
' <dd>macOS: ⌥F9</dd>\n' +
|
||||
' <dt>Fokus auf Symbolleiste</dt>\n' +
|
||||
' <dd>Windows oder Linux: ALT+F10</dd>\n' +
|
||||
' <dd>macOS: ⌥F10</dd>\n' +
|
||||
' <dt>Fokus auf Fußzeile</dt>\n' +
|
||||
' <dd>Windows oder Linux: ALT+F11</dd>\n' +
|
||||
' <dd>macOS: ⌥F11</dd>\n' +
|
||||
' <dt>Benachrichtigung fokussieren</dt>\n' +
|
||||
' <dd>Windows oder Linux: ALT+F12</dd>\n' +
|
||||
' <dd>macOS: ⌥F12</dd>\n' +
|
||||
' <dt>Fokus auf kontextbezogene Symbolleiste</dt>\n' +
|
||||
' <dd>Windows, Linux oder macOS: STRG+F9</dd>\n' +
|
||||
'</dl>\n' +
|
||||
'\n' +
|
||||
'<p>Die Navigation beginnt beim ersten Benutzeroberflächenelement, welches hervorgehoben ist. Falls sich das erste Element im Pfad der Fußzeile befindet,\n' +
|
||||
' ist es unterstrichen.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Zwischen Abschnitten der Benutzeroberfläche navigieren</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Um von einem Abschnitt der Benutzeroberfläche zum nächsten zu wechseln, drücken Sie <strong>TAB</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Um von einem Abschnitt der Benutzeroberfläche zum vorherigen zu wechseln, drücken Sie <strong>UMSCHALT+TAB</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Die Abschnitte der Benutzeroberfläche haben folgende <strong>TAB</strong>-Reihenfolge:</p>\n' +
|
||||
'\n' +
|
||||
'<ol>\n' +
|
||||
' <li>Menüleiste</li>\n' +
|
||||
' <li>Einzelne Gruppen der Symbolleiste</li>\n' +
|
||||
' <li>Randleiste</li>\n' +
|
||||
' <li>Elementpfad in der Fußzeile</li>\n' +
|
||||
' <li>Umschaltfläche „Wörter zählen“ in der Fußzeile</li>\n' +
|
||||
' <li>Branding-Link in der Fußzeile</li>\n' +
|
||||
' <li>Editor-Ziehpunkt zur Größenänderung in der Fußzeile</li>\n' +
|
||||
'</ol>\n' +
|
||||
'\n' +
|
||||
'<p>Falls ein Abschnitt der Benutzeroberflächen nicht vorhanden ist, wird er übersprungen.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Wenn in der Fußzeile die Tastaturnavigation fokussiert ist und keine Randleiste angezeigt wird, wechselt der Fokus durch Drücken von <strong>UMSCHALT+TAB</strong>\n' +
|
||||
' zur ersten Gruppe der Symbolleiste, nicht zur letzten.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Innerhalb von Abschnitten der Benutzeroberfläche navigieren</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Um von einem Element der Benutzeroberfläche zum nächsten zu wechseln, drücken Sie die entsprechende <strong>Pfeiltaste</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Die Pfeiltasten <strong>Links</strong> und <strong>Rechts</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>wechseln zwischen Menüs in der Menüleiste.</li>\n' +
|
||||
' <li>öffnen das Untermenü eines Menüs.</li>\n' +
|
||||
' <li>wechseln zwischen Schaltflächen in einer Gruppe der Symbolleiste.</li>\n' +
|
||||
' <li>wechseln zwischen Elementen im Elementpfad der Fußzeile.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p>Die Pfeiltasten <strong>Abwärts</strong> und <strong>Aufwärts</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>wechseln zwischen Menüelementen in einem Menü.</li>\n' +
|
||||
' <li>wechseln zwischen Elementen in einem Popupmenü der Symbolleiste.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p>Die <strong>Pfeiltasten</strong> rotieren innerhalb des fokussierten Abschnitts der Benutzeroberfläche.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Um ein geöffnetes Menü, ein geöffnetes Untermenü oder ein geöffnetes Popupmenü zu schließen, drücken Sie die <strong>ESC</strong>-Taste.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Wenn sich der aktuelle Fokus ganz oben in einem bestimmten Abschnitt der Benutzeroberfläche befindet, wird durch Drücken der <strong>ESC</strong>-Taste auch\n' +
|
||||
' die Tastaturnavigation beendet.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Ein Menüelement oder eine Symbolleistenschaltfläche ausführen</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Wenn das gewünschte Menüelement oder die gewünschte Symbolleistenschaltfläche hervorgehoben ist, drücken Sie <strong>Zurück</strong>, <strong>Eingabe</strong>\n' +
|
||||
' oder die <strong>Leertaste</strong>, um das Element auszuführen.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>In Dialogfeldern ohne Registerkarten navigieren</h1>\n' +
|
||||
'\n' +
|
||||
'<p>In Dialogfeldern ohne Registerkarten ist beim Öffnen eines Dialogfelds die erste interaktive Komponente fokussiert.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Navigieren Sie zwischen den interaktiven Komponenten eines Dialogfelds, indem Sie <strong>TAB</strong> oder <strong>UMSCHALT+TAB</strong> drücken.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>In Dialogfeldern mit Registerkarten navigieren</h1>\n' +
|
||||
'\n' +
|
||||
'<p>In Dialogfeldern mit Registerkarten ist beim Öffnen eines Dialogfelds die erste Schaltfläche eines Registerkartenmenüs fokussiert.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Navigieren Sie zwischen den interaktiven Komponenten auf dieser Registerkarte des Dialogfelds, indem Sie <strong>TAB</strong> oder\n' +
|
||||
' <strong>UMSCHALT+TAB</strong> drücken.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Wechseln Sie zu einer anderen Registerkarte des Dialogfelds, indem Sie den Fokus auf das Registerkartenmenü legen und dann die entsprechende <strong>Pfeiltaste</strong>\n' +
|
||||
' drücken, um durch die verfügbaren Registerkarten zu rotieren.</p>\n');
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user