Inital commit
This commit is contained in:
+662
@@ -0,0 +1,662 @@
|
|||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
our General Public Licenses are 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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Developers that use our General Public Licenses protect your rights
|
||||||
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
|
you this License which gives you legal permission to copy, distribute
|
||||||
|
and/or modify the software.
|
||||||
|
|
||||||
|
A secondary benefit of defending all users' freedom is that
|
||||||
|
improvements made in alternate versions of the program, if they
|
||||||
|
receive widespread use, become available for other developers to
|
||||||
|
incorporate. Many developers of free software are heartened and
|
||||||
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
|
The GNU Affero General Public License is designed specifically to
|
||||||
|
ensure that, in such cases, the modified source code becomes available
|
||||||
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
|
An older license, called the Affero General Public License and
|
||||||
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
|
|
||||||
|
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 Affero 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. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
|
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 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 work with which it is combined will remain governed by version
|
||||||
|
3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU Affero 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 Affero 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 Affero 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 Affero General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
|
||||||
|
Copyright (C) 2026 Terncode
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero 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 Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer
|
||||||
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
|
interface could display a "Source" link that leads users to an archive
|
||||||
|
of the code. There are many ways you could offer source, and different
|
||||||
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
|
|
||||||
|
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 AGPL, see
|
||||||
|
<http://www.gnu.org/licenses/>.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Vectrace
|
||||||
|
|
||||||
|
Vectrace is a lightweight tool for converting raster images into clean vector paths. It focuses on extracting outlines and shapes that can be directly used for laser cutting, CNC workflows, and vector-based design.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
* Image outlining and shape detection
|
||||||
|
* Raster to vector conversion
|
||||||
|
* Export to SVG
|
||||||
|
* Export to PDF
|
||||||
|
* Batch export as ZIP
|
||||||
|
* Clean path generation optimized for cutting workflows
|
||||||
|
|
||||||
|
## Use Cases
|
||||||
|
|
||||||
|
* Laser cutting preparation
|
||||||
|
* CNC path generation
|
||||||
|
* Converting sketches to vector formats
|
||||||
|
* Simplifying images into scalable outlines
|
||||||
|
|
||||||
|
## Supported Formats
|
||||||
|
|
||||||
|
**Input**
|
||||||
|
|
||||||
|
* PNG
|
||||||
|
* JPG
|
||||||
|
* BMP
|
||||||
|
|
||||||
|
**Output**
|
||||||
|
|
||||||
|
* SVG
|
||||||
|
* PDF
|
||||||
|
* ZIP (batch export)
|
||||||
|
|
||||||
|
Vectrace is built to be fast, predictable, and practical. It avoids unnecessary complexity and focuses on producing usable vector paths with minimal cleanup.
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
* GUI interface
|
||||||
|
* Advanced path smoothing
|
||||||
|
* Direct laser cutter integration
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
GPL License
|
||||||
|
|
||||||
|
## Author
|
||||||
|
|
||||||
|
Terncode
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import globals from "globals";
|
||||||
|
import pluginJs from "@eslint/js";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
import pluginReact from "eslint-plugin-react";
|
||||||
|
import stylistic from "@stylistic/eslint-plugin";
|
||||||
|
import styledComponentsA11y from "eslint-plugin-styled-components-a11y";
|
||||||
|
|
||||||
|
/** @type {import('eslint').Linter.Config[]} */
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.browser,
|
||||||
|
...globals.node
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
pluginJs.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
pluginReact.configs.flat.recommended,
|
||||||
|
{
|
||||||
|
settings: {
|
||||||
|
react: {
|
||||||
|
version: "detect"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
plugins: {
|
||||||
|
"@stylistic": stylistic,
|
||||||
|
"styled-components-a11y": styledComponentsA11y
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"react/react-in-jsx-scope": "off",
|
||||||
|
"no-trailing-spaces": "warn",
|
||||||
|
"semi": ["warn", "always"],
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
"@typescript-eslint/ban-ts-comment": "off",
|
||||||
|
"@typescript-eslint/no-misused-new": "off",
|
||||||
|
"@stylistic/linebreak-style": ["error", "unix"],
|
||||||
|
"camelcase": "warn",
|
||||||
|
"max-len": ["warn", { "code": 150 }],
|
||||||
|
"radix": [
|
||||||
|
"error",
|
||||||
|
],
|
||||||
|
"quotes": [
|
||||||
|
"warn",
|
||||||
|
"double",
|
||||||
|
{ allowTemplateLiterals: true, avoidEscape: true },
|
||||||
|
],
|
||||||
|
"indent": ["warn", 4,
|
||||||
|
{
|
||||||
|
"SwitchCase": 1,
|
||||||
|
"VariableDeclarator": 1,
|
||||||
|
"outerIIFEBody": 1,
|
||||||
|
"MemberExpression": 1,
|
||||||
|
"FunctionDeclaration": { "parameters": "first", "body": 1 },
|
||||||
|
"FunctionExpression": { "parameters": "first", "body": 1 }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"args": "all",
|
||||||
|
"argsIgnorePattern": "^_",
|
||||||
|
"caughtErrors": "all",
|
||||||
|
"caughtErrorsIgnorePattern": "^_",
|
||||||
|
"destructuredArrayIgnorePattern": "^_",
|
||||||
|
"varsIgnorePattern": "^_",
|
||||||
|
"ignoreRestSiblings": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/icons/favicon.ico" />
|
||||||
|
<link rel="manifest" href="/webmanifest.json">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Vectrace</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+5589
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
|||||||
|
{
|
||||||
|
"name": "vectrace",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": {
|
||||||
|
"name": "Terncode",
|
||||||
|
"email": "[email protected]",
|
||||||
|
"url": "https://terncode.dev"
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"lint-fix": "eslint --fix \"src/**/*.{js,mjs,cjs,ts,jsx,tsx}\"",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@stylistic/eslint-plugin": "^5.10.0",
|
||||||
|
"@types/lodash": "^4.17.24",
|
||||||
|
"eslint-plugin-react": "^7.37.5",
|
||||||
|
"fast-xml-builder": "^1.1.5",
|
||||||
|
"fast-xml-parser": "^5.7.1",
|
||||||
|
"imagetracerjs": "^1.2.6",
|
||||||
|
"jspdf": "^4.2.1",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
|
"ldrs": "^1.1.9",
|
||||||
|
"localforage": "^1.10.0",
|
||||||
|
"lodash": "^4.18.1",
|
||||||
|
"pdf-lib": "^1.17.1",
|
||||||
|
"polygon-clipping": "^0.15.7",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
|
"react-icons": "^5.6.0",
|
||||||
|
"styled-components": "^6.4.0",
|
||||||
|
"svg.js": "^2.7.1",
|
||||||
|
"svgson": "^5.3.1",
|
||||||
|
"uuid": "^14.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.4",
|
||||||
|
"@types/node": "^24.12.2",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"eslint": "^9.39.4",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
"eslint-plugin-styled-components-a11y": "^2.2.1",
|
||||||
|
"globals": "^17.4.0",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"typescript-eslint": "^8.58.0",
|
||||||
|
"vite": "^8.0.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 7.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 657 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1 @@
|
|||||||
|
{"name":"Vectrace","short_name":"Vectrace","icons":[{"src":"/icons/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/icons/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { VectraceHandler } from "./handler/vectrace-handler";
|
||||||
|
import { NavBar } from "./nav-bar";
|
||||||
|
import { RenderPage } from "./render-page";
|
||||||
|
import { DragDrop } from "./components/drag-drop";
|
||||||
|
import { PopupRenderer } from "./components/popup";
|
||||||
|
import { Grid } from "ldrs/react";
|
||||||
|
import "ldrs/react/Grid.css";
|
||||||
|
import styled from "styled-components";
|
||||||
|
|
||||||
|
const LoadingWrapper = styled.div`
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
`;
|
||||||
|
const WrapperInner = styled.div`
|
||||||
|
display: flex;
|
||||||
|
margin: 20px auto;
|
||||||
|
flex-direction: column;
|
||||||
|
width: fit-content;
|
||||||
|
font-size: 20pt;
|
||||||
|
`;
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const handler = useMemo(() => new VectraceHandler(), []);
|
||||||
|
const [ready, setReady] = useState(handler.ready);
|
||||||
|
useEffect(() => {
|
||||||
|
handler.init("image_store");
|
||||||
|
const unsub = handler.emitter.on("ready", () => setReady(true));
|
||||||
|
const onSave = (e: KeyboardEvent) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
handler.exportProject();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("keydown", onSave, true);
|
||||||
|
return () => {
|
||||||
|
unsub();
|
||||||
|
document.removeEventListener("keydown", onSave);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!ready) {
|
||||||
|
return <LoadingWrapper>
|
||||||
|
<WrapperInner>
|
||||||
|
<Grid
|
||||||
|
size="60"
|
||||||
|
speed="1.5"
|
||||||
|
|
||||||
|
color="white"
|
||||||
|
/>
|
||||||
|
<span>Loading</span>
|
||||||
|
</WrapperInner>
|
||||||
|
</LoadingWrapper>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PopupRenderer popup={handler.popup} />
|
||||||
|
<DragDrop handler={handler} />
|
||||||
|
<NavBar handler={handler} />
|
||||||
|
<RenderPage handler={handler} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
Binary file not shown.
Binary file not shown.
+155
@@ -0,0 +1,155 @@
|
|||||||
|
import { last } from "lodash";
|
||||||
|
|
||||||
|
function isNumber(char: string) {
|
||||||
|
const number = parseInt(char, 10);
|
||||||
|
return !isNaN(number);
|
||||||
|
}
|
||||||
|
|
||||||
|
const priorities = new Map<string, number>();
|
||||||
|
priorities.set("(", 4);
|
||||||
|
priorities.set(")", 4);
|
||||||
|
priorities.set("^", 3);
|
||||||
|
priorities.set("*", 2);
|
||||||
|
priorities.set("/", 2);
|
||||||
|
priorities.set("+", 1);
|
||||||
|
priorities.set("-", 1);
|
||||||
|
|
||||||
|
function getPriority(char: string) {
|
||||||
|
return priorities.get(char) || -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterOutNumbers(expression: string) {
|
||||||
|
const buffer: string[] = [];
|
||||||
|
const lastReadNumberQueue: string[] = [];
|
||||||
|
|
||||||
|
const updateNumbers = () => {
|
||||||
|
const number = lastReadNumberQueue.join("");
|
||||||
|
lastReadNumberQueue.length = 0;
|
||||||
|
if (number) {
|
||||||
|
if (!isNaN(parseFloat(number))) {
|
||||||
|
buffer.push(number);
|
||||||
|
} else {
|
||||||
|
throw new Error("malformed number");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < expression.length; i++) {
|
||||||
|
const char = expression[i];
|
||||||
|
if (isNumber(char) || char === "." || char === "," || (i === 0 && char === "-") ||
|
||||||
|
(priorities.has(last(buffer) || "") && char === "-")) {
|
||||||
|
lastReadNumberQueue.push(char);
|
||||||
|
} else {
|
||||||
|
updateNumbers();
|
||||||
|
buffer.push(char);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateNumbers();
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function processExpression(expression: string) {
|
||||||
|
const preExpression = filterOutNumbers(expression);
|
||||||
|
const postExpression: string[] = [];
|
||||||
|
const stack: string[] = [];
|
||||||
|
while (preExpression.length) {
|
||||||
|
const read = preExpression.shift()!;
|
||||||
|
if (read !== ")") {
|
||||||
|
if (isNumber(read)) {
|
||||||
|
postExpression.push(read);
|
||||||
|
}
|
||||||
|
else if (read === "(") {
|
||||||
|
stack.push(read);
|
||||||
|
} else {
|
||||||
|
while (stack.length && getPriority(read) <= getPriority(last(stack)!) &&
|
||||||
|
last(stack) !== "(") {
|
||||||
|
const op = last(stack)!;
|
||||||
|
stack.pop();
|
||||||
|
postExpression.push(op);
|
||||||
|
}
|
||||||
|
stack.push(read);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let op = last(stack)!;
|
||||||
|
stack.pop();
|
||||||
|
while (op && op !== "(") {
|
||||||
|
postExpression.push(op);
|
||||||
|
op = last(stack)!;
|
||||||
|
stack.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (stack.length) {
|
||||||
|
const op = last(stack)!;
|
||||||
|
stack.pop();
|
||||||
|
postExpression.push(op);
|
||||||
|
}
|
||||||
|
return postExpression;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResult(postExpressionQueue: string[]) {
|
||||||
|
const stackCalc: number[] = [];
|
||||||
|
while (postExpressionQueue.length) {
|
||||||
|
const read = postExpressionQueue.shift()!;
|
||||||
|
if (isNumber(read)) {
|
||||||
|
const n = parseFloat(read);
|
||||||
|
stackCalc.push(n);
|
||||||
|
} else {
|
||||||
|
const second = stackCalc.pop()!;
|
||||||
|
const first = stackCalc.pop()!;
|
||||||
|
let result = 0;
|
||||||
|
|
||||||
|
switch (read) {
|
||||||
|
case "+":
|
||||||
|
result = first + second;
|
||||||
|
break;
|
||||||
|
case "-":
|
||||||
|
result = first - second;
|
||||||
|
break;
|
||||||
|
case "*":
|
||||||
|
result = first * second;
|
||||||
|
break;
|
||||||
|
case "/":
|
||||||
|
result = first / second;
|
||||||
|
break;
|
||||||
|
case "^":
|
||||||
|
result = Math.pow(first, second);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
stackCalc.push(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (stackCalc.length === 1) {
|
||||||
|
return stackCalc[0];
|
||||||
|
}
|
||||||
|
throw new Error("Failed to calculate");
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateNumber(number: number) {
|
||||||
|
if (isNaN(number)) {
|
||||||
|
throw new Error("Not a number");
|
||||||
|
}
|
||||||
|
return number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fixStringForCalculation(expression: string) {
|
||||||
|
return expression.replace(/[^0-9()^*/+-.,]/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canEvaluateMathExpression(expression: string) {
|
||||||
|
const res = /[()^*/+-]/g.test(expression.substring(1));
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateMathExpression(expression: string) {
|
||||||
|
expression = fixStringForCalculation(expression);
|
||||||
|
|
||||||
|
if (/[()^*/+-]/g.test(expression)) {
|
||||||
|
const postFixed = processExpression(expression);
|
||||||
|
return validateNumber(getResult(postFixed));
|
||||||
|
} else {
|
||||||
|
return validateNumber(parseFloat(expression));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import { useRef, useEffect, useState } from "react";
|
||||||
|
import styled from "styled-components";
|
||||||
|
import { useSettings } from "../use/use-settings";
|
||||||
|
import type { CanvasHandlerProps } from "../handler/interfaces";
|
||||||
|
import { TOOL_OBJECT_MOVE, TOOL_CANVAS_MOVE, TOOL_SELECT } from "../handler/tools";
|
||||||
|
import { clamp } from "lodash";
|
||||||
|
import { PaperView } from "./image-tools/paper-view";
|
||||||
|
|
||||||
|
const ZOOM_SENSITIVITY = 1000;
|
||||||
|
|
||||||
|
const Container = styled.div<{ $cursor?: string }>`
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
background-color: gray;
|
||||||
|
|
||||||
|
cursor: ${({ $cursor }) => $cursor || ""};
|
||||||
|
|
||||||
|
image-rendering: -webkit-optimize-contrast;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
perspective: 1000;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const CanvasWrapper = styled.div`
|
||||||
|
position: absolute;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
will-change: transform;
|
||||||
|
box-shadow: 20px 20px 20px black;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default function A4Canvas({ handler }: CanvasHandlerProps) {
|
||||||
|
const { settings, setSettings } = useSettings(handler);
|
||||||
|
const [cursor, setCursor] = useState(handler.toolHandler.tool === TOOL_CANVAS_MOVE.key ? "grab" : "");
|
||||||
|
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const panRef = useRef({ offsetX: 0, offsetY: 0 });
|
||||||
|
const isDragging = useRef(false);
|
||||||
|
const dragStart = useRef({ x: 0, y: 0 });
|
||||||
|
const hasMoved = useRef(false);
|
||||||
|
|
||||||
|
const applyTransform = () => {
|
||||||
|
const wrapper = wrapperRef.current;
|
||||||
|
if (!wrapper) return;
|
||||||
|
const { offsetX, offsetY } = panRef.current;
|
||||||
|
wrapper.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${settings.scale})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
panRef.current.offsetX = (container.offsetWidth - (settings.landscape ? settings.paperHeight : settings.paperWidth) * settings.scale) / 2;
|
||||||
|
panRef.current.offsetY = (container.offsetHeight - (settings.landscape ? settings.paperWidth : settings.paperHeight) * settings.scale) / 2;
|
||||||
|
applyTransform();
|
||||||
|
|
||||||
|
const unsub = handler.toolHandler.emitter.on("select", (tool) => {
|
||||||
|
setCursor(tool === TOOL_CANVAS_MOVE.key ? "grab" : "");
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
unsub();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applyTransform();
|
||||||
|
}, [settings.scale]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const handleWheel = (e: WheelEvent) => {
|
||||||
|
if (!container) return;
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const delta = (1 - e.deltaY) / ZOOM_SENSITIVITY;
|
||||||
|
|
||||||
|
const newScale = settings.scale + delta;
|
||||||
|
const scale = clamp(newScale, 0.1, 10);
|
||||||
|
setSettings({ scale });
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
const mouseX = e.clientX - rect.left;
|
||||||
|
const mouseY = e.clientY - rect.top;
|
||||||
|
|
||||||
|
const currentScale = settings.scale;
|
||||||
|
const scaleRatio = newScale / currentScale;
|
||||||
|
|
||||||
|
panRef.current.offsetX = mouseX - scaleRatio * (mouseX - panRef.current.offsetX);
|
||||||
|
panRef.current.offsetY = mouseY - scaleRatio * (mouseY - panRef.current.offsetY);
|
||||||
|
};
|
||||||
|
|
||||||
|
container.addEventListener("wheel", handleWheel, { passive: false });
|
||||||
|
return () => container.removeEventListener("wheel", handleWheel);
|
||||||
|
}, [settings.scale]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const update = async (ev: KeyboardEvent) => {
|
||||||
|
const shift = ev.shiftKey ? 10 : 1;
|
||||||
|
|
||||||
|
let x = 0;
|
||||||
|
let y = 0;
|
||||||
|
switch (ev.key) {
|
||||||
|
case "ArrowUp":
|
||||||
|
case "w":
|
||||||
|
case "W":
|
||||||
|
y = -shift;
|
||||||
|
break;
|
||||||
|
case "ArrowDown":
|
||||||
|
case "s":
|
||||||
|
case "S":
|
||||||
|
y = shift;
|
||||||
|
break;
|
||||||
|
case "ArrowLeft":
|
||||||
|
case "a":
|
||||||
|
case "A":
|
||||||
|
x = -shift;
|
||||||
|
break;
|
||||||
|
case "ArrowRight":
|
||||||
|
case "d":
|
||||||
|
case "D":
|
||||||
|
x = shift;
|
||||||
|
break;
|
||||||
|
case "Delete": {
|
||||||
|
const copy = [...handler.selected];
|
||||||
|
if (copy.length && await handler.popup.confirm("Title", "Are you sure you want to delete")) {
|
||||||
|
for (const id of copy) {
|
||||||
|
handler.deleteImage(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (x || y) {
|
||||||
|
handler.selected.forEach((e) => {
|
||||||
|
handler.move(e, x, y);
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keyup", update);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keyup", update);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
|
if (!isDragging.current && handler.toolHandler.isToolSelected(TOOL_CANVAS_MOVE)) {
|
||||||
|
setCursor("grabbing");
|
||||||
|
}
|
||||||
|
isDragging.current = true;
|
||||||
|
hasMoved.current = false;
|
||||||
|
|
||||||
|
dragStart.current = {
|
||||||
|
x: e.clientX - panRef.current.offsetX,
|
||||||
|
y: e.clientY - panRef.current.offsetY,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMove = (e: React.MouseEvent) => {
|
||||||
|
if (!isDragging.current) return;
|
||||||
|
hasMoved.current = true;
|
||||||
|
|
||||||
|
if (handler.toolHandler.isToolSelected(TOOL_CANVAS_MOVE) || e.buttons === 4) {
|
||||||
|
panRef.current.offsetX = Math.round(e.clientX - dragStart.current.x);
|
||||||
|
panRef.current.offsetY = Math.round(e.clientY - dragStart.current.y);
|
||||||
|
} else if (handler.toolHandler.isToolSelected(TOOL_OBJECT_MOVE)) {
|
||||||
|
// if (!handler.selected.length) {
|
||||||
|
// handler.popup.alert("Drag", "None of the images are selected");
|
||||||
|
// stopDragging();
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
// const mx = e.clientX - panRef.current.offsetX;
|
||||||
|
// const my = e.clientY - panRef.current.offsetY;
|
||||||
|
|
||||||
|
// const dx = mx - dragStart.current.x;
|
||||||
|
// const dy = my - dragStart.current.y;
|
||||||
|
|
||||||
|
// const x = dx / settings.scale;
|
||||||
|
// const y = dy / settings.scale;
|
||||||
|
|
||||||
|
// dragStart.current.x = mx;
|
||||||
|
// dragStart.current.y = my;
|
||||||
|
|
||||||
|
// handler.selected.forEach((e) => {
|
||||||
|
// handler.move(e, x, y);
|
||||||
|
// });
|
||||||
|
}
|
||||||
|
applyTransform();
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopDragging = () => {
|
||||||
|
if (isDragging.current && handler.toolHandler.isToolSelected(TOOL_CANVAS_MOVE)) {
|
||||||
|
setCursor("grab");
|
||||||
|
}
|
||||||
|
isDragging.current = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClick = (ev: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||||
|
if (
|
||||||
|
handler.toolHandler.isToolSelected(TOOL_SELECT) ||
|
||||||
|
(handler.toolHandler.isToolSelected(TOOL_OBJECT_MOVE) && !hasMoved.current)
|
||||||
|
) {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
|
||||||
|
const screenX = ev.clientX - rect.left;
|
||||||
|
const screenY = ev.clientY - rect.top;
|
||||||
|
|
||||||
|
const a4X = (screenX - panRef.current.offsetX) / settings.scale;
|
||||||
|
const a4Y = (screenY - panRef.current.offsetY) / settings.scale;
|
||||||
|
|
||||||
|
handler.selectNext(a4X, a4Y, !ev.shiftKey, ev.ctrlKey);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container
|
||||||
|
$cursor={cursor}
|
||||||
|
ref={containerRef}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseUp={stopDragging}
|
||||||
|
onMouseLeave={stopDragging}
|
||||||
|
>
|
||||||
|
<CanvasWrapper onClick={onClick} ref={wrapperRef}>
|
||||||
|
<PaperView settings={settings} handler={handler} />
|
||||||
|
</CanvasWrapper>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import styled from "styled-components";
|
||||||
|
|
||||||
|
const Button = styled.button`
|
||||||
|
margin: 10px;
|
||||||
|
padding: 8px;
|
||||||
|
width: 75px;
|
||||||
|
height: 75px;
|
||||||
|
border: 1px solid white;
|
||||||
|
border-radius: 0;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
interface RibbonButtonProps {
|
||||||
|
icon: () => React.ReactNode;
|
||||||
|
name: string;
|
||||||
|
onClick: (ev: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RibbonButton(props: RibbonButtonProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button onClick={props.onClick}>
|
||||||
|
{props.icon()}
|
||||||
|
{props.name}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { FaBroom, FaDrawPolygon, FaFileExport, FaFileImport, FaFilePdf, FaFileZipper, FaImage } from "react-icons/fa6";
|
||||||
|
import type { CanvasHandlerProps } from "../handler/interfaces";
|
||||||
|
import { Button } from "../styles";
|
||||||
|
import styled from "styled-components";
|
||||||
|
import { createPdf, createSVGDoc, downloadBlob, downloadZip } from "../utils/download";
|
||||||
|
import type { ImageEditor } from "../handler/image-editor";
|
||||||
|
|
||||||
|
const Btn = styled(Button)`
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0px;
|
||||||
|
padding: 2px;
|
||||||
|
flex-grow: 1;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Row = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
`;
|
||||||
|
const Column = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Box = styled.div<{ $width: number, $height: number }>`
|
||||||
|
width: ${({ $width }) => $width}px;
|
||||||
|
height: ${({ $height }) => $height}px;
|
||||||
|
padding: 10px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
|
||||||
|
export function ImportExportButtons({ handler, selected }: CanvasHandlerProps & { selected: ImageEditor[] }) {
|
||||||
|
const boxSize = 90;
|
||||||
|
return <>
|
||||||
|
<Box $width={boxSize} $height={boxSize}>
|
||||||
|
<Column>
|
||||||
|
<Row>
|
||||||
|
|
||||||
|
<Btn onClick={() => { downloadZip(handler); }}>
|
||||||
|
<FaFileZipper />ZIP
|
||||||
|
</Btn>
|
||||||
|
<Btn onClick={() => { createPdf(handler, true).save(`${handler.projectName}.pdf`); }}>
|
||||||
|
<FaFilePdf />PDF
|
||||||
|
</Btn>
|
||||||
|
<Btn onClick={async () => { downloadBlob(await createSVGDoc(handler), `${handler.projectName}.svg`); }} >
|
||||||
|
<FaDrawPolygon />SVG
|
||||||
|
</Btn>
|
||||||
|
</Row>
|
||||||
|
<Row>
|
||||||
|
<Btn disabled={selected.length !== 1} onClick={() => {
|
||||||
|
const v = handler.imagesRenders.find(e => e.id === selected[0].id);
|
||||||
|
if (v) {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = v.image.naturalWidth * v.scale;
|
||||||
|
canvas.height = v.image.naturalHeight * v.scale;
|
||||||
|
const ctx = canvas.getContext("2d")!;
|
||||||
|
ctx.drawImage(v.image, 0, 0, canvas.width, canvas.height);
|
||||||
|
canvas.toBlob(blob => {
|
||||||
|
if (blob) {
|
||||||
|
downloadBlob(blob, `${v.name}.png`);
|
||||||
|
} else {
|
||||||
|
handler.popup.alert("Error", "Cannot export image");
|
||||||
|
}
|
||||||
|
}, "image/png");
|
||||||
|
} else {
|
||||||
|
handler.popup.alert("Error", "Image not found");
|
||||||
|
}
|
||||||
|
}}><FaImage />Image</Btn>
|
||||||
|
<Btn disabled={selected.length !== 1} onClick={() => {
|
||||||
|
const v = handler.imagesRenders.find(e => e.id === selected[0].id);
|
||||||
|
if (v) {
|
||||||
|
if (v.svg.scale !== v.scale || v.svg.dirty) {
|
||||||
|
handler.redrawSvg(v.id);
|
||||||
|
}
|
||||||
|
downloadBlob(new Blob([v.svg.data], { type: "image/svg+xml" }), `${v.name}.svg`);
|
||||||
|
} else {
|
||||||
|
handler.popup.alert("Error", "Image not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
}}> <FaDrawPolygon />SVG</Btn>
|
||||||
|
</Row>
|
||||||
|
</Column>
|
||||||
|
</Box>
|
||||||
|
<Box $width={boxSize} $height={boxSize}>
|
||||||
|
<Column>
|
||||||
|
<Row>
|
||||||
|
<Btn onClick={() => { handler.exportProject(); }}><FaFileExport />Import</Btn>
|
||||||
|
<Btn onClick={() => { handler.importProject(); }} ><FaFileImport />Export</Btn>
|
||||||
|
</Row>
|
||||||
|
<Btn style={{ backgroundColor: "#ff000073" }} disabled={selected.length !== 1} onClick={async () => {
|
||||||
|
{
|
||||||
|
if (await handler.popup.confirm("Clear", "Are you sure you want to clear project? Any unsaved changes will be discarded")) {
|
||||||
|
handler.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}} ><FaBroom />Clear</Btn>
|
||||||
|
</Column>
|
||||||
|
</Box>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { CanvasHandlerProps } from "../handler/interfaces";
|
||||||
|
import styled from "styled-components";
|
||||||
|
|
||||||
|
const DragOverlay = styled.div`
|
||||||
|
position: absolute;
|
||||||
|
inset: 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 2px dashed rgba(255, 255, 255, 0.6);
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
pointer-events: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-size: 14px;
|
||||||
|
z-index: 99999;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export function DragDrop({ handler }: CanvasHandlerProps) {
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleDragOver = (e: DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setDragOver(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = (e: DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setDragOver(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setDragOver(false);
|
||||||
|
|
||||||
|
const dt = e.dataTransfer;
|
||||||
|
if (!dt) return;
|
||||||
|
|
||||||
|
const files = dt.files && dt.files.length ? [...dt.files] : [];
|
||||||
|
for (const file of files) {
|
||||||
|
handler.importFile(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("dragover", handleDragOver);
|
||||||
|
window.addEventListener("dragleave", handleDragLeave);
|
||||||
|
window.addEventListener("drop", handleDrop);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("dragover", handleDragOver);
|
||||||
|
window.removeEventListener("dragleave", handleDragLeave);
|
||||||
|
window.removeEventListener("drop", handleDrop);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return dragOver ? <DragOverlay>Drop PNG files to place on page</DragOverlay> : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import styled from "styled-components";
|
||||||
|
import type { ImageEditor } from "../handler/image-editor";
|
||||||
|
|
||||||
|
const Wrapper = styled.div`
|
||||||
|
display: block;
|
||||||
|
padding: 5px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Input = styled.input`
|
||||||
|
width: 50px;
|
||||||
|
height: 10px;
|
||||||
|
background: #0d0d0d;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-top: none;
|
||||||
|
color: #e0e0e0;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
outline: none;
|
||||||
|
margin-top: 12px;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: #666;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export function ImagePropEditor({ propKey, selected }: { selected: ImageEditor[]; propKey: keyof ImageEditor }) {
|
||||||
|
if (selected.length !== 1) return null;
|
||||||
|
const item = selected[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Wrapper>
|
||||||
|
<span>{propKey}: </span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={item[propKey] as number}
|
||||||
|
onChange={(ev) => {
|
||||||
|
item.onPropsChange({ [propKey]: parseInt(ev.target.value, 10) });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Wrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import styled from "styled-components";
|
||||||
|
import { FaMinus, FaPlus } from "react-icons/fa";
|
||||||
|
import type { CanvasHandlerProps } from "../handler/interfaces";
|
||||||
|
import { clamp } from "lodash";
|
||||||
|
import type { ImageEditor } from "../handler/image-editor";
|
||||||
|
|
||||||
|
const SCALE_MIN = 0.1;
|
||||||
|
const SCALE_MAX = 3;
|
||||||
|
const SCALE_STEP = 0.01;
|
||||||
|
|
||||||
|
const Wrapper = styled.div`
|
||||||
|
width: 200px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-family: monospace;
|
||||||
|
user-select: none;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const IconBtn = styled.button`
|
||||||
|
background: var(--primary-color);
|
||||||
|
border: 1px solid #333;
|
||||||
|
color: #aaa;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition:
|
||||||
|
background 0.1s,
|
||||||
|
color 0.1s;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #2a2a2a;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: #0d0d0d;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Track = styled.div`
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
height: 4px;
|
||||||
|
background: #2e2e2e;
|
||||||
|
min-width: 80px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Fill = styled.div<{ $pct: number }>`
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
height: 100%;
|
||||||
|
width: ${({ $pct }) => $pct}%;
|
||||||
|
background: #e0e0e0;
|
||||||
|
pointer-events: none;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Thumb = styled.input`
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ThumbDot = styled.div<{ $pct: number; $disabled: boolean }>`
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: ${({ $pct }) => $pct}%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
background: ${({ $disabled }) => ($disabled ? "#444" : "#fff")};
|
||||||
|
border: 2px solid ${({ $disabled }) => ($disabled ? "#333" : "#888")};
|
||||||
|
pointer-events: none;
|
||||||
|
transition: background 0.1s;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Label = styled.span`
|
||||||
|
font-size: 11px;
|
||||||
|
color: #666;
|
||||||
|
min-width: 36px;
|
||||||
|
text-align: right;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export function ImageScaler({ handler, selected }: CanvasHandlerProps & { selected: ImageEditor[] }) {
|
||||||
|
const disabled = selected.length === 0;
|
||||||
|
const isMixed = selected.length > 1;
|
||||||
|
|
||||||
|
const scale = disabled ? 1 : isMixed ? selected.reduce((acc, e) => acc + e.scale, 0) / selected.length : selected[0].scale;
|
||||||
|
|
||||||
|
const pct = ((clamp(scale, SCALE_MIN, SCALE_MAX) - SCALE_MIN) / (SCALE_MAX - SCALE_MIN)) * 100;
|
||||||
|
|
||||||
|
const setScale = (value: number, ignoreLimit = false) => {
|
||||||
|
const clamped = ignoreLimit ? value : Math.min(SCALE_MAX, Math.max(SCALE_MIN, value));
|
||||||
|
|
||||||
|
selected.forEach((e) => {
|
||||||
|
const oldScale = e.scale;
|
||||||
|
const newScale = clamped;
|
||||||
|
|
||||||
|
const currentW = e.image.width * oldScale;
|
||||||
|
const currentH = e.image.height * oldScale;
|
||||||
|
|
||||||
|
const newW = e.image.width * newScale;
|
||||||
|
const newH = e.image.height * newScale;
|
||||||
|
|
||||||
|
const dx = (currentW - newW) / 2;
|
||||||
|
const dy = (currentH - newH) / 2;
|
||||||
|
|
||||||
|
e.onPropsChange({
|
||||||
|
scale: clamped,
|
||||||
|
x: e.x + dx,
|
||||||
|
y: e.y + dy,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const label = disabled ? "—" : isMixed ? "~" + scale.toFixed(2) : scale.toFixed(2);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Wrapper>
|
||||||
|
<IconBtn disabled={disabled} onClick={() => setScale(scale - SCALE_STEP * 10)} title="Scale down">
|
||||||
|
<FaMinus size={9} />
|
||||||
|
</IconBtn>
|
||||||
|
|
||||||
|
<Track>
|
||||||
|
<Fill $pct={pct} />
|
||||||
|
<ThumbDot $pct={pct} $disabled={disabled} />
|
||||||
|
<Thumb
|
||||||
|
type="range"
|
||||||
|
min={SCALE_MIN}
|
||||||
|
max={SCALE_MAX}
|
||||||
|
step={SCALE_STEP}
|
||||||
|
value={scale}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => setScale(parseFloat(e.target.value))}
|
||||||
|
/>
|
||||||
|
</Track>
|
||||||
|
|
||||||
|
<IconBtn disabled={disabled} onClick={() => setScale(scale + SCALE_STEP * 10, true)} title="Scale up">
|
||||||
|
<FaPlus size={9} />
|
||||||
|
</IconBtn>
|
||||||
|
|
||||||
|
<Label
|
||||||
|
onClick={async () => {
|
||||||
|
const size = await handler.popup.prompt("Custom size", "Enter custom size", scale.toString());
|
||||||
|
if (size) {
|
||||||
|
const float = parseFloat(size);
|
||||||
|
if (!isNaN(float)) {
|
||||||
|
setScale(float, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={isMixed ? "Average of selected" : undefined}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Label>
|
||||||
|
</Wrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { useRef, useEffect } from "react";
|
||||||
|
import { drawCanvas } from "../../draw-canvas";
|
||||||
|
import type { CanvasHandlerProps } from "../../handler/interfaces";
|
||||||
|
import styled from "styled-components";
|
||||||
|
|
||||||
|
const CanvasEl = styled.canvas`
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||||
|
display: block;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export function CanvasView({ handler }: CanvasHandlerProps) {
|
||||||
|
const ref = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = ref.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const render = () => {
|
||||||
|
drawCanvas(canvas!, handler);
|
||||||
|
};
|
||||||
|
|
||||||
|
render();
|
||||||
|
|
||||||
|
const unsubs = [
|
||||||
|
handler.emitter.on("update", render),
|
||||||
|
handler.emitter.on("array-length", render),
|
||||||
|
handler.emitter.on("select", render),
|
||||||
|
handler.globalSettingsHandler.emitter.on("settings-update", render),
|
||||||
|
];
|
||||||
|
|
||||||
|
return function () {
|
||||||
|
unsubs.forEach((unsub) => unsub());
|
||||||
|
};
|
||||||
|
}, [handler]);
|
||||||
|
|
||||||
|
return <CanvasEl draggable={false} ref={ref} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import type { CanvasHandlerProps } from "../../handler/interfaces";
|
||||||
|
import styled from "styled-components";
|
||||||
|
import { MM_TO_INCH } from "../../constants";
|
||||||
|
import { useImages } from "../../use/use-images";
|
||||||
|
import { FreeTransform } from "./selection-box";
|
||||||
|
import type { GlobalSettings, GridType } from "../../interface";
|
||||||
|
import { SvgRenderer } from "./svg-view";
|
||||||
|
|
||||||
|
|
||||||
|
const CanvasEl = styled.div`
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||||
|
display: block;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Img = styled.img<{ $selected: boolean }>`
|
||||||
|
position: absolute;
|
||||||
|
display: block;
|
||||||
|
user-select: none;
|
||||||
|
opacity: ${({ $selected }) => $selected ? 0.5 : 1};
|
||||||
|
`;
|
||||||
|
|
||||||
|
|
||||||
|
const Box = styled.div`
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
background-color: #757575;
|
||||||
|
`;
|
||||||
|
|
||||||
|
function getGridLines(settings: GridType, width: number, height: number, strokeWidth: number, strokeWidthHalf: number) {
|
||||||
|
if (settings === "none") return null;
|
||||||
|
const configs: Record<GridType, { vertical: number[]; horizontal: number[] }> = {
|
||||||
|
"none": {
|
||||||
|
horizontal: [1],
|
||||||
|
vertical: [1]
|
||||||
|
},
|
||||||
|
"2x2": {
|
||||||
|
vertical: [1 / 2],
|
||||||
|
horizontal: [1 / 2],
|
||||||
|
},
|
||||||
|
"3x3": {
|
||||||
|
vertical: [1 / 3, 2 / 3],
|
||||||
|
horizontal: [1 / 3, 2 / 3],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = configs[settings];
|
||||||
|
if (!config) return null;
|
||||||
|
|
||||||
|
return <>
|
||||||
|
{config.vertical.map((fraction, i) => (
|
||||||
|
<Box key={`v-${i}`} style={{ left: width * fraction - strokeWidthHalf, width: strokeWidth, height: "100%" }} />
|
||||||
|
))}
|
||||||
|
{config.horizontal.map((fraction, i) => (
|
||||||
|
<Box key={`h-${i}`} style={{ top: height * fraction - strokeWidthHalf, height: strokeWidth, width: "100%" }} />
|
||||||
|
))}
|
||||||
|
</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PaperView({ handler, settings }: CanvasHandlerProps & { settings: GlobalSettings }) {
|
||||||
|
const { images } = useImages(handler);
|
||||||
|
const dpi = settings.DPI;
|
||||||
|
const width = Math.round(((settings.landscape ? settings.paperHeight : settings.paperWidth) / MM_TO_INCH) * dpi) * settings.scale;
|
||||||
|
const height = Math.round(((settings.landscape ? settings.paperWidth : settings.paperHeight) / MM_TO_INCH) * dpi) * settings.scale;
|
||||||
|
|
||||||
|
const renderGuides = () => {
|
||||||
|
const strokeWidth = 1;
|
||||||
|
const strokeWidthHalf = strokeWidth / 2;
|
||||||
|
return getGridLines(settings.grid, width, height, strokeWidth, strokeWidthHalf);
|
||||||
|
};
|
||||||
|
|
||||||
|
return <CanvasEl style={{ width: `${width}px`, height: `${height}px` }} draggable={false}>
|
||||||
|
{images.map(e => {
|
||||||
|
if (!e.visible) return;
|
||||||
|
const width = e.image.naturalWidth * e.scale * settings.scale;
|
||||||
|
const height = e.image.naturalHeight * e.scale * settings.scale;
|
||||||
|
const x = e.x * settings.scale;
|
||||||
|
const y = e.y * settings.scale;
|
||||||
|
return <div key={e.id}>
|
||||||
|
<Img
|
||||||
|
$selected={e.selected}
|
||||||
|
draggable="false"
|
||||||
|
src={e.url}
|
||||||
|
alt={e.id}
|
||||||
|
style={{ left: `${x}px`, top: `${y}px` }}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
/>
|
||||||
|
<SvgRenderer x={x} y={y} width={width} height={height} svg={e.svg} />
|
||||||
|
{e.selected ?
|
||||||
|
<FreeTransform scale={settings.scale} transform={{
|
||||||
|
x: x,
|
||||||
|
y: y,
|
||||||
|
height,
|
||||||
|
width,
|
||||||
|
rotation: 0
|
||||||
|
}} onTransformChange={({ x, y, width }) => {
|
||||||
|
const scale = Math.round(width / e.image.width / settings.scale * 100) / 100;
|
||||||
|
e.onPropsChange({ x: Math.round(x / settings.scale), y: Math.round(y / settings.scale), scale });
|
||||||
|
}} /> : null}
|
||||||
|
</div>;
|
||||||
|
})}
|
||||||
|
{renderGuides()}
|
||||||
|
</CanvasEl>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import React, { useRef, useCallback } from "react";
|
||||||
|
import styled from "styled-components";
|
||||||
|
|
||||||
|
type Transform = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
rotation: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DragState =
|
||||||
|
| { type: "none" }
|
||||||
|
| { type: "move"; startX: number; startY: number; originX: number; originY: number }
|
||||||
|
| { type: "resize"; handle: HandleKey; startX: number; startY: number; originTransform: Transform }
|
||||||
|
| { type: "rotate"; startAngle: number; originRotation: number; cx: number; cy: number };
|
||||||
|
|
||||||
|
type HandleKey = "nw" | "ne" | "se" | "sw";
|
||||||
|
|
||||||
|
const HANDLE_SIZE = 10;
|
||||||
|
const MIN_SIZE = 20;
|
||||||
|
|
||||||
|
const HANDLE_CURSORS: Record<HandleKey, string> = {
|
||||||
|
nw: "nwse-resize",
|
||||||
|
ne: "nesw-resize",
|
||||||
|
se: "nwse-resize",
|
||||||
|
sw: "nesw-resize",
|
||||||
|
};
|
||||||
|
|
||||||
|
const HANDLE_POSITIONS: Record<HandleKey, { x: number; y: number }> = {
|
||||||
|
nw: { x: 0, y: 0 },
|
||||||
|
ne: { x: 1, y: 0 },
|
||||||
|
se: { x: 1, y: 1 },
|
||||||
|
sw: { x: 0, y: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const Overlay = styled.div`
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TransformBox = styled.div`
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: move;
|
||||||
|
user-select: none;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 999999;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SelectionBorder = styled.div`
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border: 2px dashed #00d4ff;
|
||||||
|
box-sizing: border-box;
|
||||||
|
pointer-events: none;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ResizeHandle = styled.div<{ $x: number; $y: number; $cursor: string }>`
|
||||||
|
position: absolute;
|
||||||
|
left: calc(${({ $x }) => $x * 100}% - ${HANDLE_SIZE / 2}px);
|
||||||
|
top: calc(${({ $y }) => $y * 100}% - ${HANDLE_SIZE / 2}px);
|
||||||
|
width: ${HANDLE_SIZE}px;
|
||||||
|
height: ${HANDLE_SIZE}px;
|
||||||
|
background: white;
|
||||||
|
border: 2px solid #00d4ff;
|
||||||
|
border-radius: 2px;
|
||||||
|
cursor: ${({ $cursor }) => $cursor};
|
||||||
|
pointer-events: auto;
|
||||||
|
z-index: 10;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// const RotateHandleWrapper = styled.div`
|
||||||
|
// position: absolute;
|
||||||
|
// left: 50%;
|
||||||
|
// top: -36px;
|
||||||
|
// transform: translateX(-50%);
|
||||||
|
// display: flex;
|
||||||
|
// flex-direction: column;
|
||||||
|
// align-items: center;
|
||||||
|
// pointer-events: auto;
|
||||||
|
// cursor: grab;
|
||||||
|
// z-index: 10;
|
||||||
|
// `;
|
||||||
|
|
||||||
|
// const RotateStem = styled.div`
|
||||||
|
// width: 2px;
|
||||||
|
// height: 20px;
|
||||||
|
// background: #00d4ff;
|
||||||
|
// `;
|
||||||
|
|
||||||
|
// const RotateKnob = styled.div`
|
||||||
|
// width: 14px;
|
||||||
|
// height: 14px;
|
||||||
|
// border-radius: 50%;
|
||||||
|
// background: white;
|
||||||
|
// border: 2px solid #00d4ff;
|
||||||
|
// margin-top: -1px;
|
||||||
|
// `;
|
||||||
|
|
||||||
|
export type FreeTransformProps = {
|
||||||
|
transform: Transform;
|
||||||
|
scale?: number;
|
||||||
|
onTransformChange: (t: Transform) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FreeTransform({
|
||||||
|
transform,
|
||||||
|
scale = 1,
|
||||||
|
onTransformChange,
|
||||||
|
}: FreeTransformProps) {
|
||||||
|
const dragRef = useRef<DragState>({ type: "none" });
|
||||||
|
|
||||||
|
const apply = useCallback(
|
||||||
|
(updater: (prev: Transform) => Transform) => {
|
||||||
|
onTransformChange(updater(transform));
|
||||||
|
},
|
||||||
|
[transform, onTransformChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onMovePointerDown = useCallback(
|
||||||
|
(e: React.PointerEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId);
|
||||||
|
dragRef.current = {
|
||||||
|
type: "move",
|
||||||
|
startX: e.clientX,
|
||||||
|
startY: e.clientY,
|
||||||
|
originX: transform.x,
|
||||||
|
originY: transform.y,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[transform.x, transform.y]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onResizePointerDown = useCallback(
|
||||||
|
(handle: HandleKey) => (e: React.PointerEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId);
|
||||||
|
dragRef.current = {
|
||||||
|
type: "resize",
|
||||||
|
handle,
|
||||||
|
startX: e.clientX,
|
||||||
|
startY: e.clientY,
|
||||||
|
originTransform: { ...transform },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[transform]
|
||||||
|
);
|
||||||
|
|
||||||
|
// const onRotatePointerDown = useCallback(
|
||||||
|
// (e: React.PointerEvent) => {
|
||||||
|
// e.stopPropagation();
|
||||||
|
// e.currentTarget.setPointerCapture(e.pointerId);
|
||||||
|
// const cx = transform.x * scale + (transform.width * scale) / 2;
|
||||||
|
// const cy = transform.y * scale + (transform.height * scale) / 2;
|
||||||
|
// const startAngle = Math.atan2(e.clientY - cy, e.clientX - cx) * (180 / Math.PI);
|
||||||
|
// dragRef.current = {
|
||||||
|
// type: "rotate",
|
||||||
|
// startAngle,
|
||||||
|
// originRotation: transform.rotation,
|
||||||
|
// cx,
|
||||||
|
// cy,
|
||||||
|
// };
|
||||||
|
// },
|
||||||
|
// [transform, scale]
|
||||||
|
// );
|
||||||
|
|
||||||
|
const onPointerMove = useCallback(
|
||||||
|
(e: React.PointerEvent) => {
|
||||||
|
if (e.buttons !== 1) return;
|
||||||
|
const drag = dragRef.current;
|
||||||
|
if (drag.type === "none") return;
|
||||||
|
|
||||||
|
if (drag.type === "move") {
|
||||||
|
const dx = (e.clientX - drag.startX) / scale;
|
||||||
|
const dy = (e.clientY - drag.startY) / scale;
|
||||||
|
apply(() => ({
|
||||||
|
...transform,
|
||||||
|
x: drag.originX + dx,
|
||||||
|
y: drag.originY + dy,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (drag.type === "rotate") {
|
||||||
|
const currentAngle =
|
||||||
|
Math.atan2(e.clientY - drag.cy, e.clientX - drag.cx) * (180 / Math.PI);
|
||||||
|
apply(() => ({
|
||||||
|
...transform,
|
||||||
|
rotation: drag.originRotation + (currentAngle - drag.startAngle),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (drag.type === "resize") {
|
||||||
|
const { handle, startX, startY, originTransform: o } = drag;
|
||||||
|
const rad = (-o.rotation * Math.PI) / 180;
|
||||||
|
const rawDx = (e.clientX - startX) / scale;
|
||||||
|
const rawDy = (e.clientY - startY) / scale;
|
||||||
|
const dx = rawDx * Math.cos(rad) - rawDy * Math.sin(rad);
|
||||||
|
const dy = rawDx * Math.sin(rad) + rawDy * Math.cos(rad);
|
||||||
|
|
||||||
|
let { x, y, width, height } = o;
|
||||||
|
|
||||||
|
if (handle.includes("e")) width = Math.max(MIN_SIZE, o.width + dx);
|
||||||
|
if (handle.includes("s")) height = Math.max(MIN_SIZE, o.height + dy);
|
||||||
|
if (handle.includes("w")) {
|
||||||
|
const newW = Math.max(MIN_SIZE, o.width - dx);
|
||||||
|
x = o.x + o.width - newW;
|
||||||
|
width = newW;
|
||||||
|
}
|
||||||
|
if (handle.includes("n")) {
|
||||||
|
const newH = Math.max(MIN_SIZE, o.height - dy);
|
||||||
|
y = o.y + o.height - newH;
|
||||||
|
height = newH;
|
||||||
|
}
|
||||||
|
|
||||||
|
apply(() => ({ ...transform, x, y, width, height }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[transform, scale, apply]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onPointerUp = useCallback(() => {
|
||||||
|
dragRef.current = { type: "none" };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { x, y, width, height, rotation } = transform;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Overlay>
|
||||||
|
<TransformBox
|
||||||
|
style={{
|
||||||
|
left: `${x}px`,
|
||||||
|
top: `${y}px`,
|
||||||
|
width: `${width}px`,
|
||||||
|
height: `${height}px`,
|
||||||
|
transform: `rotate(${rotation}deg)`
|
||||||
|
}}
|
||||||
|
onPointerDown={onMovePointerDown}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
>
|
||||||
|
<SelectionBorder />
|
||||||
|
|
||||||
|
{(Object.keys(HANDLE_POSITIONS) as HandleKey[]).map((key) => (
|
||||||
|
<ResizeHandle
|
||||||
|
key={key}
|
||||||
|
$x={HANDLE_POSITIONS[key].x}
|
||||||
|
$y={HANDLE_POSITIONS[key].y}
|
||||||
|
$cursor={HANDLE_CURSORS[key]}
|
||||||
|
onPointerDown={onResizePointerDown(key)}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* <RotateHandleWrapper
|
||||||
|
onPointerDown={onRotatePointerDown}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
>
|
||||||
|
<RotateStem />
|
||||||
|
<RotateKnob />
|
||||||
|
</RotateHandleWrapper> */}
|
||||||
|
</TransformBox>
|
||||||
|
</Overlay>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
|
||||||
|
import styled from "styled-components";
|
||||||
|
|
||||||
|
const Wrapper = styled.div`
|
||||||
|
position: absolute;
|
||||||
|
display: inline-block;
|
||||||
|
line-height: 0;
|
||||||
|
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
svg: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function SvgRenderer(props: Props) {
|
||||||
|
const { svg, x, y, width, height } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Wrapper style={{
|
||||||
|
left: x,
|
||||||
|
top: y,
|
||||||
|
width,
|
||||||
|
height
|
||||||
|
}} dangerouslySetInnerHTML={{ __html: svg }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { styled } from "styled-components";
|
||||||
|
import type { CanvasHandlerProps } from "../handler/interfaces";
|
||||||
|
import { Icon2525 } from "../styles";
|
||||||
|
import { FaMinus, FaPlus } from "react-icons/fa6";
|
||||||
|
import { useSettings } from "../use/use-settings";
|
||||||
|
import { clamp } from "lodash";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
const Wrapper = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
border-top: 1px solid var(--secondary-color);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ScaleText = styled.span`
|
||||||
|
width: 50px;
|
||||||
|
margin-top: 4px;
|
||||||
|
text-align: center;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ProjectName = styled.span`
|
||||||
|
margin: auto;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const DPI = [72, 96, 150, 300, 600];
|
||||||
|
|
||||||
|
export function InfoBar({ handler }: CanvasHandlerProps) {
|
||||||
|
const { settings, setSettings } = useSettings(handler);
|
||||||
|
const [projectName, setProjectName] = useState(handler.projectName);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return handler.emitter.on("name", name => {
|
||||||
|
setProjectName(name);
|
||||||
|
});
|
||||||
|
}, [handler.projectName]);
|
||||||
|
|
||||||
|
const setScale = (increment: boolean) => {
|
||||||
|
const amount = 0.05;
|
||||||
|
setSettings({ scale: settings.scale + (increment ? amount : -amount) });
|
||||||
|
};
|
||||||
|
const setDPI = (increment: boolean) => {
|
||||||
|
let index = DPI.indexOf(settings.DPI);
|
||||||
|
|
||||||
|
if (index === -1) {
|
||||||
|
index = DPI.reduce((bestIdx, value, i) => {
|
||||||
|
const bestDiff = Math.abs(DPI[bestIdx] - settings.DPI);
|
||||||
|
const currentDiff = Math.abs(value - settings.DPI);
|
||||||
|
return currentDiff < bestDiff ? i : bestIdx;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (increment) {
|
||||||
|
if (index + 1 < DPI.length) index++;
|
||||||
|
} else {
|
||||||
|
if (index - 1 >= 0) index--;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSettings({ DPI: DPI[index] });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Wrapper>
|
||||||
|
<Icon2525 onClick={() => setScale(false)}>
|
||||||
|
<FaMinus />
|
||||||
|
</Icon2525>
|
||||||
|
<ScaleText
|
||||||
|
onClick={async () => {
|
||||||
|
const number = await handler.popup.prompt("DPI", "Enter your desired DPI", (settings.scale * 100).toString());
|
||||||
|
if (number) {
|
||||||
|
const int = parseInt(number, 10) / 100;
|
||||||
|
if (!isNaN(int)) {
|
||||||
|
setSettings({ scale: clamp(int, 0, 100) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Math.round(settings.scale * 100)}%
|
||||||
|
</ScaleText>
|
||||||
|
<Icon2525 onClick={() => setScale(true)}>
|
||||||
|
<FaPlus />
|
||||||
|
</Icon2525>
|
||||||
|
<ProjectName>
|
||||||
|
<span onClick={() => {
|
||||||
|
handler.promptSetName();
|
||||||
|
}}> {projectName}
|
||||||
|
</span>
|
||||||
|
</ProjectName>
|
||||||
|
<Icon2525 onClick={() => setDPI(false)}>
|
||||||
|
<FaMinus />
|
||||||
|
</Icon2525>
|
||||||
|
<ScaleText
|
||||||
|
onClick={async () => {
|
||||||
|
const number = await handler.popup.prompt("DPI", "Enter your desired DPI", settings.DPI.toString());
|
||||||
|
if (number) {
|
||||||
|
const int = parseInt(number, 10);
|
||||||
|
if (!isNaN(int)) {
|
||||||
|
setSettings({ DPI: clamp(int, DPI[0], DPI[DPI.length - 1]) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Math.round(settings.DPI)}DPI
|
||||||
|
</ScaleText>
|
||||||
|
<Icon2525 onClick={() => setDPI(true)}>
|
||||||
|
<FaPlus />
|
||||||
|
</Icon2525>
|
||||||
|
</Wrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import styled from "styled-components";
|
||||||
|
import type { CanvasHandleImageEditorProps } from "../handler/interfaces";
|
||||||
|
import { FaEye, FaLock } from "react-icons/fa";
|
||||||
|
import { NamePlate } from "./layer-name";
|
||||||
|
import type { ImageEditor } from "../handler/image-editor";
|
||||||
|
|
||||||
|
const Item = styled.div<{ $selected: boolean }>`
|
||||||
|
width: 240px;
|
||||||
|
height: 30px;
|
||||||
|
border: 1px solid var(--secondary-color);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
${({ $selected }) =>
|
||||||
|
$selected &&
|
||||||
|
`
|
||||||
|
background-color: var(--secondary-color);
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Img = styled.img`
|
||||||
|
margin: 5px;
|
||||||
|
max-width: 25px;
|
||||||
|
max-height: 25px;
|
||||||
|
`;
|
||||||
|
const Icon = styled.span`
|
||||||
|
padding: 7px 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export function LayerItem({ image, handler }: CanvasHandleImageEditorProps) {
|
||||||
|
const selectLayer = (image: ImageEditor, ctrl: boolean) => {
|
||||||
|
if (!ctrl) {
|
||||||
|
const selected = handler.selected.filter(e => e !== image.id);
|
||||||
|
for (const sel of selected) {
|
||||||
|
handler.setSelect(sel, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
image.toggleSelected();
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Item
|
||||||
|
$selected={image.selected}
|
||||||
|
onClick={ev => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
ev.preventDefault();
|
||||||
|
selectLayer(image, ev.ctrlKey);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
style={{ opacity: image.visible ? 1 : 0.1 }}
|
||||||
|
onClick={ev => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
ev.preventDefault();
|
||||||
|
image.setVisible(!image.visible);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaEye />
|
||||||
|
</Icon>
|
||||||
|
|
||||||
|
<Img
|
||||||
|
draggable="false"
|
||||||
|
onClick={ev => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
ev.preventDefault();
|
||||||
|
selectLayer(image, ev.ctrlKey);
|
||||||
|
}}
|
||||||
|
src={image.url}
|
||||||
|
alt={image.id}
|
||||||
|
/>
|
||||||
|
<NamePlate image={image} />
|
||||||
|
<Icon
|
||||||
|
style={{ opacity: image.locked ? 1 : 0.1 }}
|
||||||
|
onClick={ev => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
ev.preventDefault();
|
||||||
|
image.setLock(!image.locked);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaLock />
|
||||||
|
</Icon>
|
||||||
|
</Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import styled from "styled-components";
|
||||||
|
import type { ImageEditorProps } from "../handler/interfaces";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
const NamePlateDiv = styled.span`
|
||||||
|
width: 150px;
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
flex-grow: 1;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Input = styled.input`
|
||||||
|
width: 140px;
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
border: 1px solid var(--secondary-color);
|
||||||
|
border-radius: 0px;
|
||||||
|
color: white;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export function NamePlate({ image }: ImageEditorProps) {
|
||||||
|
const [name, setName] = useState(() => image.name);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
|
||||||
|
const stopEditing = () => {
|
||||||
|
image.setName(name);
|
||||||
|
setEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NamePlateDiv>
|
||||||
|
{editing ? (
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
onBlur={stopEditing}
|
||||||
|
onKeyUp={(ev) => {
|
||||||
|
if (ev.key === "Enter") {
|
||||||
|
stopEditing();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span onDoubleClick={ev => {
|
||||||
|
if (!image.locked) {
|
||||||
|
ev.stopPropagation();
|
||||||
|
ev.preventDefault();
|
||||||
|
setEditing(true);
|
||||||
|
}
|
||||||
|
}}>{image.name}</span>
|
||||||
|
)}
|
||||||
|
</NamePlateDiv>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import type { CanvasHandlerProps } from "../handler/interfaces";
|
||||||
|
import styled from "styled-components";
|
||||||
|
import { LayerItem } from "./layer-item";
|
||||||
|
import { FaFileImport, FaTrash } from "react-icons/fa";
|
||||||
|
import { Icon2525 } from "../styles";
|
||||||
|
import { useImages } from "../use/use-images";
|
||||||
|
|
||||||
|
const LayerPanelDiv = styled.div`
|
||||||
|
border-top: 1px solid var(--secondary-color);
|
||||||
|
border-left: 1px solid var(--secondary-color);
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
display: flex;
|
||||||
|
width: 242px;
|
||||||
|
flex-direction: column;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Gap = styled.div`
|
||||||
|
flex-grow: 1;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Version = styled.span`
|
||||||
|
margin: 0 5px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
|
||||||
|
const ToolBar = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
border-top: 1px solid var(--secondary-color);
|
||||||
|
`;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const Wrapper = styled.div`
|
||||||
|
width: 200px;
|
||||||
|
padding: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Title = styled.div`
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
opacity: 0.9;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Row = styled.div`
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Label = styled.div`
|
||||||
|
opacity: 0.6;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Value = styled.div`
|
||||||
|
text-align: right;
|
||||||
|
word-break: break-word;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Link = styled.a`
|
||||||
|
color: #4AF626;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export function LayerPanel({ handler }: CanvasHandlerProps) {
|
||||||
|
const { images } = useImages(handler);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LayerPanelDiv>
|
||||||
|
{images.map((e) => (
|
||||||
|
<LayerItem key={e.id} handler={handler} image={e} />
|
||||||
|
))}
|
||||||
|
<Gap />
|
||||||
|
<ToolBar>
|
||||||
|
|
||||||
|
<Version onDoubleClick={() => {
|
||||||
|
handler.popup.custom("About", "", <Wrapper>
|
||||||
|
<Title>Vectrace {handler.VERSION}v</Title>
|
||||||
|
|
||||||
|
<Row>
|
||||||
|
<Label>Author</Label>
|
||||||
|
<Value>{handler.AUTHOR.name}</Value>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
|
||||||
|
<Row>
|
||||||
|
<Label>Email</Label>
|
||||||
|
<Value>{handler.AUTHOR.email}</Value>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row>
|
||||||
|
<Label>URL</Label>
|
||||||
|
<Value>
|
||||||
|
<Link href={handler.AUTHOR.url} target="_blank" rel="noreferrer">
|
||||||
|
{handler.AUTHOR.url}
|
||||||
|
</Link>
|
||||||
|
</Value>
|
||||||
|
</Row>
|
||||||
|
</Wrapper>);
|
||||||
|
}}>
|
||||||
|
{handler.VERSION}v
|
||||||
|
|
||||||
|
</Version>
|
||||||
|
<Gap />
|
||||||
|
<Icon2525
|
||||||
|
onClick={() => {
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "file";
|
||||||
|
input.accept = "image/*";
|
||||||
|
input.addEventListener("change", async () => {
|
||||||
|
if (input.files) {
|
||||||
|
for (const file of [...input.files]) {
|
||||||
|
await handler.importFile(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
input.click();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaFileImport />
|
||||||
|
</Icon2525>
|
||||||
|
<Icon2525
|
||||||
|
onClick={async () => {
|
||||||
|
const c = [...handler.selected];
|
||||||
|
if (c.length) {
|
||||||
|
if (
|
||||||
|
await handler.popup.confirm(
|
||||||
|
`Delete (${c.length})`,
|
||||||
|
`Are you sure you want to delete ${c.length} items?`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
for (const id of c) {
|
||||||
|
handler.deleteImage(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaTrash />
|
||||||
|
</Icon2525>
|
||||||
|
</ToolBar>
|
||||||
|
</LayerPanelDiv>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import styled from "styled-components";
|
||||||
|
import { calculateMathExpression, canEvaluateMathExpression } from "../calc";
|
||||||
|
|
||||||
|
const StyledInput = styled.input<{ $isValid: boolean | null }>`
|
||||||
|
padding: 1px;
|
||||||
|
font-size: 1rem;
|
||||||
|
width: 80px;
|
||||||
|
border: 1px solid
|
||||||
|
${({ $isValid }) =>
|
||||||
|
$isValid === null
|
||||||
|
? "#888"
|
||||||
|
: $isValid
|
||||||
|
? "#ffffff"
|
||||||
|
: "#ef4444"};
|
||||||
|
outline: none;
|
||||||
|
background-color: ${({ $isValid }) =>
|
||||||
|
$isValid === null
|
||||||
|
? "transparent"
|
||||||
|
: $isValid
|
||||||
|
? "#000000"
|
||||||
|
: "#7e0000"};
|
||||||
|
color: ${({ $isValid }) =>
|
||||||
|
$isValid === null ? "inherit" : $isValid ? "#15803d" : "#b91c1c"};
|
||||||
|
transition: border-color 0.2s ease, background-color 0.2s ease;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
box-shadow: 0 0 0 3px
|
||||||
|
${({ $isValid }) =>
|
||||||
|
$isValid === null
|
||||||
|
? "#88888844"
|
||||||
|
: $isValid
|
||||||
|
? "#22c55e44"
|
||||||
|
: "#ef444444"};
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
|
||||||
|
export interface NumberInputProps {
|
||||||
|
value?: number;
|
||||||
|
id?: string;
|
||||||
|
onChange?: (value: number) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function NumberInput({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
id,
|
||||||
|
placeholder = "Enter a number...",
|
||||||
|
}: NumberInputProps) {
|
||||||
|
const [raw, setRaw] = useState<string>(() => value !== undefined ? String(value) : ""
|
||||||
|
);
|
||||||
|
const [isValid, setIsValid] = useState<boolean | null>(
|
||||||
|
value !== undefined ? true : null
|
||||||
|
);
|
||||||
|
|
||||||
|
const isValidNumber = (val: string): boolean => {
|
||||||
|
if (val.trim() === "") return false;
|
||||||
|
const parsed = Number(val);
|
||||||
|
|
||||||
|
return isFinite(parsed) && !isNaN(parsed);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
setRaw(val);
|
||||||
|
|
||||||
|
if (val.trim() === "") {
|
||||||
|
|
||||||
|
setIsValid(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isValidNumber(val)) {
|
||||||
|
setIsValid(true);
|
||||||
|
onChange?.(Number(val));
|
||||||
|
} else {
|
||||||
|
setIsValid(canEvaluateMathExpression(val));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const increment = (increment: boolean) => {
|
||||||
|
if (isValidNumber(raw)) {
|
||||||
|
const value = Number(raw) + (increment ? 1 : -1);
|
||||||
|
setRaw(value.toString());
|
||||||
|
onChange?.(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const executeEvaluate = () => {
|
||||||
|
if (canEvaluateMathExpression(raw)) {
|
||||||
|
const value = calculateMathExpression(raw);
|
||||||
|
|
||||||
|
setRaw(value.toString());
|
||||||
|
if (isValidNumber(value.toString())) {
|
||||||
|
setIsValid(true);
|
||||||
|
onChange?.(value);
|
||||||
|
} else {
|
||||||
|
setIsValid(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (value !== undefined) {
|
||||||
|
setRaw(value.toString());
|
||||||
|
setIsValid(true);
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledInput
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={raw}
|
||||||
|
onBlur={executeEvaluate}
|
||||||
|
onKeyUp={ev => {
|
||||||
|
switch (ev.key) {
|
||||||
|
case "Enter":
|
||||||
|
executeEvaluate();
|
||||||
|
break;
|
||||||
|
case "ArrowUp":
|
||||||
|
ev.preventDefault();
|
||||||
|
increment(true);
|
||||||
|
break;
|
||||||
|
case "ArrowDown":
|
||||||
|
ev.preventDefault();
|
||||||
|
increment(false);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onChange={handleChange}
|
||||||
|
$isValid={isValid}
|
||||||
|
placeholder={placeholder}
|
||||||
|
id={id}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import styled from "styled-components";
|
||||||
|
import { NumberInput, type NumberInputProps } from "./number-input";
|
||||||
|
|
||||||
|
const OptionDiv = styled.div`
|
||||||
|
margin: 2px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export function NumericInputWithLabel({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
name,
|
||||||
|
id
|
||||||
|
|
||||||
|
}: NumberInputProps & { name: string }) {
|
||||||
|
return <OptionDiv>
|
||||||
|
<label htmlFor={id}>{name}:</label>
|
||||||
|
<NumberInput id={id} placeholder={placeholder} value={value} onChange={onChange} />
|
||||||
|
</OptionDiv>;
|
||||||
|
}
|
||||||
|
export function NumericInputWithLabelTable({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
name,
|
||||||
|
id
|
||||||
|
|
||||||
|
}: NumberInputProps & { name: string }) {
|
||||||
|
return <tr>
|
||||||
|
<td>
|
||||||
|
<label htmlFor={id}>{name}:</label>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<NumberInput
|
||||||
|
id={id}
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange} />
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import styled from "styled-components";
|
||||||
|
import { Button } from "../styles";
|
||||||
|
import type { UseSettings } from "../use/use-settings";
|
||||||
|
import { GRID } from "../interface";
|
||||||
|
|
||||||
|
const Wrapper = styled.div`
|
||||||
|
height: calc(100% - 8px);
|
||||||
|
border-right: 1px solid white;
|
||||||
|
margin: 4px 0px;
|
||||||
|
padding-right: 4px;
|
||||||
|
width: 40px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
button {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
|
||||||
|
export function PaperGuideSelector({ setSettings, settings }: UseSettings) {
|
||||||
|
return <Wrapper>
|
||||||
|
{GRID.map((e, i) => <Button
|
||||||
|
$active={e === settings.grid}
|
||||||
|
key={i}
|
||||||
|
onClick={() => {
|
||||||
|
setSettings({ grid: e });
|
||||||
|
}}
|
||||||
|
>{e}
|
||||||
|
</Button>)}
|
||||||
|
</Wrapper>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { FaFile } from "react-icons/fa6";
|
||||||
|
import { RibbonButton } from "./buttons/button-ribbon";
|
||||||
|
import type { UseSettings } from "../use/use-settings";
|
||||||
|
|
||||||
|
export function PaperOrientation({ setSettings, settings }: UseSettings) {
|
||||||
|
|
||||||
|
return <RibbonButton
|
||||||
|
icon={() => <FaFile style={{ transform: `rotate(${settings.landscape ? "90" : "0"}deg)` }} />}
|
||||||
|
onClick={() => {
|
||||||
|
setSettings({ landscape: !settings.landscape });
|
||||||
|
}}
|
||||||
|
name={settings.landscape ? "Landscape" : "Portrait"} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import styled from "styled-components";
|
||||||
|
import {
|
||||||
|
A0_HEIGHT, A0_WIDTH,
|
||||||
|
A1_HEIGHT, A1_WIDTH,
|
||||||
|
A2_HEIGHT, A2_WIDTH,
|
||||||
|
A3_HEIGHT, A3_WIDTH,
|
||||||
|
A4_HEIGHT, A4_WIDTH,
|
||||||
|
A5_HEIGHT, A5_WIDTH,
|
||||||
|
A6_HEIGHT, A6_WIDTH,
|
||||||
|
LETTER_HEIGHT, LETTER_WIDTH,
|
||||||
|
LEGAL_HEIGHT, LEGAL_WIDTH,
|
||||||
|
TABLOID_HEIGHT, TABLOID_WIDTH,
|
||||||
|
LEDGER_HEIGHT, LEDGER_WIDTH,
|
||||||
|
|
||||||
|
} from "../constants";
|
||||||
|
import { Button } from "../styles";
|
||||||
|
import type { UseSettings } from "../use/use-settings";
|
||||||
|
import { Popup } from "../handler/popup";
|
||||||
|
|
||||||
|
const Wrapper = styled.div`
|
||||||
|
height: 100%;
|
||||||
|
width: 130px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
button {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
interface Paper {
|
||||||
|
name: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPaper(name: string, width: number, height: number): Paper {
|
||||||
|
return { name, width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
const papers = [
|
||||||
|
createPaper("A0", A0_WIDTH, A0_HEIGHT),
|
||||||
|
createPaper("A1", A1_WIDTH, A1_HEIGHT),
|
||||||
|
createPaper("A2", A2_WIDTH, A2_HEIGHT),
|
||||||
|
createPaper("A3", A3_WIDTH, A3_HEIGHT),
|
||||||
|
createPaper("A4", A4_WIDTH, A4_HEIGHT),
|
||||||
|
createPaper("A5", A5_WIDTH, A5_HEIGHT),
|
||||||
|
createPaper("A6", A6_WIDTH, A6_HEIGHT),
|
||||||
|
|
||||||
|
createPaper("Letter", LETTER_WIDTH, LETTER_HEIGHT),
|
||||||
|
createPaper("Legal", LEGAL_WIDTH, LEGAL_HEIGHT),
|
||||||
|
createPaper("Tabloid", TABLOID_WIDTH, TABLOID_HEIGHT),
|
||||||
|
createPaper("Ledger", LEDGER_WIDTH, LEDGER_HEIGHT),
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
export function PaperSizeSelector({ setSettings, settings, popup }: UseSettings & { popup: Popup }) {
|
||||||
|
const custom = papers.find(e => settings.paperWidth === e.width && settings.paperHeight === e.height);
|
||||||
|
return <Wrapper>
|
||||||
|
{papers.map((e, i) => <Button
|
||||||
|
$active={settings.paperWidth === e.width && settings.paperHeight === e.height}
|
||||||
|
key={i}
|
||||||
|
onClick={() => {
|
||||||
|
setSettings({ paperWidth: e.width, paperHeight: e.height });
|
||||||
|
}}
|
||||||
|
>{e.name}
|
||||||
|
</Button>)}
|
||||||
|
<Button
|
||||||
|
$active={!custom}
|
||||||
|
onClick={async () => {
|
||||||
|
const result = await popup.prompt(
|
||||||
|
"Paper size",
|
||||||
|
`Enter paper size like <WIDTH>x<HEIGHT> (${A4_WIDTH}x${A4_HEIGHT})`,
|
||||||
|
`${settings.paperWidth}x${settings.paperHeight}`
|
||||||
|
);
|
||||||
|
if (result) {
|
||||||
|
const [width, height] = result.split("x").map(e => Math.max(parseInt(e, 10), 1));
|
||||||
|
if (isNaN(width) || isNaN(height)) {
|
||||||
|
popup.alert("Paper size", "Invalid size");
|
||||||
|
} else {
|
||||||
|
setSettings({
|
||||||
|
paperWidth: width,
|
||||||
|
paperHeight: height
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}>Custom</Button>
|
||||||
|
</Wrapper>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import styled, { keyframes } from "styled-components";
|
||||||
|
import { useState, type JSX } from "react";
|
||||||
|
import { type PopupResolver, Popup } from "../handler/popup";
|
||||||
|
|
||||||
|
const fadeIn = keyframes`
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const slideDown = keyframes`
|
||||||
|
from { transform: translateY(-24px); opacity: 0; }
|
||||||
|
to { transform: translateY(0); opacity: 1; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Overlay = styled.div`
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.65);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 9999;
|
||||||
|
animation: ${fadeIn} 0.15s ease-in-out;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Dialog = styled.div`
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
border: 1px solid var(--secondary-color);
|
||||||
|
min-width: 360px;
|
||||||
|
max-width: 520px;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
animation: ${slideDown} 0.18s ease-out;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TitleBar = styled.div`
|
||||||
|
border-bottom: 1px solid var(--secondary-color);
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #888;
|
||||||
|
user-select: none;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Body = styled.div`
|
||||||
|
padding: 24px 20px;
|
||||||
|
color: #d4d4d4;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
flex: 1;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const CustomBody = styled(Body)`
|
||||||
|
padding: 0;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Input = styled.input`
|
||||||
|
width: calc(100% - 24px);
|
||||||
|
background: #0d0d0d;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-top: none;
|
||||||
|
color: #e0e0e0;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
outline: none;
|
||||||
|
margin-top: 12px;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: #666;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Footer = styled.div`
|
||||||
|
display: flex;
|
||||||
|
border-top: 1px solid #2a2a2a;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Btn = styled.button<{ $primary?: boolean }>`
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
background: ${({ $primary }) => ($primary ? "#343434" : "#000000")};
|
||||||
|
color: ${({ $primary }) => ($primary ? "#ffffff" : "#777")};
|
||||||
|
border-right: 1px solid #2a2a2a;
|
||||||
|
transition:
|
||||||
|
background 0.1s,
|
||||||
|
color 0.1s;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: ${({ $primary }) => ($primary ? "#6e6e6e" : "#2a2a2a")};
|
||||||
|
color: ${({ $primary }) => ($primary ? "#000" : "#aaa")};
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: ${({ $primary }) => ($primary ? "#ccc" : "#111")};
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
function AlertPopup({ popup }: { popup: PopupResolver }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Body>{popup.message}</Body>
|
||||||
|
<Footer>
|
||||||
|
<Btn $primary onClick={() => popup.resolve(undefined)}>
|
||||||
|
OK
|
||||||
|
</Btn>
|
||||||
|
</Footer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConfirmPopup({ popup }: { popup: PopupResolver }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Body>{popup.message}</Body>
|
||||||
|
<Footer>
|
||||||
|
<Btn onClick={() => popup.resolve(false)}>Cancel</Btn>
|
||||||
|
<Btn $primary onClick={() => popup.resolve(true)}>
|
||||||
|
Confirm
|
||||||
|
</Btn>
|
||||||
|
</Footer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PromptPopup({ popup }: { popup: PopupResolver }) {
|
||||||
|
const typed = popup as typeof popup & { _default?: string };
|
||||||
|
const [value, setValue] = useState(typed._default ?? "");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Body>
|
||||||
|
{popup.message}
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
value={value}
|
||||||
|
placeholder={typed._default ?? ""}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") popup.resolve(value);
|
||||||
|
if (e.key === "Escape") popup.resolve(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Body>
|
||||||
|
<Footer>
|
||||||
|
<Btn onClick={() => popup.resolve(null)}>Cancel</Btn>
|
||||||
|
<Btn $primary onClick={() => popup.resolve(value)}>
|
||||||
|
Submit
|
||||||
|
</Btn>
|
||||||
|
</Footer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CustomPopup({ popup }: { popup: PopupResolver }) {
|
||||||
|
const typed = popup as typeof popup & { element: JSX.Element };
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CustomBody>{typed.element}</CustomBody>
|
||||||
|
<Footer>
|
||||||
|
<Btn $primary onClick={() => popup.resolve(undefined)}>
|
||||||
|
Close
|
||||||
|
</Btn>
|
||||||
|
</Footer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopupBody({ popup }: { popup: PopupResolver }) {
|
||||||
|
if (Popup.isAlert(popup)) return <AlertPopup popup={popup} />;
|
||||||
|
if (Popup.isConfirm(popup)) return <ConfirmPopup popup={popup} />;
|
||||||
|
if (Popup.isPrompt(popup)) return <PromptPopup popup={popup} />;
|
||||||
|
if (Popup.isCustom(popup)) return <CustomPopup popup={popup} />;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PopupRendererProps {
|
||||||
|
popup: Popup;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PopupRenderer({ popup }: PopupRendererProps) {
|
||||||
|
const { pendingPopups } = popup.use();
|
||||||
|
|
||||||
|
const current = pendingPopups[0];
|
||||||
|
if (!current) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Overlay>
|
||||||
|
<Dialog>
|
||||||
|
<TitleBar>{current.title}</TitleBar>
|
||||||
|
<PopupBody popup={current} />
|
||||||
|
</Dialog>
|
||||||
|
</Overlay>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { ImageEditor } from "../handler/image-editor";
|
||||||
|
import type { TracerOptions } from "../interface";
|
||||||
|
import { NumericInputWithLabelTable } from "./numeric-labeld-input";
|
||||||
|
|
||||||
|
|
||||||
|
export function SvgTracerOptions({ selected }: { selected: ImageEditor[] }) {
|
||||||
|
const [options, setOptions] = useState(selected[0]?.tracerOptions ?? {});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOptions(selected[0]?.tracerOptions ?? {});
|
||||||
|
}, [selected.map(e => e.id).join("")]);
|
||||||
|
|
||||||
|
const setValue = (key: keyof TracerOptions, value: number) => {
|
||||||
|
setOptions({ ...options, [key]: value });
|
||||||
|
selected.forEach(e => {
|
||||||
|
e.setTracerSvg({ ...options, [key]: value });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return <tbody>
|
||||||
|
<NumericInputWithLabelTable
|
||||||
|
name="Ltres"
|
||||||
|
value={options.ltres}
|
||||||
|
onChange={value => setValue("ltres", value)}
|
||||||
|
/>
|
||||||
|
<NumericInputWithLabelTable
|
||||||
|
name="Qtres"
|
||||||
|
value={options.qtres}
|
||||||
|
onChange={value => setValue("qtres", value)}
|
||||||
|
/>
|
||||||
|
<NumericInputWithLabelTable
|
||||||
|
name="Path omit"
|
||||||
|
value={options.pathomit}
|
||||||
|
onChange={value => setValue("pathomit", value)}
|
||||||
|
/>
|
||||||
|
<NumericInputWithLabelTable
|
||||||
|
name="Round corners"
|
||||||
|
value={options.roundcoords}
|
||||||
|
onChange={value => setValue("roundcoords", value)}
|
||||||
|
/>
|
||||||
|
</tbody>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { styled } from "styled-components";
|
||||||
|
import type { CanvasHandlerProps } from "../handler/interfaces";
|
||||||
|
import { useTool } from "../use/use-tool";
|
||||||
|
import { Icon2525 } from "../styles";
|
||||||
|
|
||||||
|
const Wrapper = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
border-top: 1px solid var(--secondary-color);
|
||||||
|
`;
|
||||||
|
|
||||||
|
export function Toolbar({ handler }: CanvasHandlerProps) {
|
||||||
|
const { currentTool, tools, setTool } = useTool(handler);
|
||||||
|
return (
|
||||||
|
<Wrapper>
|
||||||
|
{tools.map((tool) => (
|
||||||
|
<Icon2525
|
||||||
|
$selected={currentTool === tool.key}
|
||||||
|
key={tool.key}
|
||||||
|
onClick={() => setTool(tool.key)}
|
||||||
|
className={currentTool === tool.key ? "active" : ""}
|
||||||
|
>
|
||||||
|
{tool.icon()}
|
||||||
|
</Icon2525>
|
||||||
|
))}
|
||||||
|
</Wrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
export const A0_WIDTH = 841;
|
||||||
|
export const A0_HEIGHT = 1189;
|
||||||
|
|
||||||
|
export const A1_WIDTH = 594;
|
||||||
|
export const A1_HEIGHT = 841;
|
||||||
|
|
||||||
|
export const A2_WIDTH = 420;
|
||||||
|
export const A2_HEIGHT = 594;
|
||||||
|
|
||||||
|
export const A3_WIDTH = 297;
|
||||||
|
export const A3_HEIGHT = 420;
|
||||||
|
|
||||||
|
export const A4_WIDTH = 210;
|
||||||
|
export const A4_HEIGHT = 297;
|
||||||
|
|
||||||
|
export const A5_WIDTH = 148;
|
||||||
|
export const A5_HEIGHT = 210;
|
||||||
|
|
||||||
|
export const A6_WIDTH = 105;
|
||||||
|
export const A6_HEIGHT = 148;
|
||||||
|
|
||||||
|
export const LETTER_WIDTH = 216;
|
||||||
|
export const LETTER_HEIGHT = 279;
|
||||||
|
|
||||||
|
export const LEGAL_WIDTH = 216;
|
||||||
|
export const LEGAL_HEIGHT = 356;
|
||||||
|
|
||||||
|
export const TABLOID_WIDTH = 279;
|
||||||
|
export const TABLOID_HEIGHT = 432;
|
||||||
|
|
||||||
|
export const LEDGER_WIDTH = 432;
|
||||||
|
export const LEDGER_HEIGHT = 279;
|
||||||
|
|
||||||
|
export const MM_TO_INCH = 25.4;
|
||||||
|
|
||||||
|
export const VERSION = "1.0.0";
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { MM_TO_INCH } from "./constants";
|
||||||
|
import type { VectraceHandler } from "./handler/vectrace-handler";
|
||||||
|
|
||||||
|
export function drawCanvas(canvas: HTMLCanvasElement, handler: VectraceHandler) {
|
||||||
|
const settings = handler.globalSettingsHandler.settings;
|
||||||
|
const dpi = settings.DPI;
|
||||||
|
const width = Math.round((settings.paperWidth / MM_TO_INCH) * dpi);
|
||||||
|
const height = Math.round((settings.paperHeight / MM_TO_INCH) * dpi);
|
||||||
|
if (canvas.width !== width) {
|
||||||
|
canvas.width = width;
|
||||||
|
}
|
||||||
|
if (canvas.height !== height) {
|
||||||
|
canvas.height = height;
|
||||||
|
}
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
ctx.lineWidth = 5;
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
ctx.strokeStyle = "#ccc";
|
||||||
|
ctx.strokeRect(0, 0, width, height);
|
||||||
|
|
||||||
|
ctx.strokeStyle = "#000000";
|
||||||
|
//ctx.fillStyle = "#00bbff24";
|
||||||
|
ctx.fillStyle = "#00bbffea";
|
||||||
|
|
||||||
|
for (let i = handler.imagesRenders.length - 1; i >= 0; i--) {
|
||||||
|
const image = handler.imagesRenders[i];
|
||||||
|
if (image.visible) {
|
||||||
|
// const base64 = btoa(new TextEncoder().encode(image.svg).reduce((acc, byte) => acc + String.fromCharCode(byte), ""));
|
||||||
|
// const dataUrl = `data:image/svg+xml;base64,${base64}`;
|
||||||
|
// drawSVGOnCanvasScaled(image.svg, canvas);
|
||||||
|
ctx.drawImage(image.image, image.x, image.y, image.image.width * image.scale, image.image.height * image.scale);
|
||||||
|
//ctx.drawImage(image.image, image.x, image.y, image.image.naturalWidth * image.scale, image.image.naturalHeight * image.scale);
|
||||||
|
// if (handler.isSelected(image.id)) {
|
||||||
|
// ctx.strokeRect(image.x, image.y, image.image.width * image.scale, image.image.height * image.scale);
|
||||||
|
// ctx.fillRect(image.x, image.y, image.image.width * image.scale, image.image.height * image.scale);
|
||||||
|
// }
|
||||||
|
// ctx.drawImage(image.svgCanvas.image, image.x, image.y, image.image.width * image.scale, image.image.height * image.scale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { throttle } from "lodash";
|
||||||
|
import type { GlobalSettings } from "../interface";
|
||||||
|
import { BasicEventEmitter } from "../utils/eventEmitter";
|
||||||
|
import { A4_HEIGHT, A4_WIDTH } from "../constants";
|
||||||
|
|
||||||
|
function defaultGlobalSetting(): GlobalSettings {
|
||||||
|
return {
|
||||||
|
DPI: 96,
|
||||||
|
scale: 1,
|
||||||
|
paperHeight: A4_HEIGHT,
|
||||||
|
paperWidth: A4_WIDTH,
|
||||||
|
grid: "none",
|
||||||
|
landscape: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GlobalSettingsHandler {
|
||||||
|
private STORAGE_SETTINGS_KEY = "settings";
|
||||||
|
public readonly emitter = new BasicEventEmitter<{
|
||||||
|
"settings-update": [];
|
||||||
|
}>();
|
||||||
|
private _settings: GlobalSettings = defaultGlobalSetting();
|
||||||
|
private store!: LocalForage;
|
||||||
|
|
||||||
|
async init(store: LocalForage) {
|
||||||
|
this.store = store;
|
||||||
|
this._settings = (await this.store.getItem<GlobalSettings>(this.STORAGE_SETTINGS_KEY)) || this._settings;
|
||||||
|
}
|
||||||
|
setSettings(settings: Partial<GlobalSettings>) {
|
||||||
|
const entries = Object.entries(settings);
|
||||||
|
let emitUpdate = false;
|
||||||
|
for (const [key, value] of entries) {
|
||||||
|
const diff = (this._settings as any)[key] !== value;
|
||||||
|
(this._settings as any)[key] = value;
|
||||||
|
if (diff) {
|
||||||
|
emitUpdate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (emitUpdate) {
|
||||||
|
this.emitter.emit("settings-update");
|
||||||
|
this.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async clear() {
|
||||||
|
this._settings = defaultGlobalSetting();
|
||||||
|
await this.store.setItem(this.STORAGE_SETTINGS_KEY, this._settings);
|
||||||
|
}
|
||||||
|
get settings() {
|
||||||
|
return { ...this._settings };
|
||||||
|
}
|
||||||
|
private save = throttle(async () => {
|
||||||
|
await this.store.setItem(this.STORAGE_SETTINGS_KEY, this._settings);
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { BasicEventEmitter } from "../utils/eventEmitter";
|
||||||
|
import type { DefinedTool } from "./interfaces";
|
||||||
|
import { TOOL_OBJECT_MOVE, TOOLS } from "./tools";
|
||||||
|
|
||||||
|
export class ToolHandler {
|
||||||
|
private STORAGE_TOOL_KEY = "tool";
|
||||||
|
public readonly emitter = new BasicEventEmitter<{
|
||||||
|
select: [string];
|
||||||
|
}>();
|
||||||
|
public readonly tools = TOOLS;
|
||||||
|
private currentTool: string = TOOL_OBJECT_MOVE.key;
|
||||||
|
private store!: LocalForage;
|
||||||
|
|
||||||
|
async init(store: LocalForage) {
|
||||||
|
this.store = store;
|
||||||
|
|
||||||
|
const tool = await this.store.getItem<string>(this.STORAGE_TOOL_KEY);
|
||||||
|
if (tool && this.tools.map((e) => e.key).includes(tool)) {
|
||||||
|
this.currentTool = tool;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setTool(toolKey: string) {
|
||||||
|
if (this.currentTool !== toolKey) {
|
||||||
|
this.currentTool = toolKey;
|
||||||
|
this.emitter.emit("select", toolKey);
|
||||||
|
this.store.setItem<string>(this.STORAGE_TOOL_KEY, this.currentTool);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isToolSelected(tool: DefinedTool<any>) {
|
||||||
|
return this.currentTool === tool.key;
|
||||||
|
}
|
||||||
|
async clear() {
|
||||||
|
this.setTool(TOOL_OBJECT_MOVE.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
get tool() {
|
||||||
|
return this.currentTool;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import type { CanvasImageEx, TracerOptions } from "../interface";
|
||||||
|
|
||||||
|
export class ImageEditor {
|
||||||
|
public readonly id: string;
|
||||||
|
private query: () => CanvasImageEx;
|
||||||
|
onPropsChange: (data: Partial<CanvasImageEx>) => void;
|
||||||
|
private isSelected: () => boolean;
|
||||||
|
private select: () => void;
|
||||||
|
constructor(
|
||||||
|
id: string,
|
||||||
|
query: () => CanvasImageEx,
|
||||||
|
onPropsChange: (data: Partial<CanvasImageEx>) => void,
|
||||||
|
isSelected: () => boolean,
|
||||||
|
select: () => void,
|
||||||
|
) {
|
||||||
|
this.id = id;
|
||||||
|
this.query = query;
|
||||||
|
this.onPropsChange = onPropsChange;
|
||||||
|
this.isSelected = isSelected;
|
||||||
|
this.select = select;
|
||||||
|
}
|
||||||
|
move(x: number, y: number) {
|
||||||
|
this.onPropsChange({ x, y });
|
||||||
|
}
|
||||||
|
get selected() {
|
||||||
|
return this.isSelected();
|
||||||
|
}
|
||||||
|
toggleSelected() {
|
||||||
|
return this.select();
|
||||||
|
}
|
||||||
|
get image() {
|
||||||
|
return this.query().image;
|
||||||
|
}
|
||||||
|
get url() {
|
||||||
|
return this.query().url;
|
||||||
|
}
|
||||||
|
get canvas() {
|
||||||
|
return this.query().canvas;
|
||||||
|
}
|
||||||
|
get blob() {
|
||||||
|
return this.query().blob;
|
||||||
|
}
|
||||||
|
get name() {
|
||||||
|
return this.query().name;
|
||||||
|
}
|
||||||
|
get svg() {
|
||||||
|
return this.query().svg.data;
|
||||||
|
}
|
||||||
|
setName(name: string) {
|
||||||
|
this.onPropsChange({ name });
|
||||||
|
}
|
||||||
|
get tracerOptions() {
|
||||||
|
return this.query().svg.options;
|
||||||
|
}
|
||||||
|
setTracerSvg(options: TracerOptions) {
|
||||||
|
const svg = this.query().svg;
|
||||||
|
this.onPropsChange({
|
||||||
|
svg: {
|
||||||
|
options,
|
||||||
|
data: svg.data,
|
||||||
|
scale: svg.scale,
|
||||||
|
dirty: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
get x() {
|
||||||
|
return this.query().x;
|
||||||
|
}
|
||||||
|
set x(x: number) {
|
||||||
|
this.onPropsChange({ x });
|
||||||
|
}
|
||||||
|
get y() {
|
||||||
|
return this.query().y;
|
||||||
|
}
|
||||||
|
set y(y: number) {
|
||||||
|
this.onPropsChange({ y });
|
||||||
|
}
|
||||||
|
get scale() {
|
||||||
|
return this.query().scale;
|
||||||
|
}
|
||||||
|
setScale(scale: number) {
|
||||||
|
this.onPropsChange({ scale });
|
||||||
|
}
|
||||||
|
get visible() {
|
||||||
|
return this.query().visible;
|
||||||
|
}
|
||||||
|
setVisible(visible: boolean) {
|
||||||
|
this.onPropsChange({ visible });
|
||||||
|
}
|
||||||
|
get locked() {
|
||||||
|
return this.query().locked;
|
||||||
|
}
|
||||||
|
setLock(locked: boolean) {
|
||||||
|
this.onPropsChange({ locked });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { JSX } from "react";
|
||||||
|
import type { VectraceHandler } from "./vectrace-handler";
|
||||||
|
import type { ImageEditor } from "./image-editor";
|
||||||
|
|
||||||
|
export interface CanvasHandlerProps {
|
||||||
|
handler: VectraceHandler;
|
||||||
|
}
|
||||||
|
export interface ImageEditorProps {
|
||||||
|
image: ImageEditor;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CanvasHandleImageEditorProps extends CanvasHandlerProps, ImageEditorProps { }
|
||||||
|
|
||||||
|
export type DefinedTool<T = any, K extends string = string> = {
|
||||||
|
key: K;
|
||||||
|
name: string;
|
||||||
|
icon: () => JSX.Element;
|
||||||
|
defaultSettings: () => T;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReturnTypeTool<T extends DefinedTool<any>> = T extends DefinedTool<infer R> ? R : never;
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { useEffect, useState, type JSX } from "react";
|
||||||
|
import { BasicEventEmitter } from "../utils/eventEmitter";
|
||||||
|
import { removeItem } from "../utils/collection";
|
||||||
|
|
||||||
|
interface PopupType {
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PopupAlert extends PopupType {
|
||||||
|
type: "alert";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PopupConfirm extends PopupType {
|
||||||
|
type: "confirm";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PopupPrompt extends PopupType {
|
||||||
|
type: "prompt";
|
||||||
|
_default?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PopupCustom extends PopupType {
|
||||||
|
type: "custom";
|
||||||
|
element: JSX.Element;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PopupTypes = PopupAlert | PopupConfirm | PopupPrompt | PopupCustom;
|
||||||
|
|
||||||
|
export interface PopupResolver extends PopupType {
|
||||||
|
resolve: (value: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function promiseSpy<A = any>() {
|
||||||
|
let _resolve!: (value: A | PromiseLike<A>) => void;
|
||||||
|
let _reject!: (reason?: any) => void;
|
||||||
|
const promise = new Promise<A>((resolve, reject) => {
|
||||||
|
_resolve = resolve;
|
||||||
|
_reject = reject;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
promise,
|
||||||
|
resolve: _resolve,
|
||||||
|
reject: _reject,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Popup {
|
||||||
|
public readonly emitter = new BasicEventEmitter<{
|
||||||
|
update: [PopupResolver[]];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
private list: PopupResolver[] = [];
|
||||||
|
|
||||||
|
private push(ref: PopupResolver) {
|
||||||
|
this.list.push(ref);
|
||||||
|
this.emitter.emit("update", [...this.list]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async alert(title: string, message: string) {
|
||||||
|
const spy = promiseSpy<any>();
|
||||||
|
const ref: PopupResolver = {
|
||||||
|
type: "alert",
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
resolve: (value: any) => {
|
||||||
|
removeItem(this.list, ref);
|
||||||
|
this.emitter.emit("update", [...this.list]);
|
||||||
|
spy.resolve(value);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
this.push(ref);
|
||||||
|
return spy.promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async confirm(title: string, message: string) {
|
||||||
|
const spy = promiseSpy<any>();
|
||||||
|
const ref: PopupResolver = {
|
||||||
|
type: "confirm",
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
resolve: (value: any) => {
|
||||||
|
removeItem(this.list, ref);
|
||||||
|
this.emitter.emit("update", [...this.list]);
|
||||||
|
spy.resolve(value);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
this.push(ref);
|
||||||
|
return spy.promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async prompt(title: string, message: string, _default?: string): Promise<string | null> {
|
||||||
|
const spy = promiseSpy<string | null>();
|
||||||
|
const ref = {
|
||||||
|
type: "prompt",
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
_default,
|
||||||
|
resolve: (value: any) => {
|
||||||
|
removeItem(this.list, ref);
|
||||||
|
this.emitter.emit("update", [...this.list]);
|
||||||
|
spy.resolve(value);
|
||||||
|
},
|
||||||
|
} as PopupResolver;
|
||||||
|
this.push(ref);
|
||||||
|
return spy.promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async custom(title: string, message: string, element: JSX.Element) {
|
||||||
|
const spy = promiseSpy<string | null>();
|
||||||
|
const ref = {
|
||||||
|
type: "custom",
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
element,
|
||||||
|
resolve: (value: any) => {
|
||||||
|
removeItem(this.list, ref);
|
||||||
|
this.emitter.emit("update", [...this.list]);
|
||||||
|
spy.resolve(value);
|
||||||
|
},
|
||||||
|
} as PopupResolver;
|
||||||
|
this.push(ref);
|
||||||
|
return spy.promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
static isAlert(data: PopupType): data is PopupAlert {
|
||||||
|
return data.type === "alert";
|
||||||
|
}
|
||||||
|
static isConfirm(data: PopupType): data is PopupConfirm {
|
||||||
|
return data.type === "confirm";
|
||||||
|
}
|
||||||
|
static isPrompt(data: PopupType): data is PopupPrompt {
|
||||||
|
return data.type === "prompt";
|
||||||
|
}
|
||||||
|
static isCustom(data: PopupType): data is PopupCustom {
|
||||||
|
return data.type === "custom";
|
||||||
|
}
|
||||||
|
|
||||||
|
use() {
|
||||||
|
const [pendingPopups, setPendingPopups] = useState<PopupResolver[]>([...this.list]);
|
||||||
|
useEffect(() => {
|
||||||
|
return this.emitter.on("update", (items) => {
|
||||||
|
setPendingPopups(items);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
return { pendingPopups };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { FaArrowsUpDownLeftRight, FaExpand } from "react-icons/fa6";
|
||||||
|
import type { DefinedTool } from "./interfaces";
|
||||||
|
import type { JSX } from "react";
|
||||||
|
import { FaMousePointer } from "react-icons/fa";
|
||||||
|
|
||||||
|
export const TOOLS: DefinedTool<any>[] = [];
|
||||||
|
|
||||||
|
export function defineTool<T = any, K extends string = string>(
|
||||||
|
key: K,
|
||||||
|
name: string,
|
||||||
|
icon: () => JSX.Element,
|
||||||
|
defaultSettings: () => T = () => null,
|
||||||
|
): DefinedTool<T, K> {
|
||||||
|
const tool: DefinedTool<T, K> = {
|
||||||
|
key,
|
||||||
|
name,
|
||||||
|
icon,
|
||||||
|
defaultSettings,
|
||||||
|
};
|
||||||
|
TOOLS.push(tool);
|
||||||
|
return tool;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TOOL_SELECT = defineTool<null>("select", "Select", () => <FaMousePointer />);
|
||||||
|
export const TOOL_CANVAS_MOVE = defineTool<null>("move", "move-canvas", () => <FaArrowsUpDownLeftRight />);
|
||||||
|
export const TOOL_OBJECT_MOVE = defineTool<null>("move-object", "Move object", () => <FaExpand />);
|
||||||
@@ -0,0 +1,511 @@
|
|||||||
|
import localforage from "localforage";
|
||||||
|
import { BasicEventEmitter } from "../utils/eventEmitter";
|
||||||
|
import type { CanvasImage, CanvasImageEx, ProjectConfig, TracerOptions } from "../interface";
|
||||||
|
import { pushUnique, removeItem } from "../utils/collection";
|
||||||
|
import { cloneDeep, isEqual, last, throttle } from "lodash";
|
||||||
|
import { ImageEditor } from "./image-editor";
|
||||||
|
import { ToolHandler } from "./handler-tools";
|
||||||
|
import { GlobalSettingsHandler } from "./handler-global-settings";
|
||||||
|
import { Popup } from "./popup";
|
||||||
|
import { imageToLaserSVG } from "../svg-tracer";
|
||||||
|
import { urlToImage, canvasToBlob, imageToCanvas } from "../utils/image";
|
||||||
|
import { loadFileAsDataUrl, makeFilenameSafe } from "../utils/generic";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import JSZip from "jszip";
|
||||||
|
import { downloadBlob } from "../utils/download";
|
||||||
|
|
||||||
|
declare const __APP_VERSION__: string;
|
||||||
|
declare const __AUTHOR__: {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class VectraceHandler {
|
||||||
|
public readonly VERSION = __APP_VERSION__;
|
||||||
|
public readonly AUTHOR = __AUTHOR__;
|
||||||
|
private readonly STORAGE_IMAGE_KEY = "images";
|
||||||
|
private readonly STORAGE_NAME_KEY = "NAME";
|
||||||
|
private _projectName = "";
|
||||||
|
private readonly STROKE_WIDTH = 2;
|
||||||
|
public readonly popup = new Popup();
|
||||||
|
private store!: LocalForage;
|
||||||
|
public readonly emitter = new BasicEventEmitter<{
|
||||||
|
update: [string, string[]];
|
||||||
|
add: [string];
|
||||||
|
remove: [string];
|
||||||
|
name: [string];
|
||||||
|
select: [string[], string | undefined | "*", string | undefined | "*"];
|
||||||
|
"array-length": [number];
|
||||||
|
ready: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
private _images: CanvasImage[] = [];
|
||||||
|
private _renderedImages: CanvasImageEx[] = [];
|
||||||
|
private _selected: string[] = [];
|
||||||
|
private _ready = false;
|
||||||
|
private _toolHandler = new ToolHandler();
|
||||||
|
private _globalSettingsHandler = new GlobalSettingsHandler();
|
||||||
|
private POOL_RATE = 250;
|
||||||
|
private queue: { id: string, action: () => Promise<void> | void }[] = [];
|
||||||
|
private frame!: number;
|
||||||
|
private destroyed = false;
|
||||||
|
|
||||||
|
private tick = async () => {
|
||||||
|
if (this.destroyed) return;
|
||||||
|
const process = this.queue.shift();
|
||||||
|
if (process) {
|
||||||
|
await Promise.resolve(process.action()).catch(console.log);
|
||||||
|
}
|
||||||
|
this.frame = window.setTimeout(this.tick, this.POOL_RATE);
|
||||||
|
};
|
||||||
|
|
||||||
|
async init(name: string) {
|
||||||
|
if (this.store) return;
|
||||||
|
this._projectName = localStorage.getItem(this.STORAGE_NAME_KEY) || "";
|
||||||
|
if (!this._projectName) {
|
||||||
|
await this.promptSetName();
|
||||||
|
}
|
||||||
|
(window as any).c = this;
|
||||||
|
this.store = localforage.createInstance({
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
const [images] = await Promise.all([
|
||||||
|
this.store.getItem<CanvasImage[]>(this.STORAGE_IMAGE_KEY),
|
||||||
|
this._toolHandler.init(this.store),
|
||||||
|
this._globalSettingsHandler.init(this.store),
|
||||||
|
]);
|
||||||
|
|
||||||
|
this._images = images || [];
|
||||||
|
this._renderedImages = await Promise.all(this._images.map((e) => this.renderImage(e)));
|
||||||
|
this._ready = true;
|
||||||
|
this.tick();
|
||||||
|
this.emitter.emit("ready");
|
||||||
|
}
|
||||||
|
destroy() {
|
||||||
|
clearTimeout(this.frame);
|
||||||
|
this.destroyed = true;
|
||||||
|
this.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
async promptSetName() {
|
||||||
|
const fn = (message: string) => this._ready ? this.popup.prompt("Project name", message) : Promise.resolve(window.prompt(message));
|
||||||
|
this.setName(await fn("Enter your project name here") || "");
|
||||||
|
|
||||||
|
}
|
||||||
|
async setName(name: string) {
|
||||||
|
this._projectName = makeFilenameSafe(name);
|
||||||
|
this.emitter.emit("name", this._projectName);
|
||||||
|
localStorage.setItem(this.STORAGE_NAME_KEY, this._projectName);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
async clear() {
|
||||||
|
for (const image of [...this._images]) {
|
||||||
|
this.deleteImage(image.id);
|
||||||
|
}
|
||||||
|
this._images = [];
|
||||||
|
this._renderedImages = [];
|
||||||
|
this._toolHandler.clear();
|
||||||
|
await Promise.all([
|
||||||
|
await this.store.setItem(this.STORAGE_IMAGE_KEY, this._images),
|
||||||
|
await this._globalSettingsHandler.clear()
|
||||||
|
]);
|
||||||
|
this._selected = [];
|
||||||
|
localStorage.setItem(this.STORAGE_NAME_KEY, "");
|
||||||
|
this.emitter.emit("select", [], "*", "*");
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportProject() {
|
||||||
|
const zip = new JSZip();
|
||||||
|
const images = zip.folder("images")!;
|
||||||
|
|
||||||
|
for (const image of this._renderedImages) {
|
||||||
|
images.file(image.id, image.buffer);
|
||||||
|
}
|
||||||
|
const data: ProjectConfig = {
|
||||||
|
version: this.VERSION,
|
||||||
|
name: this._projectName,
|
||||||
|
settings: this._globalSettingsHandler.settings,
|
||||||
|
tool: this._toolHandler.tool,
|
||||||
|
images: this._images
|
||||||
|
};
|
||||||
|
zip.file("config.json", new Blob([JSON.stringify(data)], { type: "application/json" }));
|
||||||
|
const blob = await zip.generateAsync({ type: "blob" });
|
||||||
|
downloadBlob(blob, `${this.projectName}.ppd`);
|
||||||
|
}
|
||||||
|
|
||||||
|
importProject() {
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "file";
|
||||||
|
input.accept = ".ppd,application/octet-stream";
|
||||||
|
input.addEventListener("change", async () => {
|
||||||
|
const file = [...(input.files || [])][0];
|
||||||
|
if (file) {
|
||||||
|
const zip = await JSZip.loadAsync(file);
|
||||||
|
const config = JSON.parse(await zip.files["config.json"].async("string")) as ProjectConfig;
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
|
||||||
|
const entries = Object.keys(zip.files);
|
||||||
|
for (let i = 0; i < entries.length; i++) {
|
||||||
|
const name = entries[i];
|
||||||
|
|
||||||
|
if (!name.startsWith("images/"))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
const entry = zip.files[name];
|
||||||
|
if (entry.dir)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
const buffer = await entry.async("string");
|
||||||
|
|
||||||
|
map.set(last(name.split("/"))!, buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
const images: CanvasImage[] = [];
|
||||||
|
for (const img of config.images) {
|
||||||
|
if (map.has(img.id)) {
|
||||||
|
images.push({
|
||||||
|
id: img.id,
|
||||||
|
buffer: map.get(img.id)!,
|
||||||
|
locked: img.locked,
|
||||||
|
name: img.name,
|
||||||
|
scale: img.scale,
|
||||||
|
svg: img.svg,
|
||||||
|
visible: img.visible,
|
||||||
|
x: img.x,
|
||||||
|
y: img.y
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (await this.popup.confirm("Import", "Are you sure you want to import. Any unsaved changes will be discarded")) {
|
||||||
|
await this.clear();
|
||||||
|
|
||||||
|
const renderedImages = await Promise.all(images.map((e) => this.renderImage(e)));
|
||||||
|
|
||||||
|
this._images = images;
|
||||||
|
this._renderedImages = renderedImages;
|
||||||
|
for (const add of images) {
|
||||||
|
this.emitter.emit("add", add.id);
|
||||||
|
//await new Promise<void>(e => requestAnimationFrame(() => e()));
|
||||||
|
}
|
||||||
|
this.emitter.emit("array-length", images.length);
|
||||||
|
this._globalSettingsHandler.setSettings(config.settings);
|
||||||
|
this._toolHandler.setTool(config.tool);
|
||||||
|
this.setName(config.name);
|
||||||
|
this.save();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.popup.alert("Error", "File not selected");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
input.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
get projectName() {
|
||||||
|
return this._projectName;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async renderImage(image: CanvasImage): Promise<CanvasImageEx> {
|
||||||
|
const imageBuffer = await urlToImage(image.buffer);
|
||||||
|
const canvas = imageToCanvas(imageBuffer);
|
||||||
|
const blob = await canvasToBlob(canvas);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: image.id,
|
||||||
|
name: image.name,
|
||||||
|
buffer: image.buffer,
|
||||||
|
x: image.x,
|
||||||
|
y: image.y,
|
||||||
|
scale: image.scale,
|
||||||
|
locked: image.locked,
|
||||||
|
visible: image.visible,
|
||||||
|
blob,
|
||||||
|
url,
|
||||||
|
canvas,
|
||||||
|
svg: image.svg,
|
||||||
|
|
||||||
|
image: imageBuffer,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
move(id: string, x: number, y: number) {
|
||||||
|
const { image, renderedImage } = this.getImagesEx(id);
|
||||||
|
if (image.locked) return;
|
||||||
|
renderedImage.x = image.x = Math.round(image.x + x);
|
||||||
|
renderedImage.y = image.y = Math.round(image.y + y);
|
||||||
|
this.emitter.emit("update", image.id, ["x", "y"]);
|
||||||
|
this.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
async importFile(file: File) {
|
||||||
|
const buffer = await loadFileAsDataUrl(file);
|
||||||
|
const imageElement = await urlToImage(buffer);
|
||||||
|
const options: TracerOptions = {};
|
||||||
|
const svg = imageToLaserSVG(imageElement, {
|
||||||
|
...options,
|
||||||
|
scale: 1,
|
||||||
|
strokewidth: this.STROKE_WIDTH
|
||||||
|
});
|
||||||
|
const f = file.name.split(".");
|
||||||
|
f.pop();
|
||||||
|
const fileName = f.join(".");
|
||||||
|
|
||||||
|
const image: CanvasImage = {
|
||||||
|
id: uuidv4(),
|
||||||
|
buffer,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
locked: false,
|
||||||
|
visible: true,
|
||||||
|
name: fileName,
|
||||||
|
svg: {
|
||||||
|
dirty: false,
|
||||||
|
data: svg,
|
||||||
|
scale: 1,
|
||||||
|
options
|
||||||
|
},
|
||||||
|
scale: 1,
|
||||||
|
};
|
||||||
|
const rendered = await this.renderImage(image);
|
||||||
|
this._images.push(image);
|
||||||
|
this._renderedImages.push(rendered);
|
||||||
|
|
||||||
|
this.emitter.emit("array-length", this._images.length);
|
||||||
|
this.emitter.emit("add", image.id);
|
||||||
|
this.save();
|
||||||
|
}
|
||||||
|
selectNext(x: number, y: number, reverse: boolean, add: boolean) {
|
||||||
|
const potentials = this._renderedImages.filter((e) => {
|
||||||
|
return e.visible && !e.locked &&
|
||||||
|
x >= e.x * this.globalSettingsHandler.settings.scale &&
|
||||||
|
x <= e.x * this.globalSettingsHandler.settings.scale + e.image.width * e.scale * this.globalSettingsHandler.settings.scale &&
|
||||||
|
y >= e.y * this.globalSettingsHandler.settings.scale &&
|
||||||
|
y <= e.y * this.globalSettingsHandler.settings.scale + e.image.height * e.scale * this.globalSettingsHandler.settings.scale;
|
||||||
|
});
|
||||||
|
if (potentials.length === 0) {
|
||||||
|
const copy = [...this._selected];
|
||||||
|
this._selected.length = 0;
|
||||||
|
if (copy.length) {
|
||||||
|
this.emitter.emit("select", this._selected, "*", "*");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastSelectedIndex = -1;
|
||||||
|
for (let i = 0; i < potentials.length; i++) {
|
||||||
|
if (this._selected.includes(potentials[i].id)) {
|
||||||
|
lastSelectedIndex = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (add) {
|
||||||
|
potentials.forEach((p) => {
|
||||||
|
if (!this._selected.includes(p.id)) {
|
||||||
|
this._selected.push(p.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
let nextIndex: number;
|
||||||
|
if (lastSelectedIndex === -1) {
|
||||||
|
nextIndex = reverse ? potentials.length - 1 : 0;
|
||||||
|
} else {
|
||||||
|
if (reverse) {
|
||||||
|
nextIndex = (lastSelectedIndex - 1 + potentials.length) % potentials.length;
|
||||||
|
} else {
|
||||||
|
nextIndex = (lastSelectedIndex + 1) % potentials.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._selected = [potentials[nextIndex].id];
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emitter.emit("select", this._selected, "*", "*");
|
||||||
|
}
|
||||||
|
|
||||||
|
get imagesData() {
|
||||||
|
return [...this._images];
|
||||||
|
}
|
||||||
|
get imagesRenders() {
|
||||||
|
return [...this._renderedImages];
|
||||||
|
}
|
||||||
|
private getImageById(id: string) {
|
||||||
|
return this._renderedImages.find((e) => e.id === id) || null;
|
||||||
|
}
|
||||||
|
private getImageByIdEx(id: string) {
|
||||||
|
const image = this.getImageById(id);
|
||||||
|
if (image) {
|
||||||
|
return image;
|
||||||
|
} else {
|
||||||
|
throw new Error("Image does not exist!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private getImages(id: string) {
|
||||||
|
const image = this._images.find((e) => e.id == id);
|
||||||
|
const renderedImage = this._renderedImages.find((e) => e.id == id);
|
||||||
|
if (image && renderedImage) {
|
||||||
|
return { image, renderedImage };
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private getImagesEx(id: string) {
|
||||||
|
const obj = this.getImages(id);
|
||||||
|
if (obj) {
|
||||||
|
return obj;
|
||||||
|
} else {
|
||||||
|
throw new Error("Image does not exist!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
enqueueForRedrawSvg(id: string) {
|
||||||
|
this.queue = this.queue.filter(e => e.id !== id);
|
||||||
|
|
||||||
|
this.queue.push({
|
||||||
|
id,
|
||||||
|
action: async () => {
|
||||||
|
this.redrawSvg(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
redrawSvg(id: string) {
|
||||||
|
const images = this.getImages(id);
|
||||||
|
if (!images) return;
|
||||||
|
|
||||||
|
const rendered = images.renderedImage;
|
||||||
|
const base = images.image;
|
||||||
|
|
||||||
|
const scale = rendered.scale;
|
||||||
|
const options: TracerOptions = {
|
||||||
|
...rendered.svg.options,
|
||||||
|
scale,
|
||||||
|
strokewidth: this.STROKE_WIDTH
|
||||||
|
};
|
||||||
|
|
||||||
|
const prevOptions = cloneDeep(options);
|
||||||
|
|
||||||
|
const svg = imageToLaserSVG(rendered.image, options);
|
||||||
|
|
||||||
|
const dirty = !isEqual(prevOptions, options);
|
||||||
|
rendered.svg.dirty = dirty;
|
||||||
|
base.svg.dirty = dirty;
|
||||||
|
|
||||||
|
base.svg.scale = scale;
|
||||||
|
rendered.svg.scale = scale;
|
||||||
|
|
||||||
|
if (!dirty) {
|
||||||
|
rendered.svg.options = options;
|
||||||
|
base.svg.options = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
rendered.svg.data = svg;
|
||||||
|
base.svg.data = svg;
|
||||||
|
|
||||||
|
this.emitter.emit(
|
||||||
|
"update",
|
||||||
|
id,
|
||||||
|
["svg", "svgCanvas"],
|
||||||
|
);
|
||||||
|
this.save();
|
||||||
|
}
|
||||||
|
isSelected(id: string) {
|
||||||
|
return this._selected.indexOf(id) !== -1;
|
||||||
|
}
|
||||||
|
setSelect(id: string, value: boolean) {
|
||||||
|
if (this.isSelected(id)) {
|
||||||
|
if (!value) {
|
||||||
|
removeItem(this._selected, id);
|
||||||
|
this.emitter.emit("select", this._selected, undefined, id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (value) {
|
||||||
|
if (this.getImageByIdEx(id).visible) {
|
||||||
|
pushUnique(this._selected, id);
|
||||||
|
this.emitter.emit("select", this._selected, id, undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
get selected() {
|
||||||
|
return this._selected;
|
||||||
|
}
|
||||||
|
deleteImage(id: string) {
|
||||||
|
const images = this.getImages(id);
|
||||||
|
if (images) {
|
||||||
|
removeItem(this._images, images.image);
|
||||||
|
removeItem(this._renderedImages, images.renderedImage);
|
||||||
|
URL.revokeObjectURL(images.renderedImage.url);
|
||||||
|
this.emitter.emit("remove", id);
|
||||||
|
this.emitter.emit("array-length", this._images.length);
|
||||||
|
this.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private save = throttle(async () => {
|
||||||
|
await this.store.setItem(this.STORAGE_IMAGE_KEY, this._images);
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
createImagesWithEmit() {
|
||||||
|
return this._images.map((e) => this.createImageWithEmit(e.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
createImageWithEmit(id: string) {
|
||||||
|
if (this.getImageById(id)) {
|
||||||
|
return new ImageEditor(
|
||||||
|
id,
|
||||||
|
() => this.getImageByIdEx(id),
|
||||||
|
async (data) => {
|
||||||
|
const { image, renderedImage } = this.getImagesEx(id);
|
||||||
|
|
||||||
|
if (image && renderedImage) {
|
||||||
|
let emitUpdate = false;
|
||||||
|
const entries = Object.entries(data);
|
||||||
|
for (const [key, value] of entries) {
|
||||||
|
|
||||||
|
if (image.locked && key !== "locked") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let v = value;
|
||||||
|
if (key === "x" || key === "y") {
|
||||||
|
v = Math.round(v as number);
|
||||||
|
}
|
||||||
|
(image as any)[key] = value as any;
|
||||||
|
const diff = (renderedImage as any)[key] !== v;
|
||||||
|
(renderedImage as any)[key] = v as any;
|
||||||
|
if ((key === "svg") && diff) {
|
||||||
|
this.enqueueForRedrawSvg(id);
|
||||||
|
}
|
||||||
|
if (diff) {
|
||||||
|
emitUpdate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (emitUpdate) {
|
||||||
|
this.emitter.emit(
|
||||||
|
"update",
|
||||||
|
id,
|
||||||
|
entries.map((e) => e[0]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.save();
|
||||||
|
} else {
|
||||||
|
throw new Error("Image does not exist!");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
() => this.isSelected(id),
|
||||||
|
() => {
|
||||||
|
this.setSelect(id, !this.isSelected(id));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw new Error(`Image ${id} does not exist!`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get ready() {
|
||||||
|
return this._ready;
|
||||||
|
}
|
||||||
|
get toolHandler() {
|
||||||
|
return this._toolHandler;
|
||||||
|
}
|
||||||
|
get globalSettingsHandler() {
|
||||||
|
return this._globalSettingsHandler;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
|
||||||
|
:root {
|
||||||
|
--primary-color: #474747;
|
||||||
|
--secondary-color: #252525;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'tiny5';
|
||||||
|
src: url('./assets/fonts/Tiny5-Regular.ttf') format('truetype');
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: tiny5;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,body,#root {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: black;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import type { ImageTracerOptions } from "imagetracerjs";
|
||||||
|
|
||||||
|
export interface ObjectID {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TracerOptions extends ImageTracerOptions {
|
||||||
|
transparencyThreshold?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SvgData {
|
||||||
|
svg: string;
|
||||||
|
normalizedSvg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectConfig {
|
||||||
|
version: string;
|
||||||
|
name: string;
|
||||||
|
settings: GlobalSettings;
|
||||||
|
tool: string;
|
||||||
|
images: CanvasImage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GridCell {
|
||||||
|
col: number;
|
||||||
|
row: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CanvasImage extends ObjectID {
|
||||||
|
name: string;
|
||||||
|
buffer: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
scale: number;
|
||||||
|
visible: boolean;
|
||||||
|
locked: boolean;
|
||||||
|
svg: {
|
||||||
|
options: TracerOptions;
|
||||||
|
dirty: boolean;
|
||||||
|
data: string;
|
||||||
|
scale: number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CanvasImageEx extends CanvasImage {
|
||||||
|
image: HTMLImageElement;
|
||||||
|
blob: Blob;
|
||||||
|
canvas: HTMLCanvasElement;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GRID = [
|
||||||
|
"none",
|
||||||
|
"2x2",
|
||||||
|
"3x3"
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type GridType = typeof GRID[number];
|
||||||
|
export interface GlobalSettings {
|
||||||
|
DPI: number;
|
||||||
|
scale: number;
|
||||||
|
paperWidth: number;
|
||||||
|
paperHeight: number;
|
||||||
|
landscape: boolean;
|
||||||
|
grid: GridType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SVGCanvas {
|
||||||
|
svg: string;
|
||||||
|
image: HTMLImageElement,
|
||||||
|
url: string;
|
||||||
|
blob: Blob;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import App from "./App.tsx";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<App />,
|
||||||
|
);
|
||||||
+116
@@ -0,0 +1,116 @@
|
|||||||
|
import styled from "styled-components";
|
||||||
|
import type { CanvasHandlerProps } from "./handler/interfaces";
|
||||||
|
import { useImages } from "./use/use-images";
|
||||||
|
import { NumericInputWithLabelTable } from "./components/numeric-labeld-input";
|
||||||
|
import { SvgTracerOptions } from "./components/svg-tracer-options";
|
||||||
|
import { PaperSizeSelector } from "./components/paper-size-selector";
|
||||||
|
import { useSettings } from "./use/use-settings";
|
||||||
|
import { PaperOrientation } from "./components/paper-orentation";
|
||||||
|
import { PaperGuideSelector } from "./components/paper-guide-selector";
|
||||||
|
import { ImportExportButtons } from "./components/download-buttons";
|
||||||
|
|
||||||
|
|
||||||
|
const Bar = styled.div`
|
||||||
|
width: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
height: 110px;
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ImageBox = styled.table`
|
||||||
|
margin: 2px;
|
||||||
|
padding-right: 4px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SvgOption = styled.table`
|
||||||
|
margin: 2px;
|
||||||
|
padding-right: 4px;
|
||||||
|
`;
|
||||||
|
const Line = styled.div`
|
||||||
|
margin: 4px;
|
||||||
|
height: calc(100% - 8px);
|
||||||
|
border-right: 1px solid white;
|
||||||
|
`;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
type NumericKeys<T> = {
|
||||||
|
[K in keyof T]: T[K] extends number ? K : never
|
||||||
|
}[keyof T]
|
||||||
|
|
||||||
|
function getNumericValue<T>(selected: T[], key: NumericKeys<T>): number {
|
||||||
|
const disabled = selected.length === 0;
|
||||||
|
const isMixed = selected.length > 1;
|
||||||
|
|
||||||
|
return disabled
|
||||||
|
? 0
|
||||||
|
: isMixed
|
||||||
|
? selected.reduce((acc, e) => acc + (e[key] as number), 0) / selected.length
|
||||||
|
: (selected[0][key] as number);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NavBar({ handler }: CanvasHandlerProps) {
|
||||||
|
const { selected } = useImages(handler);
|
||||||
|
const { settings, setSettings } = useSettings(handler);
|
||||||
|
|
||||||
|
const setValue = (key: string, value: any) => {
|
||||||
|
selected.forEach((e) => {
|
||||||
|
e.onPropsChange({
|
||||||
|
[key]: value
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const style: React.CSSProperties = {
|
||||||
|
opacity: selected.length ? 1 : 0.2,
|
||||||
|
pointerEvents: selected.length ? "all" : "none"
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Bar>
|
||||||
|
<PaperSizeSelector settings={settings} setSettings={setSettings} popup={handler.popup} />
|
||||||
|
<PaperOrientation settings={settings} setSettings={setSettings} />
|
||||||
|
<PaperGuideSelector settings={settings} setSettings={setSettings} />
|
||||||
|
<ImageBox style={style}>
|
||||||
|
<tbody>
|
||||||
|
<NumericInputWithLabelTable
|
||||||
|
name="Size"
|
||||||
|
value={getNumericValue(selected, "scale")}
|
||||||
|
onChange={value => setValue("scale", value)}
|
||||||
|
/>
|
||||||
|
<NumericInputWithLabelTable
|
||||||
|
name="X"
|
||||||
|
value={getNumericValue(selected, "x")}
|
||||||
|
onChange={value => setValue("x", value)}
|
||||||
|
/>
|
||||||
|
<NumericInputWithLabelTable
|
||||||
|
name="Y"
|
||||||
|
value={getNumericValue(selected, "y")}
|
||||||
|
onChange={value => setValue("y", value)}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</ImageBox>
|
||||||
|
<SvgOption style={style}>
|
||||||
|
<SvgTracerOptions selected={selected} />
|
||||||
|
</SvgOption>
|
||||||
|
<Line />
|
||||||
|
<ImportExportButtons handler={handler} selected={selected} />
|
||||||
|
{/* <RibbonButton icon={() => <FaTrash />} name="Delete" onClick={async () => {
|
||||||
|
if (await handler.popup.confirm("Delete project", "Are you sure you want to delete this project?")) {
|
||||||
|
handler.clear();
|
||||||
|
handler.promptSetName();
|
||||||
|
}
|
||||||
|
}} />
|
||||||
|
<RibbonButton icon={() => <FaDownload />} name="Export" onClick={async () => {
|
||||||
|
handler.exportProject();
|
||||||
|
}} />
|
||||||
|
|
||||||
|
<RibbonButton icon={() => <FaFileImport />} name="Import" onClick={async () => {
|
||||||
|
handler.importProject();
|
||||||
|
}} /> */}
|
||||||
|
|
||||||
|
|
||||||
|
</Bar>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import styled from "styled-components";
|
||||||
|
import type { CanvasHandlerProps } from "./handler/interfaces";
|
||||||
|
import { LayerPanel } from "./components/layer-panel";
|
||||||
|
import A4Canvas from "./components/a4-canvas";
|
||||||
|
import { Toolbar } from "./components/toolbar";
|
||||||
|
import { InfoBar } from "./components/info-bar";
|
||||||
|
|
||||||
|
|
||||||
|
const Content = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-grow: 1;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Left = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
overflow: auto;
|
||||||
|
flex-grow: 1;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const LeftInner = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: auto;
|
||||||
|
flex-grow: 1;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export function RenderPage({ handler }: CanvasHandlerProps) {
|
||||||
|
return (
|
||||||
|
<Content>
|
||||||
|
<Left>
|
||||||
|
<Toolbar handler={handler} />
|
||||||
|
<LeftInner>
|
||||||
|
<A4Canvas handler={handler} />
|
||||||
|
<InfoBar handler={handler} />
|
||||||
|
</LeftInner>
|
||||||
|
</Left>
|
||||||
|
<LayerPanel handler={handler} />
|
||||||
|
</Content>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import styled from "styled-components";
|
||||||
|
|
||||||
|
export const Icon2525 = styled.button<{ $selected?: boolean }>`
|
||||||
|
margin: 0;
|
||||||
|
padding: 5px;
|
||||||
|
width: 25px;
|
||||||
|
height: 25px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
${(props) => props.$selected && "background: rgba(255, 255, 255, 0.25);"}
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const Button = styled.button<{ $active?: boolean }>`
|
||||||
|
margin: 2px;
|
||||||
|
padding: 2px;
|
||||||
|
border: 1px solid white;
|
||||||
|
border-radius: 0;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
background: ${({ $active }) => $active ? "white" : "var(--primary-color)"};
|
||||||
|
color: ${({ $active }) => $active ? "var(--primary-color)" : "white"};;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: ${({ $active }) => $active ? "rgba(0, 0, 0, 0.25)" : "rgba(255, 255, 255, 0.50)"};
|
||||||
|
background: ${({ $active }) => $active ? "rgba(255, 255, 255, 0.85)" : "rgba(255, 255, 255, 0.10)"};
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
color: rgba(255, 255, 255, 0.25);
|
||||||
|
background: rgba(255, 255, 255, 0.10);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import ImageTracer from "imagetracerjs";
|
||||||
|
import type { TracerOptions } from "./interface";
|
||||||
|
import { last } from "lodash";
|
||||||
|
import { VERSION } from "./constants";
|
||||||
|
|
||||||
|
|
||||||
|
export function imageToBlackWhiteImageData(img: HTMLImageElement, transparencyThreshold: number = 50): ImageData {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = img.naturalWidth;
|
||||||
|
canvas.height = img.naturalHeight;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext("2d")!;
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
|
||||||
|
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||||
|
const data = imageData.data;
|
||||||
|
|
||||||
|
for (let i = 0; i < data.length; i += 4) {
|
||||||
|
const a = data[i + 3];
|
||||||
|
|
||||||
|
const value = a > transparencyThreshold ? 255 : 0;
|
||||||
|
data[i] = 0;
|
||||||
|
data[i + 1] = 0;
|
||||||
|
data[i + 2] = 0;
|
||||||
|
data[i + 3] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return imageData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function imageToLaserSVG(img: HTMLImageElement, options?: TracerOptions): string {
|
||||||
|
const bwData = imageToBlackWhiteImageData(img, options?.transparencyThreshold);
|
||||||
|
|
||||||
|
const rawSVG = ImageTracer.imagedataToSVG(bwData, {
|
||||||
|
scale: 1,
|
||||||
|
...options,
|
||||||
|
viewbox: true,
|
||||||
|
desc: false,
|
||||||
|
layering: 0
|
||||||
|
});
|
||||||
|
const parser = new DOMParser();
|
||||||
|
|
||||||
|
const doc = parser.parseFromString(rawSVG, "image/svg+xml");
|
||||||
|
const svg = doc.querySelectorAll("svg")[0];
|
||||||
|
const [_, __, width, height] = svg.getAttribute("viewBox")!.split(" ");
|
||||||
|
svg.setAttribute("width", width);
|
||||||
|
svg.setAttribute("height", height);
|
||||||
|
svg.setAttribute("desc", `Created with pathshop ${VERSION}v`);
|
||||||
|
|
||||||
|
const paths = [...doc.querySelectorAll("path")];
|
||||||
|
|
||||||
|
// getting global path. Removing it as it creates noise
|
||||||
|
const longest = paths.sort((a, b) => (a.getAttribute("d") || "").length < (b.getAttribute("d") || "").length ? 1 : -1)[0];
|
||||||
|
console.log(paths, longest);
|
||||||
|
for (const path of paths) {
|
||||||
|
path.setAttribute("fill", "none");
|
||||||
|
path.setAttribute("opacity", "1");
|
||||||
|
path.setAttribute("stroke", "#000000");
|
||||||
|
if (longest === path) {
|
||||||
|
path.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new XMLSerializer().serializeToString(doc);
|
||||||
|
}
|
||||||
Vendored
+154
@@ -0,0 +1,154 @@
|
|||||||
|
declare module "imagetracerjs" {
|
||||||
|
export interface RGBAColor {
|
||||||
|
r: number;
|
||||||
|
g: number;
|
||||||
|
b: number;
|
||||||
|
a: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Palette = RGBAColor[];
|
||||||
|
|
||||||
|
|
||||||
|
export interface ImageDataLike {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
data: Uint8ClampedArray | number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface TracedSegment {
|
||||||
|
type: "L" | "Q";
|
||||||
|
x1: number;
|
||||||
|
y1: number;
|
||||||
|
x2?: number;
|
||||||
|
y2?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TracedPath {
|
||||||
|
isholepath: boolean;
|
||||||
|
holechildren: number[];
|
||||||
|
segments: TracedSegment[];
|
||||||
|
boundingbox: [number, number, number, number];
|
||||||
|
prestyle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TracedLayer {
|
||||||
|
pathnum: number;
|
||||||
|
paths: TracedPath[];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface TraceData {
|
||||||
|
layers: TracedLayer[];
|
||||||
|
palette: Palette;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageTracerOptions {
|
||||||
|
|
||||||
|
colorsampling?: 0 | 1 | 2;
|
||||||
|
|
||||||
|
|
||||||
|
layering?: 0 | 1;
|
||||||
|
viewbox?: boolean;
|
||||||
|
desc?: boolean;
|
||||||
|
corsenabled?: boolean;
|
||||||
|
|
||||||
|
rightangleenhance?: boolean;
|
||||||
|
|
||||||
|
numberofcolors?: number;
|
||||||
|
mincolorratio?: number;
|
||||||
|
colorquantcycles?: number;
|
||||||
|
ltres?: number;
|
||||||
|
qtres?: number;
|
||||||
|
pathomit?: number;
|
||||||
|
scale?: number;
|
||||||
|
roundcoords?: number;
|
||||||
|
lcpr?: number;
|
||||||
|
qcpr?: number;
|
||||||
|
blurradius?: number;
|
||||||
|
blurdelta?: number;
|
||||||
|
strokewidth?: number;
|
||||||
|
|
||||||
|
|
||||||
|
pal?: Palette;
|
||||||
|
layercontainerid?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export type OptionPreset =
|
||||||
|
| "default"
|
||||||
|
| "posterized1"
|
||||||
|
| "posterized2"
|
||||||
|
| "posterized3"
|
||||||
|
| "curvy"
|
||||||
|
| "sharp"
|
||||||
|
| "detailed"
|
||||||
|
| "smoothed"
|
||||||
|
| "grayscale"
|
||||||
|
| "fixedpalette"
|
||||||
|
| "randomsampling1"
|
||||||
|
| "randomsampling2"
|
||||||
|
| "artistic1"
|
||||||
|
| "artistic2"
|
||||||
|
| "artistic3"
|
||||||
|
| "artistic4";
|
||||||
|
|
||||||
|
export type OptionsOrPreset = ImageTracerOptions | OptionPreset;
|
||||||
|
|
||||||
|
|
||||||
|
export function imageToSVG(
|
||||||
|
imageUrl: string,
|
||||||
|
callback: (svgString: string) => void,
|
||||||
|
options?: OptionsOrPreset
|
||||||
|
): void;
|
||||||
|
|
||||||
|
|
||||||
|
export function imagedataToSVG(
|
||||||
|
imageData: ImageDataLike,
|
||||||
|
options?: OptionsOrPreset
|
||||||
|
): string;
|
||||||
|
|
||||||
|
|
||||||
|
export function imageToTracedata(
|
||||||
|
imageUrl: string,
|
||||||
|
callback: (traceData: TraceData) => void,
|
||||||
|
options?: OptionsOrPreset
|
||||||
|
): void;
|
||||||
|
|
||||||
|
export function imagedataToTracedata(
|
||||||
|
imageData: ImageDataLike,
|
||||||
|
options?: OptionsOrPreset
|
||||||
|
): TraceData;
|
||||||
|
|
||||||
|
|
||||||
|
export function appendSVGString(
|
||||||
|
svgString: string,
|
||||||
|
parentId: string
|
||||||
|
): void;
|
||||||
|
|
||||||
|
|
||||||
|
export function loadImage(
|
||||||
|
url: string,
|
||||||
|
callback: (canvas: HTMLCanvasElement) => void
|
||||||
|
): void;
|
||||||
|
|
||||||
|
|
||||||
|
export function getImgdata(
|
||||||
|
canvas: HTMLCanvasElement
|
||||||
|
): ImageData;
|
||||||
|
|
||||||
|
|
||||||
|
declare const ImageTracer: {
|
||||||
|
imageToSVG: typeof imageToSVG;
|
||||||
|
imagedataToSVG: typeof imagedataToSVG;
|
||||||
|
imageToTracedata: typeof imageToTracedata;
|
||||||
|
imagedataToTracedata: typeof imagedataToTracedata;
|
||||||
|
appendSVGString: typeof appendSVGString;
|
||||||
|
loadImage: typeof loadImage;
|
||||||
|
getImgdata: typeof getImgdata;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImageTracer;
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { compact } from "lodash";
|
||||||
|
import { useState, useEffect, useMemo } from "react";
|
||||||
|
import type { VectraceHandler } from "../handler/vectrace-handler";
|
||||||
|
import { removeItem } from "../utils/collection";
|
||||||
|
|
||||||
|
export function useImages(handler: VectraceHandler) {
|
||||||
|
const [images, setImages] = useState(() => handler.createImagesWithEmit());
|
||||||
|
|
||||||
|
const selected = useMemo(
|
||||||
|
() => images.filter((e) => handler.isSelected(e.id)),
|
||||||
|
[images]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onAdd = (id: string) => {
|
||||||
|
setImages((images) => [...images, handler.createImageWithEmit(id)]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRemove = (id: string) => {
|
||||||
|
setImages((images) => {
|
||||||
|
const clone = [...images];
|
||||||
|
const image = clone.find((e) => e.id === id);
|
||||||
|
if (image) removeItem(clone, image);
|
||||||
|
return clone;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUpdate = (id: string) => {
|
||||||
|
setImages((images) => {
|
||||||
|
const clone = [...images];
|
||||||
|
const index = clone.findIndex((e) => e.id === id);
|
||||||
|
if (index !== -1) {
|
||||||
|
clone[index] = handler.createImageWithEmit(id);
|
||||||
|
}
|
||||||
|
return clone;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSelect = (_ids: string[], added: string | undefined, remove: string | undefined) => {
|
||||||
|
setImages((images) => {
|
||||||
|
const clone = [...images];
|
||||||
|
const indices = compact([added, remove]).map((a) =>
|
||||||
|
clone.findIndex((e) => e.id === a)
|
||||||
|
);
|
||||||
|
for (const index of indices) {
|
||||||
|
if (index !== -1) {
|
||||||
|
clone[index] = handler.createImageWithEmit(clone[index].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clone;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
handler.emitter.on("add", onAdd);
|
||||||
|
handler.emitter.on("update", onUpdate);
|
||||||
|
handler.emitter.on("remove", onRemove);
|
||||||
|
handler.emitter.on("select", onSelect);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
handler.emitter.off("add", onAdd);
|
||||||
|
handler.emitter.off("update", onUpdate);
|
||||||
|
handler.emitter.off("remove", onRemove);
|
||||||
|
handler.emitter.off("select", onSelect);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { images, selected };
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { VectraceHandler } from "../handler/vectrace-handler";
|
||||||
|
import type { GlobalSettings } from "../interface";
|
||||||
|
|
||||||
|
export type UseSettings = ReturnType<typeof useSettings>;
|
||||||
|
|
||||||
|
export function useSettings(handler: VectraceHandler) {
|
||||||
|
const [settings, setSettings] = useState(handler.globalSettingsHandler.settings);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const update = () => {
|
||||||
|
setSettings(handler.globalSettingsHandler.settings);
|
||||||
|
};
|
||||||
|
handler.globalSettingsHandler.emitter.on("settings-update", update);
|
||||||
|
return () => {
|
||||||
|
handler.globalSettingsHandler.emitter.off("settings-update", update);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
return {
|
||||||
|
settings,
|
||||||
|
setSettings: (settings: Partial<GlobalSettings>) => handler.globalSettingsHandler.setSettings(settings),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { VectraceHandler } from "../handler/vectrace-handler";
|
||||||
|
|
||||||
|
export function useTool(handler: VectraceHandler) {
|
||||||
|
const [currentTool, setCurrentTool] = useState(handler.toolHandler.tool);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const update = () => {
|
||||||
|
setCurrentTool(handler.toolHandler.tool);
|
||||||
|
};
|
||||||
|
handler.toolHandler.emitter.on("select", update);
|
||||||
|
return () => {
|
||||||
|
handler.toolHandler.emitter.off("select", update);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
return {
|
||||||
|
currentTool,
|
||||||
|
tools: handler.toolHandler.tools,
|
||||||
|
setTool: (toolKey: string) => handler.toolHandler.setTool(toolKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export function removeItem<T>(items: T[], item: T) {
|
||||||
|
const index = items.indexOf(item);
|
||||||
|
if (index !== -1) {
|
||||||
|
items.splice(index, 1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pushUnique<T>(items: T[], item: T) {
|
||||||
|
const index = items.indexOf(item);
|
||||||
|
if (index === -1) {
|
||||||
|
items.push(item);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
import jsPDF from "jspdf";
|
||||||
|
import { MM_TO_INCH } from "../constants";
|
||||||
|
import type { VectraceHandler } from "../handler/vectrace-handler";
|
||||||
|
import type { CanvasImageEx, GridCell } from "../interface";
|
||||||
|
import JSZip from "jszip";
|
||||||
|
import { parse, type INode } from "svgson";
|
||||||
|
|
||||||
|
export function downloadBlob(blob: Blob, filename: string) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function prepareSVG(
|
||||||
|
handler: VectraceHandler,
|
||||||
|
imageRender: CanvasImageEx
|
||||||
|
): Promise<Blob> {
|
||||||
|
if (imageRender.svg.scale !== imageRender.scale || imageRender.svg.dirty) {
|
||||||
|
handler.redrawSvg(imageRender.id);
|
||||||
|
}
|
||||||
|
return new Blob([imageRender.svg.data], { type: "image/svg+xml" });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function getGridCells(
|
||||||
|
paperWidth: number,
|
||||||
|
paperHeight: number,
|
||||||
|
grid: string
|
||||||
|
): GridCell[] {
|
||||||
|
const cols = grid === "3x3" ? 3 : grid === "2x2" ? 2 : 1;
|
||||||
|
const rows = grid === "3x3" ? 3 : grid === "2x2" ? 2 : 1;
|
||||||
|
const cellW = paperWidth / cols;
|
||||||
|
const cellH = paperHeight / rows;
|
||||||
|
const cells: GridCell[] = [];
|
||||||
|
for (let row = 0; row < rows; row++) {
|
||||||
|
for (let col = 0; col < cols; col++) {
|
||||||
|
cells.push({
|
||||||
|
col,
|
||||||
|
row,
|
||||||
|
x: col * cellW,
|
||||||
|
y: row * cellH,
|
||||||
|
w: cellW,
|
||||||
|
h: cellH,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function createCellPdf(
|
||||||
|
handler: VectraceHandler,
|
||||||
|
cell: GridCell
|
||||||
|
): jsPDF {
|
||||||
|
|
||||||
|
|
||||||
|
const doc = new jsPDF({
|
||||||
|
orientation: cell.w > cell.h ? "landscape" : "portrait",
|
||||||
|
unit: "mm",
|
||||||
|
format: [cell.w, cell.h],
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const imageObject of handler.imagesRenders) {
|
||||||
|
if (!imageObject.visible) continue;
|
||||||
|
|
||||||
|
const imgX = imageObject.x - cell.x;
|
||||||
|
const imgY = imageObject.y - cell.y;
|
||||||
|
const imgW = imageObject.image.naturalWidth * imageObject.scale;
|
||||||
|
const imgH = imageObject.image.naturalHeight * imageObject.scale;
|
||||||
|
|
||||||
|
const overlapX = imgX + imgW > 0 && imgX < cell.w;
|
||||||
|
const overlapY = imgY + imgH > 0 && imgY < cell.h;
|
||||||
|
if (!overlapX || !overlapY) continue;
|
||||||
|
|
||||||
|
doc.addImage(
|
||||||
|
imageObject.image,
|
||||||
|
"PNG",
|
||||||
|
imgX,
|
||||||
|
imgY,
|
||||||
|
imgW,
|
||||||
|
imgH
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gridConfigs: Record<string, number[]> = {
|
||||||
|
"2x2": [1 / 2],
|
||||||
|
"3x3": [1 / 3, 2 / 3],
|
||||||
|
};
|
||||||
|
export function createPdf(handler: VectraceHandler, withGuides: boolean): jsPDF {
|
||||||
|
const { DPI, paperHeight, paperWidth } = handler.globalSettingsHandler.settings;
|
||||||
|
const width = (paperWidth / MM_TO_INCH) * DPI;
|
||||||
|
const height = (paperHeight / MM_TO_INCH) * DPI;
|
||||||
|
|
||||||
|
const doc = new jsPDF({
|
||||||
|
orientation: paperWidth > paperHeight ? "landscape" : "portrait",
|
||||||
|
unit: "mm",
|
||||||
|
format: [width, height],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (withGuides) {
|
||||||
|
const setLine = () => {
|
||||||
|
doc.setDrawColor(64, 64, 64);
|
||||||
|
doc.setLineWidth(1);
|
||||||
|
};
|
||||||
|
const fractions = gridConfigs[handler.globalSettingsHandler.settings.grid];
|
||||||
|
if (fractions) {
|
||||||
|
setLine();
|
||||||
|
fractions.forEach(f => {
|
||||||
|
doc.line(width * f, 0, width * f, height);
|
||||||
|
doc.line(0, height * f, width, height * f);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const imageObject of handler.imagesRenders) {
|
||||||
|
if (imageObject.visible) {
|
||||||
|
doc.addImage(
|
||||||
|
imageObject.image,
|
||||||
|
"PNG",
|
||||||
|
imageObject.x,
|
||||||
|
imageObject.y,
|
||||||
|
imageObject.image.naturalWidth * imageObject.scale,
|
||||||
|
imageObject.image.naturalHeight * imageObject.scale
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function createSVGDoc(handler: VectraceHandler) {
|
||||||
|
const { paperWidth, paperHeight, DPI } = handler.globalSettingsHandler.settings;
|
||||||
|
const width = (paperWidth / MM_TO_INCH) * DPI;
|
||||||
|
const height = (paperHeight / MM_TO_INCH) * DPI;
|
||||||
|
return createCellSvg(handler, {
|
||||||
|
col: 0,
|
||||||
|
row: 0,
|
||||||
|
h: height,
|
||||||
|
w: width,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadZip(handler: VectraceHandler): Promise<void> {
|
||||||
|
const zip = new JSZip();
|
||||||
|
const { grid, paperWidth, paperHeight, DPI } = handler.globalSettingsHandler.settings;
|
||||||
|
|
||||||
|
const width = (paperWidth / MM_TO_INCH) * DPI;
|
||||||
|
const height = (paperHeight / MM_TO_INCH) * DPI;
|
||||||
|
|
||||||
|
const mainFolder = zip.folder(handler.projectName)!;
|
||||||
|
const images = mainFolder.folder("images")!;
|
||||||
|
const svgs = mainFolder.folder("svgs")!;
|
||||||
|
|
||||||
|
for (let i = 0; i < handler.imagesRenders.length; i++) {
|
||||||
|
const render = handler.imagesRenders[i];
|
||||||
|
if (render.scale === 1) {
|
||||||
|
images.file(`[${i + 1}]${render.name}.png`, render.blob);
|
||||||
|
} else {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
const ctx = canvas.getContext("2d")!;
|
||||||
|
canvas.width = render.image.naturalWidth * render.scale;
|
||||||
|
canvas.height = render.image.naturalHeight * render.scale;
|
||||||
|
ctx.drawImage(render.image, 0, 0, canvas.width, canvas.height);
|
||||||
|
const blob = new Promise<Blob>((resolve, reject) => {
|
||||||
|
canvas.toBlob(data => {
|
||||||
|
if (data) {
|
||||||
|
resolve(data);
|
||||||
|
} else {
|
||||||
|
reject(new Error("Cannot create blob"));
|
||||||
|
}
|
||||||
|
}, "image/png");
|
||||||
|
});
|
||||||
|
images.file(`[${i + 1}]${render.name}.png`, blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
const svgBlob = await prepareSVG(handler, render);
|
||||||
|
svgs.file(`[${i + 1}]${render.name}.svg`, svgBlob);
|
||||||
|
}
|
||||||
|
|
||||||
|
mainFolder.file("document.pdf", createPdf(handler, false).output("blob"));
|
||||||
|
if (grid !== "none") {
|
||||||
|
mainFolder.file("document-grid.pdf", createPdf(handler, true).output("blob"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const svgBlob = await createCellSvg(handler, {
|
||||||
|
col: 0,
|
||||||
|
row: 0,
|
||||||
|
h: height,
|
||||||
|
w: width,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
});
|
||||||
|
mainFolder.file(`document.svg`, svgBlob);
|
||||||
|
|
||||||
|
if (grid !== "none") {
|
||||||
|
const cells = getGridCells(width, height, grid);
|
||||||
|
const cellPdfs = mainFolder.folder("cells/pdfs")!;
|
||||||
|
const cellSvgs = mainFolder.folder("cells/svgs")!;
|
||||||
|
|
||||||
|
for (const cell of cells) {
|
||||||
|
const label = `cell-r${cell.row}-c${cell.col}`;
|
||||||
|
|
||||||
|
const cellPdf = createCellPdf(handler, cell);
|
||||||
|
cellPdfs.file(`${label}.pdf`, cellPdf.output("blob"));
|
||||||
|
|
||||||
|
const cellSvgBlob = await createCellSvg(handler, cell);
|
||||||
|
cellSvgs.file(`${label}.svg`, cellSvgBlob);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await zip.generateAsync({ type: "blob" });
|
||||||
|
downloadBlob(content, `${handler.projectName}.zip`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectPaths(node: INode): INode[] {
|
||||||
|
const results: INode[] = [];
|
||||||
|
if (node.name === "path") results.push(node);
|
||||||
|
for (const child of node.children ?? []) {
|
||||||
|
results.push(...collectPaths(child));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCellSvg(
|
||||||
|
handler: VectraceHandler,
|
||||||
|
cell: GridCell
|
||||||
|
) {
|
||||||
|
const pathElements: string[] = [];
|
||||||
|
for (const imageObject of handler.imagesRenders) {
|
||||||
|
if (!imageObject.visible) continue;
|
||||||
|
|
||||||
|
if (imageObject.svg.scale !== imageObject.scale || imageObject.svg.dirty) {
|
||||||
|
handler.redrawSvg(imageObject.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const imgX = imageObject.x;
|
||||||
|
const imgY = imageObject.y;
|
||||||
|
const imgW = imageObject.image.naturalWidth * imageObject.scale;
|
||||||
|
const imgH = imageObject.image.naturalHeight * imageObject.scale;
|
||||||
|
|
||||||
|
// Does this image even overlap the cell at all?
|
||||||
|
const overlaps =
|
||||||
|
imgX + imgW > cell.x &&
|
||||||
|
imgX < cell.x + cell.w &&
|
||||||
|
imgY + imgH > cell.y &&
|
||||||
|
imgY < cell.y + cell.h;
|
||||||
|
|
||||||
|
if (!overlaps) continue;
|
||||||
|
|
||||||
|
const parsed = await parse(imageObject.svg.data);
|
||||||
|
const paths = collectPaths(parsed);
|
||||||
|
|
||||||
|
for (const pathNode of paths) {
|
||||||
|
const d = pathNode.attributes?.d;
|
||||||
|
if (!d) continue;
|
||||||
|
|
||||||
|
const fill = pathNode.attributes?.fill ?? "none";
|
||||||
|
const stroke = pathNode.attributes?.stroke ?? "#000000";
|
||||||
|
const strokeWidth = pathNode.attributes?.["stroke-width"] ?? "1";
|
||||||
|
|
||||||
|
pathElements.push(
|
||||||
|
`<g transform="translate(${imgX - cell.x} ${imgY - cell.y})">` +
|
||||||
|
`<path d="${d}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}"/>` +
|
||||||
|
`</g>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const svgString = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="${cell.w}"
|
||||||
|
height="${cell.h}"
|
||||||
|
viewBox="0 0 ${cell.w} ${cell.h}">
|
||||||
|
${pathElements.join("\n ")}
|
||||||
|
</svg>`;
|
||||||
|
return new Blob([svgString], { type: "image/svg+xml" });
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { pushUnique, removeItem } from "./collection";
|
||||||
|
|
||||||
|
export type EventMap<T> = Record<keyof T, any[]> | never;
|
||||||
|
export type DefaultEventMap = [never];
|
||||||
|
export type AnyRest = [...args: any[]];
|
||||||
|
export type Args<K, T> = T extends DefaultEventMap ? AnyRest : K extends keyof T ? T[K] : never;
|
||||||
|
export type Key<K, T> = T extends DefaultEventMap ? number : K | keyof T;
|
||||||
|
export type Listener<K, T, F> = T extends DefaultEventMap
|
||||||
|
? F
|
||||||
|
: K extends keyof T
|
||||||
|
? T[K] extends unknown[]
|
||||||
|
? (...args: T[K]) => void
|
||||||
|
: never
|
||||||
|
: never;
|
||||||
|
export type Listener1<K, T> = Listener<K, T, (...args: any[]) => void>;
|
||||||
|
|
||||||
|
export class BasicEventEmitter<T extends EventMap<T>> {
|
||||||
|
private listeners = new Map<Key<any, Listener1<any, any>>, any>();
|
||||||
|
|
||||||
|
on<K>(eventName: Key<K, T>, listener: Listener1<K, T>): () => void {
|
||||||
|
const arr = this.listeners.get(eventName) || [];
|
||||||
|
pushUnique(arr, listener);
|
||||||
|
this.listeners.set(eventName, arr);
|
||||||
|
return () => {
|
||||||
|
this.off(eventName, listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
off<K>(eventName: Key<K, T>, listener: Listener1<K, T>): boolean {
|
||||||
|
const arr = this.listeners.get(eventName) || [];
|
||||||
|
removeItem(arr, listener);
|
||||||
|
if (!arr.length) {
|
||||||
|
this.listeners.delete(eventName);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit<K>(eventName: Key<K, T>, ...args: Args<K, T>): Promise<unknown> {
|
||||||
|
const listeners = this.listeners.get(eventName);
|
||||||
|
const promises: Promise<unknown>[] = [];
|
||||||
|
if (listeners) {
|
||||||
|
for (let i = 0; i < listeners.length; i++) {
|
||||||
|
try {
|
||||||
|
const listener = listeners[i];
|
||||||
|
if (listener) {
|
||||||
|
const result = listener(...args);
|
||||||
|
if (result instanceof Promise) {
|
||||||
|
promises.push(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.all(promises);
|
||||||
|
}
|
||||||
|
|
||||||
|
listenersCount<K>(type: Key<K, T>) {
|
||||||
|
const arr = this.listeners.get(type);
|
||||||
|
return arr ? arr.length : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
removeAllListeners() {
|
||||||
|
this.listeners.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
removeSpecificListeners<K>(type: Key<K, T>) {
|
||||||
|
this.listeners.delete(type);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export function loadFileAsDataUrl(file: File) {
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.addEventListener("load", () => {
|
||||||
|
resolve(reader.result as string);
|
||||||
|
});
|
||||||
|
reader.addEventListener("error", () => {
|
||||||
|
reject(new Error("Failed to read file"));
|
||||||
|
});
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export function makeFilenameSafe(input: string): string {
|
||||||
|
return input
|
||||||
|
.trim()
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
.replace(/[<>:"/\\|?*\x00-\x1F]/g, "")
|
||||||
|
.replace(/\s+/g, "_")
|
||||||
|
.replace(/\.+$/, "")
|
||||||
|
.replace(/^\.+/, "")
|
||||||
|
.replace(/_{2,}/g, "_")
|
||||||
|
.slice(0, 255)
|
||||||
|
|| "unnamed";
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
export function urlToImage(base64Str: string) {
|
||||||
|
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||||
|
const imageSrc = new Image();
|
||||||
|
imageSrc.addEventListener("load", () => {
|
||||||
|
resolve(imageSrc);
|
||||||
|
});
|
||||||
|
imageSrc.addEventListener("error", () => {
|
||||||
|
reject(new Error("Failed to load image"));
|
||||||
|
});
|
||||||
|
imageSrc.src = base64Str;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function imageToCanvas(image: HTMLImageElement) {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = image.naturalWidth;
|
||||||
|
canvas.height = image.naturalHeight;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) throw new Error("No canvas context");
|
||||||
|
|
||||||
|
ctx.drawImage(image, 0, 0);
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canvasToBlob(canvas: HTMLCanvasElement) {
|
||||||
|
return new Promise<Blob>((resolve, reject) => {
|
||||||
|
canvas.toBlob((b) => {
|
||||||
|
if (!b) reject(new Error("Failed to create blob"));
|
||||||
|
else resolve(b);
|
||||||
|
}, "image/png");
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import pkg from "./package.json";
|
||||||
|
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
define: {
|
||||||
|
__APP_VERSION__: JSON.stringify(pkg.version),
|
||||||
|
__AUTHOR__: JSON.stringify(pkg.author)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
Reference in New Issue
Block a user