Compare commits
40 Commits
feature/co
...
107caef54d
| Author | SHA1 | Date | |
|---|---|---|---|
| 107caef54d | |||
| 5ca0a0ea78 | |||
| f9986b319d | |||
| df6758d112 | |||
| 00cee9e593 | |||
| 70090f3a6c | |||
| 4bbe797d9b | |||
| 9717ab9575 | |||
| 386393e507 | |||
| b2f9e7d5b9 | |||
| 044c819a09 | |||
| 8505e98947 | |||
| 19d0f3584c | |||
| 452711a6f9 | |||
| caef8003fa | |||
| 2586a0a0bc | |||
| a76c0bd04d | |||
| 551d7a027f | |||
| 6b0c82a630 | |||
| a0d2b2994b | |||
| 7f8e26255f | |||
| 4ec82317ea | |||
| fed578441a | |||
| 62205ed069 | |||
| 6ae8dd37dc | |||
| 77a1b22faf | |||
| 8b1ea0f5b4 | |||
| 9a2bf6b2db | |||
| 2c25157621 | |||
| 9fa2bbd15d | |||
| b3f530870e | |||
| 56858701ef | |||
| f8b4e98f9c | |||
| 69fc4c4a7e | |||
| d35ae69265 | |||
| aee07f23f2 | |||
| 474ddd3e27 | |||
| 097df1f5de | |||
| 5cc4eb8731 | |||
| 57d62db219 |
636
LICENSE.md
Normal file
636
LICENSE.md
Normal file
@@ -0,0 +1,636 @@
|
|||||||
|
# GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 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 General Public License is a free, copyleft license for software and
|
||||||
|
other kinds of works.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed to take
|
||||||
|
away your freedom to share and change the works. By contrast, the GNU General
|
||||||
|
Public License is intended to guarantee your freedom to share and change all
|
||||||
|
versions of a program--to make sure it remains free software for all its users.
|
||||||
|
We, the Free Software Foundation, use the GNU General Public License for most
|
||||||
|
of our software; it applies also to any other work released this way by its
|
||||||
|
authors. You can apply it to your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not price. Our
|
||||||
|
General Public Licenses are designed to make sure that you have the freedom to
|
||||||
|
distribute copies of free software (and charge for them if you wish), that you
|
||||||
|
receive source code or can get it if you want it, that you can change the
|
||||||
|
software or use pieces of it in new free programs, and that you know you can do
|
||||||
|
these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to prevent others from denying you these rights
|
||||||
|
or asking you to surrender the rights. Therefore, you have certain
|
||||||
|
responsibilities if you distribute copies of the software, or if you modify it:
|
||||||
|
responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether gratis or for
|
||||||
|
a fee, you must pass on to the recipients the same freedoms that you received.
|
||||||
|
You must make sure that they, too, receive or can get the source code. And you
|
||||||
|
must show them these terms so they know their rights.
|
||||||
|
|
||||||
|
Developers that use the GNU GPL protect your rights with two steps:
|
||||||
|
|
||||||
|
1. assert copyright on the software, and
|
||||||
|
2. offer you this License giving you legal permission to copy, distribute
|
||||||
|
and/or modify it.
|
||||||
|
|
||||||
|
For the developers' and authors' protection, the GPL clearly explains that
|
||||||
|
there is no warranty for this free software. For both users' and authors' sake,
|
||||||
|
the GPL requires that modified versions be marked as changed, so that their
|
||||||
|
problems will not be attributed erroneously to authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run modified
|
||||||
|
versions of the software inside them, although the manufacturer can do so. This
|
||||||
|
is fundamentally incompatible with the aim of protecting users' freedom to
|
||||||
|
change the software. The systematic pattern of such abuse occurs in the area of
|
||||||
|
products for individuals to use, which is precisely where it is most
|
||||||
|
unacceptable. Therefore, we have designed this version of the GPL to prohibit
|
||||||
|
the practice for those products. If such problems arise substantially in other
|
||||||
|
domains, we stand ready to extend this provision to those domains in future
|
||||||
|
versions of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents. States
|
||||||
|
should not allow patents to restrict development and use of software on
|
||||||
|
general-purpose computers, but in those that do, we wish to avoid the special
|
||||||
|
danger that patents applied to a free program could make it effectively
|
||||||
|
proprietary. To prevent this, the GPL assures that patents cannot be used to
|
||||||
|
render the program non-free.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and modification
|
||||||
|
follow.
|
||||||
|
|
||||||
|
## TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
### 0. Definitions.
|
||||||
|
|
||||||
|
*This License* refers to version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
*Copyright* also means copyright-like laws that apply to other kinds of works,
|
||||||
|
such as semiconductor masks.
|
||||||
|
|
||||||
|
*The Program* refers to any copyrightable work licensed under this License.
|
||||||
|
Each licensee is addressed as *you*. *Licensees* and *recipients* may be
|
||||||
|
individuals or organizations.
|
||||||
|
|
||||||
|
To *modify* a work means to copy from or adapt all or part of the work in a
|
||||||
|
fashion requiring copyright permission, other than the making of an exact copy.
|
||||||
|
The resulting work is called a *modified version* of the earlier work or a work
|
||||||
|
*based on* the earlier work.
|
||||||
|
|
||||||
|
A *covered work* means either the unmodified Program or a work based on the
|
||||||
|
Program.
|
||||||
|
|
||||||
|
To *propagate* a work means to do anything with it that, without permission,
|
||||||
|
would make you directly or secondarily liable for infringement under applicable
|
||||||
|
copyright law, except executing it on a computer or modifying a private copy.
|
||||||
|
Propagation includes copying, distribution (with or without modification),
|
||||||
|
making available to the public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To *convey* a work means any kind of propagation that enables other parties to
|
||||||
|
make or receive copies. Mere interaction with a user through a computer
|
||||||
|
network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays *Appropriate Legal Notices* to the
|
||||||
|
extent that it includes a convenient and prominently visible feature that
|
||||||
|
|
||||||
|
1. displays an appropriate copyright notice, and
|
||||||
|
2. tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the work
|
||||||
|
under this License, and how to view a copy of this License.
|
||||||
|
|
||||||
|
If the interface presents a list of user commands or options, such as a menu, a
|
||||||
|
prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
### 1. Source Code.
|
||||||
|
|
||||||
|
The *source code* for a work means the preferred form of the work for making
|
||||||
|
modifications to it. *Object code* means any non-source form of a work.
|
||||||
|
|
||||||
|
A *Standard Interface* means an interface that either is an official standard
|
||||||
|
defined by a recognized standards body, or, in the case of interfaces specified
|
||||||
|
for a particular programming language, one that is widely used among developers
|
||||||
|
working in that language.
|
||||||
|
|
||||||
|
The *System Libraries* of an executable work include anything, other than the
|
||||||
|
work as a whole, that (a) is included in the normal form of packaging a Major
|
||||||
|
Component, but which is not part of that Major Component, and (b) serves only
|
||||||
|
to enable use of the work with that Major Component, or to implement a Standard
|
||||||
|
Interface for which an implementation is available to the public in source code
|
||||||
|
form. A *Major Component*, in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system (if any) on
|
||||||
|
which the executable work runs, or a compiler used to produce the work, or an
|
||||||
|
object code interpreter used to run it.
|
||||||
|
|
||||||
|
The *Corresponding Source* for a work in object code form means all the source
|
||||||
|
code needed to generate, install, and (for an executable work) run the object
|
||||||
|
code and to modify the work, including scripts to control those activities.
|
||||||
|
However, it does not include the work's System Libraries, or general-purpose
|
||||||
|
tools or generally available free programs which are used unmodified in
|
||||||
|
performing those activities but which are not part of the work. For example,
|
||||||
|
Corresponding Source includes interface definition files associated with source
|
||||||
|
files for the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require, such as
|
||||||
|
by intimate data communication or control flow between those subprograms and
|
||||||
|
other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users can regenerate
|
||||||
|
automatically from other parts of the Corresponding Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that same work.
|
||||||
|
|
||||||
|
### 2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of copyright on
|
||||||
|
the Program, and are irrevocable provided the stated conditions are met. This
|
||||||
|
License explicitly affirms your unlimited permission to run the unmodified
|
||||||
|
Program. The output from running a covered work is covered by this License only
|
||||||
|
if the output, given its content, constitutes a covered work. This License
|
||||||
|
acknowledges your rights of fair use or other equivalent, as provided by
|
||||||
|
copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not convey, without
|
||||||
|
conditions so long as your license otherwise remains in force. You may convey
|
||||||
|
covered works to others for the sole purpose of having them make modifications
|
||||||
|
exclusively for you, or provide you with facilities for running those works,
|
||||||
|
provided that you comply with the terms of this License in conveying all
|
||||||
|
material for which you do not control copyright. Those thus making or running
|
||||||
|
the covered works for you must do so exclusively on your behalf, under your
|
||||||
|
direction and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under the
|
||||||
|
conditions stated below. Sublicensing is not allowed; section 10 makes it
|
||||||
|
unnecessary.
|
||||||
|
|
||||||
|
### 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological measure
|
||||||
|
under any applicable law fulfilling obligations under article 11 of the WIPO
|
||||||
|
copyright treaty adopted on 20 December 1996, or similar laws prohibiting or
|
||||||
|
restricting circumvention of such measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention is
|
||||||
|
effected by exercising rights under this License with respect to the covered
|
||||||
|
work, and you disclaim any intention to limit operation or modification of the
|
||||||
|
work as a means of enforcing, against the work's users, your or third parties'
|
||||||
|
legal rights to forbid circumvention of technological measures.
|
||||||
|
|
||||||
|
### 4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you receive it,
|
||||||
|
in any medium, provided that you conspicuously and appropriately publish on
|
||||||
|
each copy an appropriate copyright notice; keep intact all notices stating that
|
||||||
|
this License and any non-permissive terms added in accord with section 7 apply
|
||||||
|
to the code; keep intact all notices of the absence of any warranty; and give
|
||||||
|
all recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey, and you may
|
||||||
|
offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
### 5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to produce it
|
||||||
|
from the Program, in the form of source code under the terms of section 4,
|
||||||
|
provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
- a) The work must carry prominent notices stating that you modified it, and
|
||||||
|
giving a relevant date.
|
||||||
|
- b) The work must carry prominent notices stating that it is released under
|
||||||
|
this License and any conditions added under section 7. This requirement
|
||||||
|
modifies the requirement in section 4 to *keep intact all notices*.
|
||||||
|
- c) You must license the entire work, as a whole, under this License to
|
||||||
|
anyone who comes into possession of a copy. This License will therefore
|
||||||
|
apply, along with any applicable section 7 additional terms, to the whole
|
||||||
|
of the work, and all its parts, regardless of how they are packaged. This
|
||||||
|
License gives no permission to license the work in any other way, but it
|
||||||
|
does not invalidate such permission if you have separately received it.
|
||||||
|
- d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your work need
|
||||||
|
not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent works,
|
||||||
|
which are not by their nature extensions of the covered work, and which are not
|
||||||
|
combined with it such as to form a larger program, in or on a volume of a
|
||||||
|
storage or distribution medium, is called an *aggregate* if the compilation and
|
||||||
|
its resulting copyright are not used to limit the access or legal rights of the
|
||||||
|
compilation's users beyond what the individual works permit. Inclusion of a
|
||||||
|
covered work in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
### 6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms of sections 4
|
||||||
|
and 5, provided that you also convey the machine-readable Corresponding Source
|
||||||
|
under the terms of this License, in one of these ways:
|
||||||
|
|
||||||
|
- a) Convey the object code in, or embodied in, a physical product (including
|
||||||
|
a physical distribution medium), accompanied by the Corresponding Source
|
||||||
|
fixed on a durable physical medium customarily used for software
|
||||||
|
interchange.
|
||||||
|
- b) Convey the object code in, or embodied in, a physical product (including
|
||||||
|
a physical distribution medium), accompanied by a written offer, valid for
|
||||||
|
at least three years and valid for as long as you offer spare parts or
|
||||||
|
customer support for that product model, to give anyone who possesses the
|
||||||
|
object code either
|
||||||
|
1. a copy of the Corresponding Source for all the software in the product
|
||||||
|
that is covered by this License, on a durable physical medium
|
||||||
|
customarily used for software interchange, for a price no more than your
|
||||||
|
reasonable cost of physically performing this conveying of source, or
|
||||||
|
2. access to copy the Corresponding Source from a network server at no
|
||||||
|
charge.
|
||||||
|
- c) Convey individual copies of the object code with a copy of the written
|
||||||
|
offer to provide the Corresponding Source. This alternative is allowed only
|
||||||
|
occasionally and noncommercially, and only if you received the object code
|
||||||
|
with such an offer, in accord with subsection 6b.
|
||||||
|
- d) Convey the object code by offering access from a designated place
|
||||||
|
(gratis or for a charge), and offer equivalent access to the Corresponding
|
||||||
|
Source in the same way through the same place at no further charge. You
|
||||||
|
need not require recipients to copy the Corresponding Source along with the
|
||||||
|
object code. If the place to copy the object code is a network server, the
|
||||||
|
Corresponding Source may be on a different server operated by you or a
|
||||||
|
third party) that supports equivalent copying facilities, provided you
|
||||||
|
maintain clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the Corresponding
|
||||||
|
Source, you remain obligated to ensure that it is available for as long as
|
||||||
|
needed to satisfy these requirements.
|
||||||
|
- e) Convey the object code using peer-to-peer transmission, provided you
|
||||||
|
inform other peers where the object code and Corresponding Source of the
|
||||||
|
work are being offered to the general public at no charge under subsection
|
||||||
|
6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded from the
|
||||||
|
Corresponding Source as a System Library, need not be included in conveying the
|
||||||
|
object code work.
|
||||||
|
|
||||||
|
A *User Product* is either
|
||||||
|
|
||||||
|
1. a *consumer product*, which means any tangible personal property which is
|
||||||
|
normally used for personal, family, or household purposes, or
|
||||||
|
2. anything designed or sold for incorporation into a dwelling.
|
||||||
|
|
||||||
|
In determining whether a product is a consumer product, doubtful cases shall be
|
||||||
|
resolved in favor of coverage. For a particular product received by a
|
||||||
|
particular user, *normally used* refers to a typical or common use of that
|
||||||
|
class of product, regardless of the status of the particular user or of the way
|
||||||
|
in which the particular user actually uses, or expects or is expected to use,
|
||||||
|
the product. A product is a consumer product regardless of whether the product
|
||||||
|
has substantial commercial, industrial or non-consumer uses, unless such uses
|
||||||
|
represent the only significant mode of use of the product.
|
||||||
|
|
||||||
|
*Installation Information* for a User Product means any methods, procedures,
|
||||||
|
authorization keys, or other information required to install and execute
|
||||||
|
modified versions of a covered work in that User Product from a modified
|
||||||
|
version of its Corresponding Source. The information must suffice to ensure
|
||||||
|
that the continued functioning of the modified object code is in no case
|
||||||
|
prevented or interfered with solely because modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as part of a
|
||||||
|
transaction in which the right of possession and use of the User Product is
|
||||||
|
transferred to the recipient in perpetuity or for a fixed term (regardless of
|
||||||
|
how the transaction is characterized), the Corresponding Source conveyed under
|
||||||
|
this section must be accompanied by the Installation Information. But this
|
||||||
|
requirement does not apply if neither you nor any third party retains the
|
||||||
|
ability to install modified object code on the User Product (for example, the
|
||||||
|
work has been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates for a
|
||||||
|
work that has been modified or installed by the recipient, or for the User
|
||||||
|
Product in which it has been modified or installed. Access to a network may be
|
||||||
|
denied when the modification itself materially and adversely affects the
|
||||||
|
operation of the network or violates the rules and protocols for communication
|
||||||
|
across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided, in accord
|
||||||
|
with this section must be in a format that is publicly documented (and with an
|
||||||
|
implementation available to the public in source code form), and must require
|
||||||
|
no special password or key for unpacking, reading or copying.
|
||||||
|
|
||||||
|
### 7. Additional Terms.
|
||||||
|
|
||||||
|
*Additional permissions* are terms that supplement the terms of this License by
|
||||||
|
making exceptions from one or more of its conditions. Additional permissions
|
||||||
|
that are applicable to the entire Program shall be treated as though they were
|
||||||
|
included in this License, to the extent that they are valid under applicable
|
||||||
|
law. If additional permissions apply only to part of the Program, that part may
|
||||||
|
be used separately under those permissions, but the entire Program remains
|
||||||
|
governed by this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option remove any
|
||||||
|
additional permissions from that copy, or from any part of it. (Additional
|
||||||
|
permissions may be written to require their own removal in certain cases when
|
||||||
|
you modify the work.) You may place additional permissions on material, added
|
||||||
|
by you to a covered work, for which you have or can give appropriate copyright
|
||||||
|
permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you add to a
|
||||||
|
covered work, you may (if authorized by the copyright holders of that material)
|
||||||
|
supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
- a) Disclaiming warranty or limiting liability differently from the terms of
|
||||||
|
sections 15 and 16 of this License; or
|
||||||
|
- b) Requiring preservation of specified reasonable legal notices or author
|
||||||
|
attributions in that material or in the Appropriate Legal Notices displayed
|
||||||
|
by works containing it; or
|
||||||
|
- c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in reasonable
|
||||||
|
ways as different from the original version; or
|
||||||
|
- d) Limiting the use for publicity purposes of names of licensors or authors
|
||||||
|
of the material; or
|
||||||
|
- e) Declining to grant rights under trademark law for use of some trade
|
||||||
|
names, trademarks, or service marks; or
|
||||||
|
- f) Requiring indemnification of licensors and authors of that material by
|
||||||
|
anyone who conveys the material (or modified versions of it) with
|
||||||
|
contractual assumptions of liability to the recipient, for any liability
|
||||||
|
that these contractual assumptions directly impose on those licensors and
|
||||||
|
authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered *further restrictions*
|
||||||
|
within the meaning of section 10. If the Program as you received it, or any
|
||||||
|
part of it, contains a notice stating that it is governed by this License along
|
||||||
|
with a term that is a further restriction, you may remove that term. If a
|
||||||
|
license document contains a further restriction but permits relicensing or
|
||||||
|
conveying under this License, you may add to a covered work material governed
|
||||||
|
by the terms of that license document, provided that the further restriction
|
||||||
|
does not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you must place,
|
||||||
|
in the relevant source files, a statement of the additional terms that apply to
|
||||||
|
those files, or a notice indicating where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the form of a
|
||||||
|
separately written license, or stated as exceptions; the above requirements
|
||||||
|
apply either way.
|
||||||
|
|
||||||
|
### 8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly provided
|
||||||
|
under this License. Any attempt otherwise to propagate or modify it is void,
|
||||||
|
and will automatically terminate your rights under this License (including any
|
||||||
|
patent licenses granted under the third paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your license from a
|
||||||
|
particular copyright holder is reinstated
|
||||||
|
|
||||||
|
- a) provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and
|
||||||
|
- b) permanently, if the copyright holder fails to notify you of the
|
||||||
|
violation by some reasonable means prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is reinstated
|
||||||
|
permanently if the copyright holder notifies you of the violation by some
|
||||||
|
reasonable means, this is the first time you have received notice of violation
|
||||||
|
of this License (for any work) from that copyright holder, and you cure the
|
||||||
|
violation prior to 30 days after your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the licenses
|
||||||
|
of parties who have received copies or rights from you under this License. If
|
||||||
|
your rights have been terminated and not permanently reinstated, you do not
|
||||||
|
qualify to receive new licenses for the same material under section 10.
|
||||||
|
|
||||||
|
### 9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or run a copy
|
||||||
|
of the Program. Ancillary propagation of a covered work occurring solely as a
|
||||||
|
consequence of using peer-to-peer transmission to receive a copy likewise does
|
||||||
|
not require acceptance. However, nothing other than this License grants you
|
||||||
|
permission to propagate or modify any covered work. These actions infringe
|
||||||
|
copyright if you do not accept this License. Therefore, by modifying or
|
||||||
|
propagating a covered work, you indicate your acceptance of this License to do
|
||||||
|
so.
|
||||||
|
|
||||||
|
### 10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically receives a
|
||||||
|
license from the original licensors, to run, modify and propagate that work,
|
||||||
|
subject to this License. You are not responsible for enforcing compliance by
|
||||||
|
third parties with this License.
|
||||||
|
|
||||||
|
An *entity transaction* is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered work
|
||||||
|
results from an entity transaction, each party to that transaction who receives
|
||||||
|
a copy of the work also receives whatever licenses to the work the party's
|
||||||
|
predecessor in interest had or could give under the previous paragraph, plus a
|
||||||
|
right to possession of the Corresponding Source of the work from the
|
||||||
|
predecessor in interest, if the predecessor has it or can get it with
|
||||||
|
reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the rights
|
||||||
|
granted or affirmed under this License. For example, you may not impose a
|
||||||
|
license fee, royalty, or other charge for exercise of rights granted under this
|
||||||
|
License, and you may not initiate litigation (including a cross-claim or
|
||||||
|
counterclaim in a lawsuit) alleging that any patent claim is infringed by
|
||||||
|
making, using, selling, offering for sale, or importing the Program or any
|
||||||
|
portion of it.
|
||||||
|
|
||||||
|
### 11. Patents.
|
||||||
|
|
||||||
|
A *contributor* is a copyright holder who authorizes use under this License of
|
||||||
|
the Program or a work on which the Program is based. The work thus licensed is
|
||||||
|
called the contributor's *contributor version*.
|
||||||
|
|
||||||
|
A contributor's *essential patent claims* are all patent claims owned or
|
||||||
|
controlled by the contributor, whether already acquired or hereafter acquired,
|
||||||
|
that would be infringed by some manner, permitted by this License, of making,
|
||||||
|
using, or selling its contributor version, but do not include claims that would
|
||||||
|
be infringed only as a consequence of further modification of the contributor
|
||||||
|
version. For purposes of this definition, *control* includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of this
|
||||||
|
License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free patent
|
||||||
|
license under the contributor's essential patent claims, to make, use, sell,
|
||||||
|
offer for sale, import and otherwise run, modify and propagate the contents of
|
||||||
|
its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a *patent license* is any express agreement
|
||||||
|
or commitment, however denominated, not to enforce a patent (such as an express
|
||||||
|
permission to practice a patent or covenant not to sue for patent
|
||||||
|
infringement). To *grant* such a patent license to a party means to make such
|
||||||
|
an agreement or commitment not to enforce a patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license, and the
|
||||||
|
Corresponding Source of the work is not available for anyone to copy, free of
|
||||||
|
charge and under the terms of this License, through a publicly available
|
||||||
|
network server or other readily accessible means, then you must either
|
||||||
|
|
||||||
|
1. cause the Corresponding Source to be so available, or
|
||||||
|
2. arrange to deprive yourself of the benefit of the patent license for this
|
||||||
|
particular work, or
|
||||||
|
3. arrange, in a manner consistent with the requirements of this License, to
|
||||||
|
extend the patent license to downstream recipients.
|
||||||
|
|
||||||
|
*Knowingly relying* means you have actual knowledge that, but for the patent
|
||||||
|
license, your conveying the covered work in a country, or your recipient's use
|
||||||
|
of the covered work in a country, would infringe one or more identifiable
|
||||||
|
patents in that country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or arrangement, you
|
||||||
|
convey, or propagate by procuring conveyance of, a covered work, and grant a
|
||||||
|
patent license to some of the parties receiving the covered work authorizing
|
||||||
|
them to use, propagate, modify or convey a specific copy of the covered work,
|
||||||
|
then the patent license you grant is automatically extended to all recipients
|
||||||
|
of the covered work and works based on it.
|
||||||
|
|
||||||
|
A patent license is *discriminatory* if it does not include within the scope of
|
||||||
|
its coverage, prohibits the exercise of, or is conditioned on the non-exercise
|
||||||
|
of one or more of the rights that are specifically granted under this License.
|
||||||
|
You may not convey a covered work if you are a party to an arrangement with a
|
||||||
|
third party that is in the business of distributing software, under which you
|
||||||
|
make payment to the third party based on the extent of your activity of
|
||||||
|
conveying the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory patent
|
||||||
|
license
|
||||||
|
|
||||||
|
- a) in connection with copies of the covered work conveyed by you (or copies
|
||||||
|
made from those copies), or
|
||||||
|
- b) primarily for and in connection with specific products or compilations
|
||||||
|
that contain the covered work, unless you entered into that arrangement, or
|
||||||
|
that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting any implied
|
||||||
|
license or other defenses to infringement that may otherwise be available to
|
||||||
|
you under applicable patent law.
|
||||||
|
|
||||||
|
### 12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not excuse
|
||||||
|
you from the conditions of this License. If you cannot convey a covered work so
|
||||||
|
as to satisfy simultaneously your obligations under this License and any other
|
||||||
|
pertinent obligations, then as a consequence you may not convey it at all. For
|
||||||
|
example, if you agree to terms that obligate you to collect a royalty for
|
||||||
|
further conveying from those to whom you convey the Program, the only way you
|
||||||
|
could satisfy both those terms and this License would be to refrain entirely
|
||||||
|
from conveying the Program.
|
||||||
|
|
||||||
|
### 13. Use with the GNU Affero General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have permission to
|
||||||
|
link or combine any covered work with a work licensed under version 3 of the
|
||||||
|
GNU Affero General Public License into a single combined work, and to convey
|
||||||
|
the resulting work. The terms of this License will continue to apply to the
|
||||||
|
part which is the covered work, but the special requirements of the GNU Affero
|
||||||
|
General Public License, section 13, concerning interaction through a network
|
||||||
|
will apply to the combination as such.
|
||||||
|
|
||||||
|
### 14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of the GNU
|
||||||
|
General Public License from time to time. Such new versions will be similar in
|
||||||
|
spirit to the present version, but may differ in detail to address new problems
|
||||||
|
or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the Program specifies
|
||||||
|
that a certain numbered version of the GNU General Public License *or any later
|
||||||
|
version* applies to it, you have the option of following the terms and
|
||||||
|
conditions either of that numbered version or of any later version published by
|
||||||
|
the Free Software Foundation. If the Program does not specify a version number
|
||||||
|
of the GNU General Public License, you may choose any version ever published by
|
||||||
|
the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future versions of the
|
||||||
|
GNU General Public License can be used, that proxy's public statement of
|
||||||
|
acceptance of a version permanently authorizes you to choose that version for
|
||||||
|
the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different permissions.
|
||||||
|
However, no additional obligations are imposed on any author or copyright
|
||||||
|
holder as a result of your choosing to follow a later version.
|
||||||
|
|
||||||
|
### 15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE
|
||||||
|
LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER
|
||||||
|
PARTIES PROVIDE THE PROGRAM *AS IS* WITHOUT WARRANTY OF ANY KIND, EITHER
|
||||||
|
EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
|
||||||
|
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE
|
||||||
|
QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE
|
||||||
|
DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR
|
||||||
|
CORRECTION.
|
||||||
|
|
||||||
|
### 16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY
|
||||||
|
COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS
|
||||||
|
PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL,
|
||||||
|
INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE
|
||||||
|
THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED
|
||||||
|
INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE
|
||||||
|
PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY
|
||||||
|
HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||||
|
|
||||||
|
### 17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided above cannot
|
||||||
|
be given local legal effect according to their terms, reviewing courts shall
|
||||||
|
apply local law that most closely approximates an absolute waiver of all civil
|
||||||
|
liability in connection with the Program, unless a warranty or assumption of
|
||||||
|
liability accompanies a copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
## END OF TERMS AND CONDITIONS ###
|
||||||
|
|
||||||
|
### How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest possible
|
||||||
|
use to the public, the best way to achieve this is to make it free software
|
||||||
|
which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest to attach
|
||||||
|
them to the start of each source file to most effectively state the exclusion
|
||||||
|
of warranty; and each file should have at least the *copyright* line and a
|
||||||
|
pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it output a short notice like
|
||||||
|
this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
<program> Copyright (C) <year> <name of author>
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it
|
||||||
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w` and `show c` should show the appropriate
|
||||||
|
parts of the General Public License. Of course, your program's commands might
|
||||||
|
be different; for a GUI interface, you would use an *about box*.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school, if
|
||||||
|
any, to sign a *copyright disclaimer* for the program, if necessary. For more
|
||||||
|
information on this, and how to apply and follow the GNU GPL, see
|
||||||
|
[http://www.gnu.org/licenses/](http://www.gnu.org/licenses/).
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program into
|
||||||
|
proprietary programs. If your program is a subroutine library, you may consider
|
||||||
|
it more useful to permit linking proprietary applications with the library. If
|
||||||
|
this is what you want to do, use the GNU Lesser General Public License instead
|
||||||
|
of this License. But first, please read
|
||||||
|
[http://www.gnu.org/philosophy/why-not-lgpl.html](http://www.gnu.org/philosophy/why-not-lgpl.html).
|
||||||
10
index.html
10
index.html
@@ -14,24 +14,24 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
|
||||||
<!-- Primary Meta Tags -->
|
<!-- Primary Meta Tags -->
|
||||||
<title>Spritesheet Generator - Create Game Spritesheets Online</title>
|
<title>Spritesheet generator - Create Game Spritesheets Online</title>
|
||||||
<meta name="title" content="Spritesheet Generator - Create Game Spritesheets Online">
|
<meta name="title" content="Spritesheet generator - Create Game Spritesheets Online">
|
||||||
<meta name="description" content="Free online tool to create spritesheets for game development. Upload sprites, arrange them, and export as a spritesheet with animation preview.">
|
<meta name="description" content="Free online tool to create spritesheets for game development. Upload sprites, arrange them, and export as a spritesheet with animation preview.">
|
||||||
<meta name="keywords" content="spritesheet generator, sprite sheet maker, game development, pixel art, sprite animation, game assets, 2D game tools">
|
<meta name="keywords" content="Spritesheet generator, sprite sheet maker, game development, pixel art, sprite animation, game assets, 2D game tools">
|
||||||
<meta name="author" content="nu11ed">
|
<meta name="author" content="nu11ed">
|
||||||
<meta name="robots" content="index, follow">
|
<meta name="robots" content="index, follow">
|
||||||
|
|
||||||
<!-- Open Graph / Facebook -->
|
<!-- Open Graph / Facebook -->
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:type" content="website">
|
||||||
<meta property="og:url" content="https://spritesheetgenerator.online/">
|
<meta property="og:url" content="https://spritesheetgenerator.online/">
|
||||||
<meta property="og:title" content="Spritesheet Generator - Create Game Spritesheets Online">
|
<meta property="og:title" content="Spritesheet generator - Create Game Spritesheets Online">
|
||||||
<meta property="og:description" content="Free online tool to create spritesheets for game development. Upload sprites, arrange them, and export as a spritesheet with animation preview.">
|
<meta property="og:description" content="Free online tool to create spritesheets for game development. Upload sprites, arrange them, and export as a spritesheet with animation preview.">
|
||||||
<meta property="og:image" content="/og-image.png">
|
<meta property="og:image" content="/og-image.png">
|
||||||
|
|
||||||
<!-- Twitter -->
|
<!-- Twitter -->
|
||||||
<meta property="twitter:card" content="summary_large_image">
|
<meta property="twitter:card" content="summary_large_image">
|
||||||
<meta property="twitter:url" content="https://spritesheetgenerator.online/">
|
<meta property="twitter:url" content="https://spritesheetgenerator.online/">
|
||||||
<meta property="twitter:title" content="Spritesheet Generator - Create Game Spritesheets Online">
|
<meta property="twitter:title" content="Spritesheet generator - Create Game Spritesheets Online">
|
||||||
<meta property="twitter:description" content="Free online tool to create spritesheets for game development. Upload sprites, arrange them, and export as a spritesheet with animation preview.">
|
<meta property="twitter:description" content="Free online tool to create spritesheets for game development. Upload sprites, arrange them, and export as a spritesheet with animation preview.">
|
||||||
<meta property="twitter:image" content="/og-image.png">
|
<meta property="twitter:image" content="/og-image.png">
|
||||||
|
|
||||||
|
|||||||
319
package-lock.json
generated
319
package-lock.json
generated
@@ -75,7 +75,6 @@
|
|||||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.5",
|
"@babel/generator": "^7.28.5",
|
||||||
@@ -1386,9 +1385,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.53.2",
|
"version": "4.53.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz",
|
||||||
"integrity": "sha512-yDPzwsgiFO26RJA4nZo8I+xqzh7sJTZIWQOxn+/XOdPE31lAvLIYCKqjV+lNH/vxE2L2iH3plKxDCRK6i+CwhA==",
|
"integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -1399,9 +1398,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm64": {
|
"node_modules/@rollup/rollup-android-arm64": {
|
||||||
"version": "4.53.2",
|
"version": "4.53.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz",
|
||||||
"integrity": "sha512-k8FontTxIE7b0/OGKeSN5B6j25EuppBcWM33Z19JoVT7UTXFSo3D9CdU39wGTeb29NO3XxpMNauh09B+Ibw+9g==",
|
"integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1412,9 +1411,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||||
"version": "4.53.2",
|
"version": "4.53.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz",
|
||||||
"integrity": "sha512-A6s4gJpomNBtJ2yioj8bflM2oogDwzUiMl2yNJ2v9E7++sHrSrsQ29fOfn5DM/iCzpWcebNYEdXpaK4tr2RhfQ==",
|
"integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1425,9 +1424,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-x64": {
|
"node_modules/@rollup/rollup-darwin-x64": {
|
||||||
"version": "4.53.2",
|
"version": "4.53.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz",
|
||||||
"integrity": "sha512-e6XqVmXlHrBlG56obu9gDRPW3O3hLxpwHpLsBJvuI8qqnsrtSZ9ERoWUXtPOkY8c78WghyPHZdmPhHLWNdAGEw==",
|
"integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1438,9 +1437,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||||
"version": "4.53.2",
|
"version": "4.53.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz",
|
||||||
"integrity": "sha512-v0E9lJW8VsrwPux5Qe5CwmH/CF/2mQs6xU1MF3nmUxmZUCHazCjLgYvToOk+YuuUqLQBio1qkkREhxhc656ViA==",
|
"integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1451,9 +1450,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||||
"version": "4.53.2",
|
"version": "4.53.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz",
|
||||||
"integrity": "sha512-ClAmAPx3ZCHtp6ysl4XEhWU69GUB1D+s7G9YjHGhIGCSrsg00nEGRRZHmINYxkdoJehde8VIsDC5t9C0gb6yqA==",
|
"integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1464,9 +1463,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||||
"version": "4.53.2",
|
"version": "4.53.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz",
|
||||||
"integrity": "sha512-EPlb95nUsz6Dd9Qy13fI5kUPXNSljaG9FiJ4YUGU1O/Q77i5DYFW5KR8g1OzTcdZUqQQ1KdDqsTohdFVwCwjqg==",
|
"integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -1477,9 +1476,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||||
"version": "4.53.2",
|
"version": "4.53.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz",
|
||||||
"integrity": "sha512-BOmnVW+khAUX+YZvNfa0tGTEMVVEerOxN0pDk2E6N6DsEIa2Ctj48FOMfNDdrwinocKaC7YXUZ1pHlKpnkja/Q==",
|
"integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -1490,9 +1489,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||||
"version": "4.53.2",
|
"version": "4.53.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz",
|
||||||
"integrity": "sha512-Xt2byDZ+6OVNuREgBXr4+CZDJtrVso5woFtpKdGPhpTPHcNG7D8YXeQzpNbFRxzTVqJf7kvPMCub/pcGUWgBjA==",
|
"integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1503,9 +1502,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||||
"version": "4.53.2",
|
"version": "4.53.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz",
|
||||||
"integrity": "sha512-+LdZSldy/I9N8+klim/Y1HsKbJ3BbInHav5qE9Iy77dtHC/pibw1SR/fXlWyAk0ThnpRKoODwnAuSjqxFRDHUQ==",
|
"integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1516,9 +1515,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||||
"version": "4.53.2",
|
"version": "4.53.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz",
|
||||||
"integrity": "sha512-8ms8sjmyc1jWJS6WdNSA23rEfdjWB30LH8Wqj0Cqvv7qSHnvw6kgMMXRdop6hkmGPlyYBdRPkjJnj3KCUHV/uQ==",
|
"integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@@ -1529,9 +1528,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||||
"version": "4.53.2",
|
"version": "4.53.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz",
|
||||||
"integrity": "sha512-3HRQLUQbpBDMmzoxPJYd3W6vrVHOo2cVW8RUo87Xz0JPJcBLBr5kZ1pGcQAhdZgX9VV7NbGNipah1omKKe23/g==",
|
"integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -1542,9 +1541,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||||
"version": "4.53.2",
|
"version": "4.53.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz",
|
||||||
"integrity": "sha512-fMjKi+ojnmIvhk34gZP94vjogXNNUKMEYs+EDaB/5TG/wUkoeua7p7VCHnE6T2Tx+iaghAqQX8teQzcvrYpaQA==",
|
"integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -1555,9 +1554,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||||
"version": "4.53.2",
|
"version": "4.53.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz",
|
||||||
"integrity": "sha512-XuGFGU+VwUUV5kLvoAdi0Wz5Xbh2SrjIxCtZj6Wq8MDp4bflb/+ThZsVxokM7n0pcbkEr2h5/pzqzDYI7cCgLQ==",
|
"integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -1568,9 +1567,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||||
"version": "4.53.2",
|
"version": "4.53.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz",
|
||||||
"integrity": "sha512-w6yjZF0P+NGzWR3AXWX9zc0DNEGdtvykB03uhonSHMRa+oWA6novflo2WaJr6JZakG2ucsyb+rvhrKac6NIy+w==",
|
"integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -1581,9 +1580,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||||
"version": "4.53.2",
|
"version": "4.53.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz",
|
||||||
"integrity": "sha512-yo8d6tdfdeBArzC7T/PnHd7OypfI9cbuZzPnzLJIyKYFhAQ8SvlkKtKBMbXDxe1h03Rcr7u++nFS7tqXz87Gtw==",
|
"integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1594,9 +1593,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||||
"version": "4.53.2",
|
"version": "4.53.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz",
|
||||||
"integrity": "sha512-ah59c1YkCxKExPP8O9PwOvs+XRLKwh/mV+3YdKqQ5AMQ0r4M4ZDuOrpWkUaqO7fzAHdINzV9tEVu8vNw48z0lA==",
|
"integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1607,9 +1606,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||||
"version": "4.53.2",
|
"version": "4.53.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz",
|
||||||
"integrity": "sha512-4VEd19Wmhr+Zy7hbUsFZ6YXEiP48hE//KPLCSVNY5RMGX2/7HZ+QkN55a3atM1C/BZCGIgqN+xrVgtdak2S9+A==",
|
"integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1620,9 +1619,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||||
"version": "4.53.2",
|
"version": "4.53.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz",
|
||||||
"integrity": "sha512-IlbHFYc/pQCgew/d5fslcy1KEaYVCJ44G8pajugd8VoOEI8ODhtb/j8XMhLpwHCMB3yk2J07ctup10gpw2nyMA==",
|
"integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1633,9 +1632,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||||
"version": "4.53.2",
|
"version": "4.53.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz",
|
||||||
"integrity": "sha512-lNlPEGgdUfSzdCWU176ku/dQRnA7W+Gp8d+cWv73jYrb8uT7HTVVxq62DUYxjbaByuf1Yk0RIIAbDzp+CnOTFg==",
|
"integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -1646,9 +1645,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||||
"version": "4.53.2",
|
"version": "4.53.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz",
|
||||||
"integrity": "sha512-S6YojNVrHybQis2lYov1sd+uj7K0Q05NxHcGktuMMdIQ2VixGwAfbJ23NnlvvVV1bdpR2m5MsNBViHJKcA4ADw==",
|
"integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1659,9 +1658,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||||
"version": "4.53.2",
|
"version": "4.53.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz",
|
||||||
"integrity": "sha512-k+/Rkcyx//P6fetPoLMb8pBeqJBNGx81uuf7iljX9++yNBVRDQgD04L+SVXmXmh5ZP4/WOp4mWF0kmi06PW2tA==",
|
"integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1984,7 +1983,6 @@
|
|||||||
"integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==",
|
"integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
@@ -2086,40 +2084,39 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/compiler-core": {
|
"node_modules/@vue/compiler-core": {
|
||||||
"version": "3.5.24",
|
"version": "3.5.25",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.24.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.25.tgz",
|
||||||
"integrity": "sha512-eDl5H57AOpNakGNAkFDH+y7kTqrQpJkZFXhWZQGyx/5Wh7B1uQYvcWkvZi11BDhscPgj8N7XV3oRwiPnx1Vrig==",
|
"integrity": "sha512-vay5/oQJdsNHmliWoZfHPoVZZRmnSWhug0BYT34njkYTPqClh3DNWLkZNJBVSjsNMrg0CCrBfoKkjZQPM/QVUw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/parser": "^7.28.5",
|
"@babel/parser": "^7.28.5",
|
||||||
"@vue/shared": "3.5.24",
|
"@vue/shared": "3.5.25",
|
||||||
"entities": "^4.5.0",
|
"entities": "^4.5.0",
|
||||||
"estree-walker": "^2.0.2",
|
"estree-walker": "^2.0.2",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/compiler-dom": {
|
"node_modules/@vue/compiler-dom": {
|
||||||
"version": "3.5.24",
|
"version": "3.5.25",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.24.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.25.tgz",
|
||||||
"integrity": "sha512-1QHGAvs53gXkWdd3ZMGYuvQFXHW4ksKWPG8HP8/2BscrbZ0brw183q2oNWjMrSWImYLHxHrx1ItBQr50I/q2zw==",
|
"integrity": "sha512-4We0OAcMZsKgYoGlMjzYvaoErltdFI2/25wqanuTu+S4gismOTRTBPi4IASOjxWdzIwrYSjnqONfKvuqkXzE2Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-core": "3.5.24",
|
"@vue/compiler-core": "3.5.25",
|
||||||
"@vue/shared": "3.5.24"
|
"@vue/shared": "3.5.25"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/compiler-sfc": {
|
"node_modules/@vue/compiler-sfc": {
|
||||||
"version": "3.5.24",
|
"version": "3.5.25",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.24.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.25.tgz",
|
||||||
"integrity": "sha512-8EG5YPRgmTB+YxYBM3VXy8zHD9SWHUJLIGPhDovo3Z8VOgvP+O7UP5vl0J4BBPWYD9vxtBabzW1EuEZ+Cqs14g==",
|
"integrity": "sha512-PUgKp2rn8fFsI++lF2sO7gwO2d9Yj57Utr5yEsDf3GNaQcowCLKL7sf+LvVFvtJDXUp/03+dC6f2+LCv5aK1ag==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/parser": "^7.28.5",
|
"@babel/parser": "^7.28.5",
|
||||||
"@vue/compiler-core": "3.5.24",
|
"@vue/compiler-core": "3.5.25",
|
||||||
"@vue/compiler-dom": "3.5.24",
|
"@vue/compiler-dom": "3.5.25",
|
||||||
"@vue/compiler-ssr": "3.5.24",
|
"@vue/compiler-ssr": "3.5.25",
|
||||||
"@vue/shared": "3.5.24",
|
"@vue/shared": "3.5.25",
|
||||||
"estree-walker": "^2.0.2",
|
"estree-walker": "^2.0.2",
|
||||||
"magic-string": "^0.30.21",
|
"magic-string": "^0.30.21",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
@@ -2127,13 +2124,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/compiler-ssr": {
|
"node_modules/@vue/compiler-ssr": {
|
||||||
"version": "3.5.24",
|
"version": "3.5.25",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.24.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.25.tgz",
|
||||||
"integrity": "sha512-trOvMWNBMQ/odMRHW7Ae1CdfYx+7MuiQu62Jtu36gMLXcaoqKvAyh+P73sYG9ll+6jLB6QPovqoKGGZROzkFFg==",
|
"integrity": "sha512-ritPSKLBcParnsKYi+GNtbdbrIE1mtuFEJ4U1sWeuOMlIziK5GtOL85t5RhsNy4uWIXPgk+OUdpnXiTdzn8o3A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.24",
|
"@vue/compiler-dom": "3.5.25",
|
||||||
"@vue/shared": "3.5.24"
|
"@vue/shared": "3.5.25"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/compiler-vue2": {
|
"node_modules/@vue/compiler-vue2": {
|
||||||
@@ -2243,53 +2240,53 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/reactivity": {
|
"node_modules/@vue/reactivity": {
|
||||||
"version": "3.5.24",
|
"version": "3.5.25",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.24.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.25.tgz",
|
||||||
"integrity": "sha512-BM8kBhtlkkbnyl4q+HiF5R5BL0ycDPfihowulm02q3WYp2vxgPcJuZO866qa/0u3idbMntKEtVNuAUp5bw4teg==",
|
"integrity": "sha512-5xfAypCQepv4Jog1U4zn8cZIcbKKFka3AgWHEFQeK65OW+Ys4XybP6z2kKgws4YB43KGpqp5D/K3go2UPPunLA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/shared": "3.5.24"
|
"@vue/shared": "3.5.25"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/runtime-core": {
|
"node_modules/@vue/runtime-core": {
|
||||||
"version": "3.5.24",
|
"version": "3.5.25",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.24.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.25.tgz",
|
||||||
"integrity": "sha512-RYP/byyKDgNIqfX/gNb2PB55dJmM97jc9wyF3jK7QUInYKypK2exmZMNwnjueWwGceEkP6NChd3D2ZVEp9undQ==",
|
"integrity": "sha512-Z751v203YWwYzy460bzsYQISDfPjHTl+6Zzwo/a3CsAf+0ccEjQ8c+0CdX1WsumRTHeywvyUFtW6KvNukT/smA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/reactivity": "3.5.24",
|
"@vue/reactivity": "3.5.25",
|
||||||
"@vue/shared": "3.5.24"
|
"@vue/shared": "3.5.25"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/runtime-dom": {
|
"node_modules/@vue/runtime-dom": {
|
||||||
"version": "3.5.24",
|
"version": "3.5.25",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.24.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.25.tgz",
|
||||||
"integrity": "sha512-Z8ANhr/i0XIluonHVjbUkjvn+CyrxbXRIxR7wn7+X7xlcb7dJsfITZbkVOeJZdP8VZwfrWRsWdShH6pngMxRjw==",
|
"integrity": "sha512-a4WrkYFbb19i9pjkz38zJBg8wa/rboNERq3+hRRb0dHiJh13c+6kAbgqCPfMaJ2gg4weWD3APZswASOfmKwamA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/reactivity": "3.5.24",
|
"@vue/reactivity": "3.5.25",
|
||||||
"@vue/runtime-core": "3.5.24",
|
"@vue/runtime-core": "3.5.25",
|
||||||
"@vue/shared": "3.5.24",
|
"@vue/shared": "3.5.25",
|
||||||
"csstype": "^3.1.3"
|
"csstype": "^3.1.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/server-renderer": {
|
"node_modules/@vue/server-renderer": {
|
||||||
"version": "3.5.24",
|
"version": "3.5.25",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.24.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.25.tgz",
|
||||||
"integrity": "sha512-Yh2j2Y4G/0/4z/xJ1Bad4mxaAk++C2v4kaa8oSYTMJBJ00/ndPuxCnWeot0/7/qafQFLh5pr6xeV6SdMcE/G1w==",
|
"integrity": "sha512-UJaXR54vMG61i8XNIzTSf2Q7MOqZHpp8+x3XLGtE3+fL+nQd+k7O5+X3D/uWrnQXOdMw5VPih+Uremcw+u1woQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-ssr": "3.5.24",
|
"@vue/compiler-ssr": "3.5.25",
|
||||||
"@vue/shared": "3.5.24"
|
"@vue/shared": "3.5.25"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"vue": "3.5.24"
|
"vue": "3.5.25"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/shared": {
|
"node_modules/@vue/shared": {
|
||||||
"version": "3.5.24",
|
"version": "3.5.25",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.24.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.25.tgz",
|
||||||
"integrity": "sha512-9cwHL2EsJBdi8NY22pngYYWzkTDhld6fAD6jlaeloNGciNSJL6bLpbxVgXl96X00Jtc6YWQv96YA/0sxex/k1A==",
|
"integrity": "sha512-AbOPdQQnAnzs58H2FrrDxYj/TJfmeS2jdfEEhgiKINy+bnOANmVizIEgq1r+C5zsbs6l1CCQxtcj71rwNQ4jWg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@vue/tsconfig": {
|
"node_modules/@vue/tsconfig": {
|
||||||
@@ -2377,9 +2374,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.8.29",
|
"version": "2.8.31",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.29.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.31.tgz",
|
||||||
"integrity": "sha512-sXdt2elaVnhpDNRDz+1BDx1JQoJRuNk7oVlAlbGiFkLikHCAQiccexF/9e91zVi6RCgqspl04aP+6Cnl9zRLrA==",
|
"integrity": "sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -2438,7 +2435,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.8.25",
|
"baseline-browser-mapping": "^2.8.25",
|
||||||
"caniuse-lite": "^1.0.30001754",
|
"caniuse-lite": "^1.0.30001754",
|
||||||
@@ -2477,9 +2473,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001755",
|
"version": "1.0.30001757",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001755.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz",
|
||||||
"integrity": "sha512-44V+Jm6ctPj7R52Na4TLi3Zri4dWUljJd+RDm+j8LtNCc/ihLCT+X1TzoOAkRETEWqjuLnh9581Tl80FvK7jVA==",
|
"integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -2670,9 +2666,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.255",
|
"version": "1.5.260",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.255.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.260.tgz",
|
||||||
"integrity": "sha512-Z9oIp4HrFF/cZkDPMpz2XSuVpc1THDpT4dlmATFlJUIBVCy9Vap5/rIXsASP1CscBacBqhabwh8vLctqBwEerQ==",
|
"integrity": "sha512-ov8rBoOBhVawpzdre+Cmz4FB+y66Eqrk6Gwqd8NGxuhv99GQ8XqMAr351KEkOt7gukXWDg6gJWEMKgL2RLMPtA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
@@ -3809,7 +3805,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -3832,7 +3827,6 @@
|
|||||||
"integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
|
"integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"prettier": "bin/prettier.cjs"
|
"prettier": "bin/prettier.cjs"
|
||||||
},
|
},
|
||||||
@@ -3915,11 +3909,10 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.53.2",
|
"version": "4.53.3",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.2.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz",
|
||||||
"integrity": "sha512-MHngMYwGJVi6Fmnk6ISmnk7JAHRNF0UkuucA0CUW3N3a4KnONPEZz+vUanQP/ZC/iY1Qkf3bwPWzyY84wEks1g==",
|
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.8"
|
"@types/estree": "1.0.8"
|
||||||
},
|
},
|
||||||
@@ -3931,28 +3924,28 @@
|
|||||||
"npm": ">=8.0.0"
|
"npm": ">=8.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rollup/rollup-android-arm-eabi": "4.53.2",
|
"@rollup/rollup-android-arm-eabi": "4.53.3",
|
||||||
"@rollup/rollup-android-arm64": "4.53.2",
|
"@rollup/rollup-android-arm64": "4.53.3",
|
||||||
"@rollup/rollup-darwin-arm64": "4.53.2",
|
"@rollup/rollup-darwin-arm64": "4.53.3",
|
||||||
"@rollup/rollup-darwin-x64": "4.53.2",
|
"@rollup/rollup-darwin-x64": "4.53.3",
|
||||||
"@rollup/rollup-freebsd-arm64": "4.53.2",
|
"@rollup/rollup-freebsd-arm64": "4.53.3",
|
||||||
"@rollup/rollup-freebsd-x64": "4.53.2",
|
"@rollup/rollup-freebsd-x64": "4.53.3",
|
||||||
"@rollup/rollup-linux-arm-gnueabihf": "4.53.2",
|
"@rollup/rollup-linux-arm-gnueabihf": "4.53.3",
|
||||||
"@rollup/rollup-linux-arm-musleabihf": "4.53.2",
|
"@rollup/rollup-linux-arm-musleabihf": "4.53.3",
|
||||||
"@rollup/rollup-linux-arm64-gnu": "4.53.2",
|
"@rollup/rollup-linux-arm64-gnu": "4.53.3",
|
||||||
"@rollup/rollup-linux-arm64-musl": "4.53.2",
|
"@rollup/rollup-linux-arm64-musl": "4.53.3",
|
||||||
"@rollup/rollup-linux-loong64-gnu": "4.53.2",
|
"@rollup/rollup-linux-loong64-gnu": "4.53.3",
|
||||||
"@rollup/rollup-linux-ppc64-gnu": "4.53.2",
|
"@rollup/rollup-linux-ppc64-gnu": "4.53.3",
|
||||||
"@rollup/rollup-linux-riscv64-gnu": "4.53.2",
|
"@rollup/rollup-linux-riscv64-gnu": "4.53.3",
|
||||||
"@rollup/rollup-linux-riscv64-musl": "4.53.2",
|
"@rollup/rollup-linux-riscv64-musl": "4.53.3",
|
||||||
"@rollup/rollup-linux-s390x-gnu": "4.53.2",
|
"@rollup/rollup-linux-s390x-gnu": "4.53.3",
|
||||||
"@rollup/rollup-linux-x64-gnu": "4.53.2",
|
"@rollup/rollup-linux-x64-gnu": "4.53.3",
|
||||||
"@rollup/rollup-linux-x64-musl": "4.53.2",
|
"@rollup/rollup-linux-x64-musl": "4.53.3",
|
||||||
"@rollup/rollup-openharmony-arm64": "4.53.2",
|
"@rollup/rollup-openharmony-arm64": "4.53.3",
|
||||||
"@rollup/rollup-win32-arm64-msvc": "4.53.2",
|
"@rollup/rollup-win32-arm64-msvc": "4.53.3",
|
||||||
"@rollup/rollup-win32-ia32-msvc": "4.53.2",
|
"@rollup/rollup-win32-ia32-msvc": "4.53.3",
|
||||||
"@rollup/rollup-win32-x64-gnu": "4.53.2",
|
"@rollup/rollup-win32-x64-gnu": "4.53.3",
|
||||||
"@rollup/rollup-win32-x64-msvc": "4.53.2",
|
"@rollup/rollup-win32-x64-msvc": "4.53.3",
|
||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4012,7 +4005,6 @@
|
|||||||
"integrity": "sha512-+VUy01yfDqNmIVMd/LLKl2TTtY0ovZN0rTonh+FhKr65mFwIYgU9WzgIZKS7U9/SPCQvWTsTGx9jyt+qRm/XFw==",
|
"integrity": "sha512-+VUy01yfDqNmIVMd/LLKl2TTtY0ovZN0rTonh+FhKr65mFwIYgU9WzgIZKS7U9/SPCQvWTsTGx9jyt+qRm/XFw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bufbuild/protobuf": "^2.5.0",
|
"@bufbuild/protobuf": "^2.5.0",
|
||||||
"buffer-builder": "^0.2.0",
|
"buffer-builder": "^0.2.0",
|
||||||
@@ -4569,7 +4561,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -4613,7 +4604,6 @@
|
|||||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -4701,7 +4691,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.4.4",
|
"fdir": "^6.4.4",
|
||||||
@@ -4881,7 +4870,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -4897,17 +4885,16 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/vue": {
|
"node_modules/vue": {
|
||||||
"version": "3.5.24",
|
"version": "3.5.25",
|
||||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.24.tgz",
|
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.25.tgz",
|
||||||
"integrity": "sha512-uTHDOpVQTMjcGgrqFPSb8iO2m1DUvo+WbGqoXQz8Y1CeBYQ0FXf2z1gLRaBtHjlRz7zZUBHxjVB5VTLzYkvftg==",
|
"integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.24",
|
"@vue/compiler-dom": "3.5.25",
|
||||||
"@vue/compiler-sfc": "3.5.24",
|
"@vue/compiler-sfc": "3.5.25",
|
||||||
"@vue/runtime-dom": "3.5.24",
|
"@vue/runtime-dom": "3.5.25",
|
||||||
"@vue/server-renderer": "3.5.24",
|
"@vue/server-renderer": "3.5.25",
|
||||||
"@vue/shared": "3.5.24"
|
"@vue/shared": "3.5.25"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "*"
|
"typescript": "*"
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [1.8.0] - 2025-11-23
|
||||||
|
- Fix context menu location
|
||||||
|
- You can now reposition all sprites in current frame
|
||||||
|
|
||||||
|
## [1.7.0] - 2025-11-22
|
||||||
|
- Add layer support
|
||||||
|
- Add background color picker
|
||||||
|
- Improved UI
|
||||||
|
|
||||||
## [1.6.0] - 2025-11-18
|
## [1.6.0] - 2025-11-18
|
||||||
- Improved animation preview modal
|
- Improved animation preview modal
|
||||||
- Add toggle for negative spacing in cells
|
- Add toggle for negative spacing in cells
|
||||||
|
- Show cell size
|
||||||
|
|
||||||
## [1.5.0] - 2025-11-17
|
## [1.5.0] - 2025-11-17
|
||||||
- Show offset values in sprite cells and in preview modal
|
- Show offset values in sprite cells and in preview modal
|
||||||
@@ -33,30 +43,30 @@ All notable changes to this project will be documented in this file.
|
|||||||
## [1.7.0] - 2025-05-02
|
## [1.7.0] - 2025-05-02
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
- 🪟 Checkerboard pattern inside sprite cells as it could conflict with the sprite. (Thanks Rivers)
|
- Checkerboard pattern inside sprite cells as it could conflict with the sprite. (Thanks Rivers)
|
||||||
|
|
||||||
## [1.6.0] - 2025-04-30
|
## [1.6.0] - 2025-04-30
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- 🎨 Dark mode support
|
- Dark mode support
|
||||||
- ⭐ Preview other sprites inside cells from overview
|
- Preview other sprites inside cells from overview
|
||||||
|
|
||||||
## [1.5.0] - 2025-04-30
|
## [1.5.0] - 2025-04-30
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- 📏 Offset indicators for better sprite alignment
|
- Offset indicators for better sprite alignment
|
||||||
- Set base offset for offset indicators
|
- Set base offset for offset indicators
|
||||||
|
|
||||||
## [1.4.0] - 2025-04-06
|
## [1.4.0] - 2025-04-06
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- 🎥 Download as GIF functionality
|
- Download as GIF functionality
|
||||||
- 🗂 Download as ZIP functionality
|
- Download as ZIP functionality
|
||||||
|
|
||||||
## [1.3.0] - 2025-04-06
|
## [1.3.0] - 2025-04-06
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- 📄 When importing a spritesheet, the tool will now remove transparent from the edges of each sprite so you can move them inside their cells.
|
- When importing a spritesheet, the tool will now remove transparent from the edges of each sprite so you can move them inside their cells.
|
||||||
|
|
||||||
## [1.2.0] - 2025-04-06
|
## [1.2.0] - 2025-04-06
|
||||||
|
|
||||||
@@ -66,22 +76,22 @@ All notable changes to this project will be documented in this file.
|
|||||||
## [1.1.0] - 2025-04-06
|
## [1.1.0] - 2025-04-06
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- 📝 Help modal with instructions and tips
|
- Help modal with instructions and tips
|
||||||
- 🎨 Pixel perfect mode for better sprite alignment
|
- Pixel perfect mode for better sprite alignment
|
||||||
|
|
||||||
## [1.0.0] - 2025-04-06
|
## [1.0.0] - 2025-04-06
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- 🎉 Initial release
|
- 🎉 Initial release
|
||||||
- ✨ Basic spritesheet generation functionality
|
- Basic spritesheet generation functionality
|
||||||
- Drag and drop image upload
|
- Drag and drop image upload
|
||||||
- Grid-based sprite arrangement
|
- Grid-based sprite arrangement
|
||||||
- Custom grid size configuration
|
- Custom grid size configuration
|
||||||
- 🎮 Animation preview functionality
|
- Animation preview functionality
|
||||||
- Real-time animation preview
|
- Real-time animation preview
|
||||||
- Adjustable animation speed
|
- Adjustable animation speed
|
||||||
- Frame-by-frame navigation
|
- Frame-by-frame navigation
|
||||||
- 💾 JSON import/export support
|
- JSON import/export support
|
||||||
- Save sprite arrangements
|
- Save sprite arrangements
|
||||||
- Load previous projects
|
- Load previous projects
|
||||||
- Export configuration files
|
- Export configuration files
|
||||||
3
public/robots.txt
Normal file
3
public/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
Sitemap: https://spritesheetgenerator.online/sitemap.xml
|
||||||
16
public/sitemap.xml
Normal file
16
public/sitemap.xml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset
|
||||||
|
xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
|
||||||
|
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
|
||||||
|
<!-- created with Free Online Sitemap Generator www.xml-sitemaps.com -->
|
||||||
|
|
||||||
|
|
||||||
|
<url>
|
||||||
|
<loc>https://spritesheetgenerator.online/</loc>
|
||||||
|
<lastmod>2025-11-25T17:52:14+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
|
||||||
|
|
||||||
|
</urlset>
|
||||||
599
src/App.vue
599
src/App.vue
@@ -1,116 +1,335 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen p-3 sm:p-6 bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 transition-colors duration-300">
|
<div class="min-h-screen flex flex-col p-4 sm:p-8 bg-slate-50 dark:bg-gray-950 transition-colors duration-300" :class="{ 'lg:h-screen': layers.some(l => l.sprites.length) }">
|
||||||
<div class="max-w-7xl mx-auto">
|
<div class="flex flex-col flex-1" :class="{ 'lg:overflow-hidden': layers.some(l => l.sprites.length) }">
|
||||||
<div class="flex flex-col sm:flex-row justify-between items-center mb-4 sm:mb-8 gap-2">
|
<header class="mb-6 sm:mb-5">
|
||||||
<h1 class="text-2xl sm:text-4xl font-bold text-gray-900 dark:text-white tracking-tight text-center sm:text-left">Spritesheet generator</h1>
|
<div class="flex flex-col sm:flex-row justify-between items-center gap-6 mb-8">
|
||||||
<dark-mode-toggle />
|
<div class="text-center sm:text-left">
|
||||||
</div>
|
<h1 class="text-3xl sm:text-5xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-gray-900 to-gray-700 dark:from-white dark:to-gray-300 tracking-tight mb-3">Spritesheet generator</h1>
|
||||||
<div class="flex flex-wrap justify-center gap-4 mb-4 sm:mb-8">
|
<p class="text-sm sm:text-base text-gray-600 dark:text-gray-400 font-medium">Create professional spritesheets for your game development projects</p>
|
||||||
<a href="https://gitea.adhd.sh/root/spritesheet-generator" target="_blank" class="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300 transition-colors" data-rybbit-event="source-link"> <i class="fab fa-github"></i> Source </a>
|
|
||||||
<a href="https://discord.gg/JTev3nzeDa" target="_blank" class="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300 transition-colors" data-rybbit-event="discord-link"> <i class="fab fa-discord"></i> Discord </a>
|
|
||||||
<a href="#" @click.prevent="openHelpModal" class="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300 transition-colors" data-rybbit-event="help-link"> <i class="fas fa-question-circle"></i> Help </a>
|
|
||||||
<a href="#" @click.prevent="openFeedbackModal" class="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300 transition-colors" data-rybbit-event="feedback-link"> <i class="fas fa-comment-dots"></i> Feedback </a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-soft dark:shadow-gray-900/30 p-4 sm:p-8 transition-colors duration-300">
|
|
||||||
<div class="flex flex-col sm:flex-row justify-between items-center mb-4 sm:mb-6 gap-3">
|
|
||||||
<h2 class="text-xl font-semibold text-gray-800 dark:text-gray-100">Upload sprites</h2>
|
|
||||||
<button
|
|
||||||
@click="openJSONImportDialog"
|
|
||||||
class="w-full sm:w-auto px-4 py-2 bg-indigo-500 hover:bg-indigo-600 dark:bg-indigo-600 dark:hover:bg-indigo-700 text-white font-medium rounded-lg transition-colors flex items-center justify-center sm:justify-start space-x-2"
|
|
||||||
data-rybbit-event="import-json"
|
|
||||||
>
|
|
||||||
<i class="fas fa-file-import"></i>
|
|
||||||
<span>Import JSON</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<file-uploader @upload-sprites="handleSpritesUpload" />
|
|
||||||
<input ref="jsonFileInput" type="file" accept=".json,application/json" class="hidden" @change="handleJSONFileChange" />
|
|
||||||
<div v-if="!sprites.length" class="mt-8">
|
|
||||||
<div class="mt-2 leading-relaxed space-y-2">
|
|
||||||
<p>Create spritesheets for your game development and animation projects with our completely free, open-source spritesheet generator.</p>
|
|
||||||
<p>This powerful online tool lets you upload individual sprite images and automatically arrange them into optimized sprite sheets with customizable layouts - perfect for indie developers, animators, and studios of any size.</p>
|
|
||||||
<p class="font-bold text-2xl pb-3 pt-2">How it works:</p>
|
|
||||||
<video controls playsinline class="w-full h-full object-contain rounded-lg shadow-md" title="Spritesheet generator tutorial" aria-label="Spritesheet generator tutorial">
|
|
||||||
<source src="@/assets/tut2.mp4" type="video/mp4" />
|
|
||||||
</video>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<nav class="flex flex-wrap items-center justify-center gap-3">
|
||||||
|
<a
|
||||||
<div v-if="sprites.length > 0" class="mt-8">
|
href="https://gitea.adhd.sh/root/spritesheet-generator"
|
||||||
<div class="flex flex-wrap items-center justify-center sm:justify-start gap-3 sm:gap-6 mb-6 sm:mb-8">
|
target="_blank"
|
||||||
<div class="flex items-center space-x-1">
|
class="btn btn-secondary hover:shadow-md"
|
||||||
<label for="columns" class="text-gray-700 dark:text-gray-200 font-medium">Columns:</label>
|
data-rybbit-event="source-link"
|
||||||
<input
|
|
||||||
id="columns"
|
|
||||||
type="number"
|
|
||||||
v-model.number="columns"
|
|
||||||
min="1"
|
|
||||||
max="10"
|
|
||||||
class="w-20 px-3 py-2 border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-transparent outline-none transition-all text-base"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Add mass position buttons -->
|
|
||||||
<div class="flex flex-wrap items-center justify-center gap-2">
|
|
||||||
<button @click="alignSprites('left')" class="p-3 sm:p-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-100 rounded-lg transition-colors" title="Align Left" data-rybbit-event="align-left">
|
|
||||||
<i class="fas fa-arrow-left"></i>
|
|
||||||
</button>
|
|
||||||
<button @click="alignSprites('center')" class="p-3 sm:p-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-100 rounded-lg transition-colors" title="Align Center" data-rybbit-event="align-center">
|
|
||||||
<i class="fas fa-arrows-left-right"></i>
|
|
||||||
</button>
|
|
||||||
<button @click="alignSprites('right')" class="p-3 sm:p-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-100 rounded-lg transition-colors" title="Align Right" data-rybbit-event="align-right">
|
|
||||||
<i class="fas fa-arrow-right"></i>
|
|
||||||
</button>
|
|
||||||
<button @click="alignSprites('top')" class="p-3 sm:p-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-100 rounded-lg transition-colors" title="Align Top" data-rybbit-event="align-top">
|
|
||||||
<i class="fas fa-arrow-up"></i>
|
|
||||||
</button>
|
|
||||||
<button @click="alignSprites('middle')" class="p-3 sm:p-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-100 rounded-lg transition-colors" title="Align Middle" data-rybbit-event="align-middle">
|
|
||||||
<i class="fas fa-arrows-up-down"></i>
|
|
||||||
</button>
|
|
||||||
<button @click="alignSprites('bottom')" class="p-3 sm:p-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-100 rounded-lg transition-colors" title="Align Bottom" data-rybbit-event="align-bottom">
|
|
||||||
<i class="fas fa-arrow-down"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button @click="downloadSpritesheet" class="w-full sm:w-auto px-6 py-3 sm:py-2.5 bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700 text-white font-medium rounded-lg transition-colors flex items-center justify-center space-x-2" data-rybbit-event="download-spritesheet">
|
|
||||||
<i class="fas fa-download"></i>
|
|
||||||
<span>Download spritesheet</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
@click="exportSpritesheetJSON"
|
|
||||||
class="w-full sm:w-auto px-6 py-3 sm:py-2.5 bg-purple-500 hover:bg-purple-600 dark:bg-purple-600 dark:hover:bg-purple-700 text-white font-medium rounded-lg transition-colors flex items-center justify-center space-x-2"
|
|
||||||
data-rybbit-event="export-json"
|
|
||||||
>
|
>
|
||||||
<i class="fas fa-file-code"></i>
|
<i class="fab fa-github"></i>
|
||||||
<span>Export as JSON</span>
|
<span class="font-medium">Source</span>
|
||||||
</button>
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://discord.gg/JTev3nzeDa"
|
||||||
|
target="_blank"
|
||||||
|
class="btn btn-secondary hover:shadow-md"
|
||||||
|
data-rybbit-event="discord-link"
|
||||||
|
>
|
||||||
|
<i class="fab fa-discord"></i>
|
||||||
|
<span class="font-medium">Discord</span>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
@click.prevent="openHelpModal"
|
||||||
|
class="btn btn-secondary hover:shadow-md"
|
||||||
|
data-rybbit-event="help-link"
|
||||||
|
>
|
||||||
|
<i class="fas fa-question-circle"></i>
|
||||||
|
<span class="font-medium">Help</span>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
@click.prevent="openFeedbackModal"
|
||||||
|
class="btn btn-secondary hover:shadow-md"
|
||||||
|
data-rybbit-event="feedback-link"
|
||||||
|
>
|
||||||
|
<i class="fas fa-comment-dots"></i>
|
||||||
|
<span class="font-medium">Feedback</span>
|
||||||
|
</a>
|
||||||
|
<dark-mode-toggle />
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<button @click="openGifFpsModal" class="w-full sm:w-auto px-6 py-3 sm:py-2.5 bg-amber-500 hover:bg-amber-600 dark:bg-amber-600 dark:hover:bg-amber-700 text-white font-medium rounded-lg transition-colors flex items-center justify-center space-x-2" data-rybbit-event="download-gif">
|
<!-- <div class="flex-shrink-0 p-4 mb-6 bg-gradient-to-r from-blue-50 to-purple-50 dark:from-gray-800 dark:to-gray-700 border border-blue-100 dark:border-gray-600 rounded-2xl shadow-sm">-->
|
||||||
<i class="fas fa-film"></i>
|
<!-- <div class="flex flex-col sm:flex-row items-center justify-between gap-2">-->
|
||||||
<span>Download as GIF</span>
|
<!-- <div class="flex items-center gap-2 text-center sm:text-left">-->
|
||||||
</button>
|
<!-- <i class="text-lg text-blue-600 dark:text-blue-400 fas fa-ad"></i>-->
|
||||||
|
<!-- <span class="text-sm font-semibold text-gray-800 dark:text-gray-100">Your ad here</span>-->
|
||||||
|
<!-- <span class="hidden sm:inline text-xs text-gray-600 dark:text-gray-400">- Reach thousands of game developers and creative professionals</span>-->
|
||||||
|
<!-- </div>-->
|
||||||
|
<!-- <div class="flex gap-2">-->
|
||||||
|
<!-- <a href="mailto:root@adhd.sh" class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 rounded transition-all">-->
|
||||||
|
<!-- <i class="text-xs fas fa-envelope"></i>-->
|
||||||
|
<!-- <span class="hidden sm:inline">root@adhd.sh</span>-->
|
||||||
|
<!-- <span class="sm:hidden">Email</span>-->
|
||||||
|
<!-- </a>-->
|
||||||
|
<!-- <a href="https://discord.gg/JTev3nzeDa" target="_blank" class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-white bg-indigo-600 hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 rounded transition-all">-->
|
||||||
|
<!-- <i class="text-xs fab fa-discord"></i>-->
|
||||||
|
<!-- <span class="hidden sm:inline">nu11ed</span>-->
|
||||||
|
<!-- <span class="sm:hidden">Discord</span>-->
|
||||||
|
<!-- </a>-->
|
||||||
|
<!-- </div>-->
|
||||||
|
<!-- </div>-->
|
||||||
|
<!-- </div>-->
|
||||||
|
|
||||||
<button @click="downloadAsZip" class="w-full sm:w-auto px-6 py-3 sm:py-2.5 bg-teal-500 hover:bg-teal-600 dark:bg-teal-600 dark:hover:bg-teal-700 text-white font-medium rounded-lg transition-colors flex items-center justify-center space-x-2" data-rybbit-event="download-zip">
|
<main class="flex flex-col flex-1 bg-white/80 dark:bg-gray-900/80 backdrop-blur-md rounded-3xl shadow-2xl border border-gray-200/50 dark:border-gray-700/50 transition-all duration-300" :class="{ 'lg:overflow-hidden': layers.some(l => l.sprites.length) }">
|
||||||
<i class="fas fa-file-archive"></i>
|
<!-- Welcome state -->
|
||||||
<span>Download as ZIP</span>
|
<div v-if="!layers.some(l => l.sprites.length)" class="p-6 sm:p-10">
|
||||||
</button>
|
<div class="mb-8">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-800 dark:text-gray-100 mb-1">Upload sprites or single image</h2>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">Drag and drop images or import from JSON</p>
|
||||||
|
</div>
|
||||||
|
<file-uploader @upload-sprites="handleSpritesUpload" />
|
||||||
|
|
||||||
<button @click="openPreviewModal" class="w-full sm:w-auto px-6 py-3 sm:py-2.5 bg-green-500 hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-700 text-white font-medium rounded-lg transition-colors flex items-center justify-center space-x-2" data-rybbit-event="preview-animation">
|
<div class="mt-10">
|
||||||
<i class="fas fa-play"></i>
|
<div class="prose prose-lg dark:prose-invert max-w-none">
|
||||||
<span>Preview animation</span>
|
<div>
|
||||||
</button>
|
<h3 class="text-2xl font-bold text-gray-800 dark:text-gray-100 mb-4">Welcome to Spritesheet generator</h3>
|
||||||
|
<p class="text-gray-700 dark:text-gray-300 mb-4">Create spritesheets for your game development and animation projects with our completely free, open-source Spritesheet generator.</p>
|
||||||
|
<p class="text-gray-700 dark:text-gray-300 mb-6">This powerful online tool lets you upload individual sprite images and automatically arrange them into optimized sprite sheets with customizable layouts - perfect for indie developers, animators, and studios of any size.</p>
|
||||||
|
<h3 class="text-2xl font-bold text-gray-800 dark:text-gray-100 mb-4">Key features of this sprite editor</h3>
|
||||||
|
<ul class="text-gray-700 dark:text-gray-300 mb-6 space-y-2 list-disc">
|
||||||
|
<li><strong>Free sprite editor</strong>: Edit, organize, and optimize your game sprites directly in your browser</li>
|
||||||
|
<li><strong>Automatic spritesheet generation</strong>: Convert multiple PNG, JPG, or GIF images into efficient sprite atlases</li>
|
||||||
|
<li><strong>Customizable grid layouts</strong>: Adjust spacing, padding, and arrangement for pixel-perfect results</li>
|
||||||
|
<li><strong>Animation preview</strong>: Test your sprite animations before exporting</li>
|
||||||
|
<li><strong>Cross-platform compatibility</strong>: Works with Unity, Godot, Phaser, Pygame, and other game engines</li>
|
||||||
|
<li><strong>Zero installation required</strong>: No downloads - use our web-based sprite sheet maker instantly</li>
|
||||||
|
<li><strong>Batch processing</strong>: Upload and process multiple sprites simultaneously</li>
|
||||||
|
<li><strong>Export options</strong>: Download spritesheet as PNG, JPG, GIF, ZIP or JSON.</li>
|
||||||
|
</ul>
|
||||||
|
<div>
|
||||||
|
<h4 class="text-xl font-bold text-gray-800 dark:text-gray-100 mb-4 flex items-center gap-2">
|
||||||
|
<i class="fas fa-play-circle text-gray-800 dark:text-gray-200"></i>
|
||||||
|
How it works
|
||||||
|
</h4>
|
||||||
|
<video controls playsinline class="w-full rounded-lg shadow-lg border border-gray-200 dark:border-gray-700" title="Spritesheet generator tutorial" aria-label="Spritesheet generator tutorial">
|
||||||
|
<source src="@/assets/demo.mp4" type="video/mp4" />
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Two-column layout: Left controls, Right preview -->
|
||||||
|
<div v-if="layers.some(l => l.sprites.length)" class="flex flex-col flex-1 lg:grid lg:grid-cols-[380px_1fr] xl:grid-cols-[420px_1fr] lg:overflow-hidden">
|
||||||
|
<!-- Left sidebar - Controls -->
|
||||||
|
<div class="p-6 bg-gray-50/50 dark:bg-gray-900/30 border-b lg:border-b-0 lg:border-r border-gray-200 dark:border-gray-700 lg:overflow-y-auto lg:overflow-x-auto">
|
||||||
|
<div class="space-y-8">
|
||||||
|
<!-- Upload Section -->
|
||||||
|
<section>
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h3 class="flex items-center gap-2 text-base font-bold text-gray-800 dark:text-gray-100">
|
||||||
|
<i class="text-sm text-gray-700 dark:text-gray-300 fas fa-upload"></i>
|
||||||
|
Upload
|
||||||
|
</h3>
|
||||||
|
<button @click="openJSONImportDialog" class="btn btn-dark btn-sm" data-rybbit-event="import-json">
|
||||||
|
<i class="text-xs fas fa-file-import"></i>
|
||||||
|
<span>JSON</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button class="w-full p-6 text-center border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl hover:border-gray-500 dark:hover:border-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-all cursor-pointer focus:outline-none focus:ring-2 focus:ring-gray-500 group" @click="openFileDialog">
|
||||||
|
<i class="fas fa-plus-circle text-3xl text-gray-400 dark:text-gray-500 group-hover:text-gray-600 dark:group-hover:text-gray-300 mb-3 transition-colors"></i>
|
||||||
|
<p class="text-sm font-medium text-gray-600 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-gray-200 transition-colors">Add sprites</p>
|
||||||
|
</button>
|
||||||
|
<input ref="uploadInput" type="file" multiple accept="image/*" class="hidden" @change="handleUploadChange" />
|
||||||
|
<input ref="jsonFileInput" type="file" accept=".json,application/json" class="hidden" @change="handleJSONFileChange" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Layers -->
|
||||||
|
<section>
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h3 class="flex items-center gap-2 text-base font-bold text-gray-800 dark:text-gray-100">
|
||||||
|
<i class="text-sm text-gray-700 dark:text-gray-300 fas fa-layer-group"></i>
|
||||||
|
Layers
|
||||||
|
</h3>
|
||||||
|
<button @click="addLayer()" class="btn btn-dark btn-sm">
|
||||||
|
<i class="text-xs fas fa-plus"></i>
|
||||||
|
<span>Add</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="layer in layers"
|
||||||
|
:key="layer.id"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-white dark:bg-gray-800 border rounded-lg transition-all"
|
||||||
|
:class="[layer.id === activeLayerId ? 'border-gray-800 ring-1 ring-gray-800 dark:border-gray-400 dark:ring-gray-400' : 'border-gray-200 dark:border-gray-700', !layer.visible ? 'opacity-50' : '']"
|
||||||
|
>
|
||||||
|
<button @click.stop="layer.visible = !layer.visible" class="btn btn-ghost btn-icon-sm rounded" :title="layer.visible ? 'Hide layer' : 'Show layer'">
|
||||||
|
<i :class="layer.visible ? 'text-sm text-gray-800 dark:text-gray-200 fas fa-eye' : 'text-sm text-gray-400 dark:text-gray-500 fas fa-eye-slash'"></i>
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
v-if="editingLayerId === layer.id"
|
||||||
|
type="text"
|
||||||
|
v-model="editingLayerName"
|
||||||
|
@blur="finishEditingLayer"
|
||||||
|
@keyup.enter="finishEditingLayer"
|
||||||
|
@keyup.esc="cancelEditingLayer"
|
||||||
|
class="flex-1 px-2 py-1 text-sm border border-gray-800 dark:border-gray-400 dark:bg-gray-700 dark:text-gray-100 rounded outline-none focus:ring-2 focus:ring-gray-800 dark:focus:ring-gray-400"
|
||||||
|
ref="layerNameInput"
|
||||||
|
@click.stop
|
||||||
|
/>
|
||||||
|
<button v-else @click="activeLayerId = layer.id" class="flex-1 px-2 py-1 text-sm font-medium text-left rounded transition-all cursor-pointer" :class="layer.id === activeLayerId ? 'text-gray-900 dark:text-gray-100' : 'text-gray-700 dark:text-gray-300'">
|
||||||
|
{{ layer.name }}
|
||||||
|
<span v-if="layer.sprites.length" class="ml-1 text-xs opacity-60">({{ layer.sprites.length }})</span>
|
||||||
|
</button>
|
||||||
|
<button v-if="editingLayerId !== layer.id" @click="startEditingLayer(layer.id, layer.name)" class="btn btn-ghost btn-icon-xs rounded" title="Rename">
|
||||||
|
<i class="text-xs text-gray-600 dark:text-gray-400 fas fa-pen"></i>
|
||||||
|
</button>
|
||||||
|
<button @click="moveLayer(layer.id, 'up')" class="btn btn-ghost btn-icon-xs rounded" title="Move up">
|
||||||
|
<i class="text-xs text-gray-600 dark:text-gray-400 fas fa-chevron-up"></i>
|
||||||
|
</button>
|
||||||
|
<button @click="moveLayer(layer.id, 'down')" class="btn btn-ghost btn-icon-xs rounded" title="Move down">
|
||||||
|
<i class="text-xs text-gray-600 dark:text-gray-400 fas fa-chevron-down"></i>
|
||||||
|
</button>
|
||||||
|
<button v-if="layers.length > 1" @click="removeLayer(layer.id)" class="btn btn-danger btn-icon-xs rounded" title="Delete">
|
||||||
|
<i class="text-xs fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Grid Settings -->
|
||||||
|
<section>
|
||||||
|
<h3 class="flex items-center gap-2 mb-3 text-base font-bold text-gray-800 dark:text-gray-100">
|
||||||
|
<i class="text-sm text-gray-700 dark:text-gray-300 fas fa-th"></i>
|
||||||
|
Grid
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between px-3 py-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||||
|
<label for="columns" class="text-sm font-medium text-gray-700 dark:text-gray-200">Columns</label>
|
||||||
|
<input id="columns" type="number" v-model.number="columns" min="1" max="10" class="input-field w-16" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-3 py-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||||
|
<label class="flex items-center justify-between mb-2 cursor-pointer">
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">Manual size</span>
|
||||||
|
<input type="checkbox" v-model="settingsStore.manualCellSizeEnabled" class="w-4 h-4 rounded" />
|
||||||
|
</label>
|
||||||
|
<div v-if="settingsStore.manualCellSizeEnabled" class="flex items-center gap-1.5 mt-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
v-model.number="settingsStore.manualCellWidth"
|
||||||
|
min="1"
|
||||||
|
max="2048"
|
||||||
|
class="input-field w-full min-w-0"
|
||||||
|
placeholder="W"
|
||||||
|
/>
|
||||||
|
<span class="flex-shrink-0 text-gray-500 dark:text-gray-400">×</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
v-model.number="settingsStore.manualCellHeight"
|
||||||
|
min="1"
|
||||||
|
max="2048"
|
||||||
|
class="input-field w-full min-w-0"
|
||||||
|
placeholder="H"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else class="mt-1 text-xs font-mono text-gray-500 dark:text-gray-400 break-words">{{ cellSize.width }} × {{ cellSize.height }}px</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Alignment -->
|
||||||
|
<section>
|
||||||
|
<h3 class="flex items-center gap-2 mb-3 text-base font-bold text-gray-800 dark:text-gray-100">
|
||||||
|
<i class="text-sm text-gray-700 dark:text-gray-300 fas fa-align-center"></i>
|
||||||
|
Align
|
||||||
|
</h3>
|
||||||
|
<div class="grid grid-cols-3 gap-2">
|
||||||
|
<button @click="alignSprites('left')" class="btn btn-secondary btn-sm" title="Left">
|
||||||
|
<i class="fas fa-arrow-left"></i>
|
||||||
|
</button>
|
||||||
|
<button @click="alignSprites('center')" class="btn btn-secondary btn-sm" title="Center">
|
||||||
|
<i class="fas fa-arrows-left-right"></i>
|
||||||
|
</button>
|
||||||
|
<button @click="alignSprites('right')" class="btn btn-secondary btn-sm" title="Right">
|
||||||
|
<i class="fas fa-arrow-right"></i>
|
||||||
|
</button>
|
||||||
|
<button @click="alignSprites('top')" class="btn btn-secondary btn-sm" title="Top">
|
||||||
|
<i class="fas fa-arrow-up"></i>
|
||||||
|
</button>
|
||||||
|
<button @click="alignSprites('middle')" class="btn btn-secondary btn-sm" title="Middle">
|
||||||
|
<i class="fas fa-arrows-up-down"></i>
|
||||||
|
</button>
|
||||||
|
<button @click="alignSprites('bottom')" class="btn btn-secondary btn-sm" title="Bottom">
|
||||||
|
<i class="fas fa-arrow-down"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Export -->
|
||||||
|
<section>
|
||||||
|
<h3 class="flex items-center gap-2 mb-3 text-base font-bold text-gray-800 dark:text-gray-100">
|
||||||
|
<i class="text-sm text-gray-700 dark:text-gray-300 fas fa-download"></i>
|
||||||
|
Export
|
||||||
|
</h3>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<button @click="downloadSpritesheet" class="btn btn-dark btn-sm" data-rybbit-event="download-spritesheet">
|
||||||
|
<i class="fas fa-image"></i>
|
||||||
|
<span>PNG</span>
|
||||||
|
</button>
|
||||||
|
<button @click="exportSpritesheetJSON" class="btn btn-dark btn-sm" data-rybbit-event="export-json">
|
||||||
|
<i class="fas fa-file-code"></i>
|
||||||
|
<span>JSON</span>
|
||||||
|
</button>
|
||||||
|
<button @click="openGifFpsModal" class="btn btn-dark btn-sm" data-rybbit-event="download-gif">
|
||||||
|
<i class="fas fa-film"></i>
|
||||||
|
<span>GIF</span>
|
||||||
|
</button>
|
||||||
|
<button @click="downloadAsZip" class="btn btn-dark btn-sm" data-rybbit-event="download-zip">
|
||||||
|
<i class="fas fa-file-archive"></i>
|
||||||
|
<span>ZIP</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<sprite-canvas :sprites="sprites" :columns="columns" @update-sprite="updateSpritePosition" @update-sprite-cell="updateSpriteCell" @remove-sprite="removeSprite" @replace-sprite="replaceSprite" @add-sprite="addSprite" @add-sprite-with-resize="addSpriteWithResize" />
|
<!-- Right panel - Tabs -->
|
||||||
</div>
|
<div class="flex flex-col overflow-hidden">
|
||||||
</div>
|
<!-- Tab Navigation -->
|
||||||
</div>
|
<div class="bg-gray-50/50 dark:bg-gray-900/30 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex gap-1 p-2">
|
||||||
|
<button
|
||||||
|
@click="activeTab = 'canvas'"
|
||||||
|
class="border-gray-600 border"
|
||||||
|
:class="[
|
||||||
|
'flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-all cursor-pointer',
|
||||||
|
activeTab === 'canvas' ? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm' : 'text-gray-600 dark:text-gray-400 hover:bg-white/50 dark:hover:bg-gray-800/50',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<i class="fas fa-th"></i>
|
||||||
|
<span>Canvas</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="activeTab = 'preview'"
|
||||||
|
class="border-gray-600 border"
|
||||||
|
:class="[
|
||||||
|
'flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-all cursor-pointer',
|
||||||
|
activeTab === 'preview' ? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm' : 'text-gray-600 dark:text-gray-400 hover:bg-white/50 dark:hover:bg-gray-800/50',
|
||||||
|
]"
|
||||||
|
data-rybbit-event="preview-animation"
|
||||||
|
>
|
||||||
|
<i class="fas fa-play"></i>
|
||||||
|
<span>Preview</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Modal :is-open="isPreviewModalOpen" @close="closePreviewModal" title="Animation preview">
|
<!-- Tab Content -->
|
||||||
<sprite-preview :sprites="sprites" :columns="columns" @update-sprite="updateSpritePosition" />
|
<div class="p-6 lg:flex-1 lg:overflow-auto">
|
||||||
</Modal>
|
<div v-if="activeTab === 'canvas'">
|
||||||
|
<sprite-canvas :layers="layers" :active-layer-id="activeLayerId" :columns="columns" @update-sprite="updateSpritePosition" @update-sprite-cell="updateSpriteCell" @remove-sprite="removeSprite" @replace-sprite="replaceSprite" @add-sprite="addSprite" />
|
||||||
|
</div>
|
||||||
|
<div v-if="activeTab === 'preview'">
|
||||||
|
<sprite-preview :layers="layers" :active-layer-id="activeLayerId" :columns="columns" @update-sprite="updateSpritePosition" @update-sprite-in-layer="updateSpriteInLayer" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
<HelpModal :is-open="isHelpModalOpen" @close="closeHelpModal" />
|
<HelpModal :is-open="isHelpModalOpen" @close="closeHelpModal" />
|
||||||
<FeedbackModal :is-open="isFeedbackModalOpen" @close="closeFeedbackModal" />
|
<FeedbackModal :is-open="isFeedbackModalOpen" @close="closeFeedbackModal" />
|
||||||
@@ -118,15 +337,15 @@
|
|||||||
<GifFpsModal :is-open="isGifFpsModalOpen" @close="closeGifFpsModal" @confirm="downloadAsGif" :default-fps="10" />
|
<GifFpsModal :is-open="isGifFpsModalOpen" @close="closeGifFpsModal" @confirm="downloadAsGif" :default-fps="10" />
|
||||||
|
|
||||||
<!-- One-time feedback popup -->
|
<!-- One-time feedback popup -->
|
||||||
<div v-if="showFeedbackPopup" class="fixed inset-0 backdrop-blur-sm flex items-center justify-center z-50">
|
<div v-if="showFeedbackPopup" class="fixed inset-0 z-50 flex items-center justify-center backdrop-blur-sm">
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-6 max-w-md mx-4 shadow-xl border border-gray-600">
|
<div class="max-w-md p-6 mx-4 bg-white dark:bg-gray-800 border border-gray-600 rounded-xl shadow-xl">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="text-4xl mb-4">💬</div>
|
<div class="mb-4 text-4xl">💬</div>
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-3">Help us improve!</h3>
|
<h3 class="mb-3 text-lg font-semibold text-gray-900 dark:text-white">Help us improve!</h3>
|
||||||
<p class="text-gray-600 dark:text-gray-300 mb-6">We'd love to hear your thoughts about the spritesheet generator. Would you like to share your feedback?</p>
|
<p class="mb-6 text-gray-600 dark:text-gray-300">We'd love to hear your thoughts about the Spritesheet generator. Would you like to share your feedback?</p>
|
||||||
<div class="flex gap-3 justify-center">
|
<div class="flex justify-center gap-3">
|
||||||
<button @click="handleFeedbackPopupResponse(false)" class="px-4 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors">Maybe later</button>
|
<button @click="handleFeedbackPopupResponse(false)" class="px-4 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors cursor-pointer">Maybe later</button>
|
||||||
<button @click="handleFeedbackPopupResponse(true)" class="px-6 py-2 bg-blue-500 hover:bg-blue-600 text-white font-medium rounded-lg transition-colors">Share feedback</button>
|
<button @click="handleFeedbackPopupResponse(true)" class="px-6 py-2 font-medium text-white bg-gray-700 hover:bg-gray-800 rounded-lg transition-colors cursor-pointer">Share feedback</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -135,80 +354,110 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, toRef } from 'vue';
|
import { ref, onMounted, toRef, computed } from 'vue';
|
||||||
import FileUploader from './components/FileUploader.vue';
|
import FileUploader from './components/FileUploader.vue';
|
||||||
import SpriteCanvas from './components/SpriteCanvas.vue';
|
import SpriteCanvas from './components/SpriteCanvas.vue';
|
||||||
import Modal from './components/utilities/Modal.vue';
|
|
||||||
import SpritePreview from './components/SpritePreview.vue';
|
import SpritePreview from './components/SpritePreview.vue';
|
||||||
import HelpModal from './components/HelpModal.vue';
|
import HelpModal from './components/HelpModal.vue';
|
||||||
import FeedbackModal from './components/FeedbackModal.vue';
|
import FeedbackModal from './components/FeedbackModal.vue';
|
||||||
import SpritesheetSplitter from './components/SpritesheetSplitter.vue';
|
import SpritesheetSplitter from './components/SpritesheetSplitter.vue';
|
||||||
import GifFpsModal from './components/GifFpsModal.vue';
|
import GifFpsModal from './components/GifFpsModal.vue';
|
||||||
import DarkModeToggle from './components/utilities/DarkModeToggle.vue';
|
import DarkModeToggle from './components/utilities/DarkModeToggle.vue';
|
||||||
import { useSprites } from './composables/useSprites';
|
import { useExportLayers } from './composables/useExportLayers';
|
||||||
import { useExport } from './composables/useExport';
|
import { useLayers } from './composables/useLayers';
|
||||||
|
import { getMaxDimensionsAcrossLayers } from './composables/useLayers';
|
||||||
import { useSettingsStore } from './stores/useSettingsStore';
|
import { useSettingsStore } from './stores/useSettingsStore';
|
||||||
|
import { calculateNegativeSpacing } from './composables/useNegativeSpacing';
|
||||||
import type { SpriteFile } from './types/sprites';
|
import type { SpriteFile } from './types/sprites';
|
||||||
|
|
||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
const { sprites, columns, updateSpritePosition, updateSpriteCell, removeSprite, replaceSprite, addSprite, addSpriteWithResize, processImageFiles, alignSprites } = useSprites();
|
const { layers, visibleLayers, activeLayer, activeLayerId, columns, updateSpritePosition, updateSpriteInLayer, updateSpriteCell, removeSprite, replaceSprite, addSprite, processImageFiles, alignSprites, addLayer, removeLayer, moveLayer } = useLayers();
|
||||||
|
|
||||||
const { downloadSpritesheet, exportSpritesheetJSON, importSpritesheetJSON, downloadAsGif, downloadAsZip } = useExport(sprites, columns, toRef(settingsStore, 'negativeSpacingEnabled'));
|
const { downloadSpritesheet, exportSpritesheetJSON, importSpritesheetJSON, downloadAsGif, downloadAsZip } = useExportLayers(
|
||||||
const isPreviewModalOpen = ref(false);
|
layers,
|
||||||
|
columns,
|
||||||
|
toRef(settingsStore, 'negativeSpacingEnabled'),
|
||||||
|
activeLayerId,
|
||||||
|
toRef(settingsStore, 'backgroundColor'),
|
||||||
|
toRef(settingsStore, 'manualCellSizeEnabled'),
|
||||||
|
toRef(settingsStore, 'manualCellWidth'),
|
||||||
|
toRef(settingsStore, 'manualCellHeight')
|
||||||
|
);
|
||||||
|
|
||||||
|
const getCellSize = () => {
|
||||||
|
if (!visibleLayers.value.length) return { width: 0, height: 0 };
|
||||||
|
|
||||||
|
if (settingsStore.manualCellSizeEnabled) {
|
||||||
|
return { width: settingsStore.manualCellWidth, height: settingsStore.manualCellHeight };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers.value);
|
||||||
|
const allSprites = visibleLayers.value.flatMap(l => l.sprites);
|
||||||
|
const negativeSpacing = calculateNegativeSpacing(allSprites, settingsStore.negativeSpacingEnabled);
|
||||||
|
return { width: maxWidth + negativeSpacing, height: maxHeight + negativeSpacing };
|
||||||
|
};
|
||||||
|
|
||||||
|
const cellSize = computed(getCellSize);
|
||||||
|
const activeTab = ref<'canvas' | 'preview'>('canvas');
|
||||||
const isHelpModalOpen = ref(false);
|
const isHelpModalOpen = ref(false);
|
||||||
const isFeedbackModalOpen = ref(false);
|
const isFeedbackModalOpen = ref(false);
|
||||||
const isSpritesheetSplitterOpen = ref(false);
|
const isSpritesheetSplitterOpen = ref(false);
|
||||||
const isGifFpsModalOpen = ref(false);
|
const isGifFpsModalOpen = ref(false);
|
||||||
|
const uploadInput = ref<HTMLInputElement | null>(null);
|
||||||
const jsonFileInput = ref<HTMLInputElement | null>(null);
|
const jsonFileInput = ref<HTMLInputElement | null>(null);
|
||||||
const spritesheetImageUrl = ref('');
|
const spritesheetImageUrl = ref('');
|
||||||
const spritesheetImageFile = ref<File | null>(null);
|
const spritesheetImageFile = ref<File | null>(null);
|
||||||
const showFeedbackPopup = ref(false);
|
const showFeedbackPopup = ref(false);
|
||||||
|
const editingLayerId = ref<string | null>(null);
|
||||||
|
const editingLayerName = ref('');
|
||||||
|
const layerNameInput = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const handleSpritesUpload = async (files: File[]) => {
|
const handleSpritesUpload = async (files: File[]) => {
|
||||||
const jsonFile = files.find(file => file.type === 'application/json' || file.name.endsWith('.json'));
|
const jsonFile = files.find(file => file.type === 'application/json' || file.name.endsWith('.json'));
|
||||||
|
|
||||||
if (jsonFile) {
|
if (jsonFile) {
|
||||||
try {
|
await handleJSONImport(jsonFile);
|
||||||
await importSpritesheetJSON(jsonFile);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error importing JSON:', error);
|
|
||||||
alert('Failed to import JSON file. Please check the file format.');
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (files.length === 1 && files[0].type.startsWith('image/')) {
|
if (files.length === 1 && files[0].type.startsWith('image/')) {
|
||||||
const file = files[0];
|
const file = files[0];
|
||||||
const url = URL.createObjectURL(file);
|
const reader = new FileReader();
|
||||||
|
reader.onload = e => {
|
||||||
|
const url = e.target?.result as string;
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
if (confirm('This looks like it might be a spritesheet. Would you like to split it into individual sprites?')) {
|
||||||
|
spritesheetImageUrl.value = url;
|
||||||
|
spritesheetImageFile.value = file;
|
||||||
|
isSpritesheetSplitterOpen.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const img = new Image();
|
processImageFiles([file]);
|
||||||
img.onload = () => {
|
};
|
||||||
if (confirm('This looks like it might be a spritesheet. Would you like to split it into individual sprites?')) {
|
img.onerror = () => {
|
||||||
spritesheetImageUrl.value = url;
|
console.error('Failed to load image:', file.name);
|
||||||
spritesheetImageFile.value = file;
|
};
|
||||||
isSpritesheetSplitterOpen.value = true;
|
img.src = url;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
processImageFiles([file]);
|
|
||||||
};
|
};
|
||||||
img.src = url;
|
reader.onerror = () => {
|
||||||
|
console.error('Failed to read image file:', file.name);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
processImageFiles(files);
|
processImageFiles(files);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openPreviewModal = () => {
|
const handleJSONImport = async (jsonFile: File) => {
|
||||||
if (sprites.value.length === 0) {
|
try {
|
||||||
alert('Please upload or import sprites to preview an animation.');
|
await importSpritesheetJSON(jsonFile);
|
||||||
return;
|
} catch (error) {
|
||||||
|
console.error('Error importing JSON:', error);
|
||||||
|
alert('Failed to import JSON file. Please check the file format.');
|
||||||
}
|
}
|
||||||
isPreviewModalOpen.value = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const closePreviewModal = () => {
|
|
||||||
isPreviewModalOpen.value = false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const openHelpModal = () => {
|
const openHelpModal = () => {
|
||||||
@@ -229,15 +478,17 @@
|
|||||||
|
|
||||||
const closeSpritesheetSplitter = () => {
|
const closeSpritesheetSplitter = () => {
|
||||||
isSpritesheetSplitterOpen.value = false;
|
isSpritesheetSplitterOpen.value = false;
|
||||||
if (spritesheetImageUrl.value) {
|
if (spritesheetImageUrl.value && spritesheetImageUrl.value.startsWith('blob:')) {
|
||||||
URL.revokeObjectURL(spritesheetImageUrl.value);
|
try {
|
||||||
spritesheetImageUrl.value = '';
|
URL.revokeObjectURL(spritesheetImageUrl.value);
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
|
spritesheetImageUrl.value = '';
|
||||||
spritesheetImageFile.value = null;
|
spritesheetImageFile.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const openGifFpsModal = () => {
|
const openGifFpsModal = () => {
|
||||||
if (sprites.value.length === 0) {
|
if (!visibleLayers.value.some(l => l.sprites.length > 0)) {
|
||||||
alert('Please upload or import sprites before generating a GIF.');
|
alert('Please upload or import sprites before generating a GIF.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -259,14 +510,8 @@
|
|||||||
const handleJSONFileChange = async (event: Event) => {
|
const handleJSONFileChange = async (event: Event) => {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
if (input.files && input.files.length > 0) {
|
if (input.files && input.files.length > 0) {
|
||||||
const jsonFile = input.files[0];
|
await handleJSONImport(input.files[0]);
|
||||||
try {
|
input.value = '';
|
||||||
await importSpritesheetJSON(jsonFile);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error importing JSON:', error);
|
|
||||||
alert('Failed to import JSON file. Please check the file format.');
|
|
||||||
}
|
|
||||||
if (jsonFileInput.value) jsonFileInput.value.value = '';
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -287,4 +532,42 @@
|
|||||||
openFeedbackModal();
|
openFeedbackModal();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openFileDialog = () => {
|
||||||
|
uploadInput.value?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUploadChange = async (event: Event) => {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
if (input.files && input.files.length > 0) {
|
||||||
|
await handleSpritesUpload(Array.from(input.files));
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEditingLayer = (layerId: string, currentName: string) => {
|
||||||
|
editingLayerId.value = layerId;
|
||||||
|
editingLayerName.value = currentName;
|
||||||
|
// Focus the input on next tick
|
||||||
|
setTimeout(() => {
|
||||||
|
layerNameInput.value?.focus();
|
||||||
|
layerNameInput.value?.select();
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const finishEditingLayer = () => {
|
||||||
|
if (editingLayerId.value && editingLayerName.value.trim()) {
|
||||||
|
const layer = layers.value.find(l => l.id === editingLayerId.value);
|
||||||
|
if (layer) {
|
||||||
|
layer.name = editingLayerName.value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
editingLayerId.value = null;
|
||||||
|
editingLayerName.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEditingLayer = () => {
|
||||||
|
editingLayerId.value = null;
|
||||||
|
editingLayerName.value = '';
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
BIN
src/assets/demo.mp4
Normal file
BIN
src/assets/demo.mp4
Normal file
Binary file not shown.
@@ -37,3 +37,57 @@ html.dark {
|
|||||||
touch-action: manipulation; /* Improve touch responsiveness */
|
touch-action: manipulation; /* Improve touch responsiveness */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.btn {
|
||||||
|
@apply inline-flex items-center justify-center gap-2 px-4 py-2 text-sm rounded-lg transition-all cursor-pointer font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed active:scale-95;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
@apply bg-gray-900 text-white hover:bg-gray-800 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-gray-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
@apply bg-white text-gray-700 border border-gray-200 hover:bg-gray-50 hover:shadow-sm dark:bg-gray-800 dark:text-gray-200 dark:border-gray-700 dark:hover:bg-gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
@apply text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
@apply text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/30;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-dark {
|
||||||
|
@apply bg-gray-700 text-white hover:bg-gray-800 dark:bg-gray-600 dark:hover:bg-gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
@apply px-3 py-1.5 text-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-md {
|
||||||
|
@apply px-4 py-2 text-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
@apply p-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon-sm {
|
||||||
|
@apply p-1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon-xs {
|
||||||
|
@apply p-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field {
|
||||||
|
@apply px-2 py-1 text-sm border border-gray-300 rounded outline-none focus:ring-2 focus:ring-gray-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
@apply bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="border-2 border-dashed rounded-xl p-4 sm:p-8 text-center transition-all duration-200"
|
class="relative border-3 border-dashed rounded-2xl p-8 sm:p-12 text-center transition-all duration-300 cursor-pointer group overflow-hidden"
|
||||||
:class="{
|
:class="{
|
||||||
'border-blue-300 bg-blue-50 dark:border-blue-500 dark:bg-blue-900/30': isDragging,
|
'border-blue-400 bg-blue-50 dark:border-blue-400 dark:bg-blue-900/40 scale-[1.02]': isDragging,
|
||||||
'border-gray-200 hover:border-blue-300 hover:bg-gray-50 dark:border-gray-600 dark:hover:border-blue-500 dark:hover:bg-gray-700/50': !isDragging,
|
'border-gray-300 bg-gray-50/50 hover:border-blue-400 hover:bg-blue-50/80 dark:border-gray-600 dark:bg-gray-800/30 dark:hover:border-blue-400 dark:hover:bg-blue-900/30': !isDragging,
|
||||||
}"
|
}"
|
||||||
@dragenter.prevent="isDragging = true"
|
@dragenter.prevent="isDragging = true"
|
||||||
@dragleave.prevent="isDragging = false"
|
@dragleave.prevent="isDragging = false"
|
||||||
@@ -12,19 +12,35 @@
|
|||||||
@click="openFileDialog"
|
@click="openFileDialog"
|
||||||
data-rybbit-event="file-upload-area"
|
data-rybbit-event="file-upload-area"
|
||||||
>
|
>
|
||||||
|
<div class="absolute inset-0 bg-blue-400/0 group-hover:bg-blue-400/5 transition-all duration-300"></div>
|
||||||
|
|
||||||
<input ref="fileInput" type="file" multiple accept="image/*,.json" class="hidden" @change="handleFileChange" />
|
<input ref="fileInput" type="file" multiple accept="image/*,.json" class="hidden" @change="handleFileChange" />
|
||||||
|
|
||||||
<div class="mb-4 sm:mb-6">
|
<div class="relative z-10">
|
||||||
<img src="@/assets/images/file.svg" alt="File upload" class="w-16 h-16 sm:w-20 sm:h-20 mx-auto mb-2 sm:mb-4 opacity-75 dark:invert" />
|
<div class="mb-6 transform transition-transform duration-300" :class="isDragging ? 'scale-110' : 'group-hover:scale-105'">
|
||||||
|
<div class="w-20 h-20 sm:w-24 sm:h-24 mx-auto mb-4 bg-blue-100 dark:bg-blue-900/50 rounded-2xl flex items-center justify-center shadow-lg">
|
||||||
|
<i class="fas fa-cloud-upload-alt text-4xl sm:text-5xl text-blue-600 dark:text-blue-400"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="text-xl sm:text-2xl font-bold text-gray-800 dark:text-gray-100 mb-3">
|
||||||
|
<span v-if="isDragging">Drop your files here</span>
|
||||||
|
<span v-else>Upload Your Sprites</span>
|
||||||
|
</h3>
|
||||||
|
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 mb-2">Drag and drop sprite images or JSON files</p>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-8">Supports PNG, JPG, GIF, and JSON</p>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-center gap-4 mb-6">
|
||||||
|
<div class="h-px flex-1 bg-gray-300 dark:bg-gray-600"></div>
|
||||||
|
<span class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">or</span>
|
||||||
|
<div class="h-px flex-1 bg-gray-300 dark:bg-gray-600"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="px-8 py-3.5 bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700 text-white font-semibold rounded-xl shadow-lg hover:shadow-xl transition-all transform hover:scale-105 flex items-center justify-center gap-3 mx-auto" data-rybbit-event="select-files">
|
||||||
|
<i class="fas fa-folder-open text-lg"></i>
|
||||||
|
<span>Browse Files</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-lg sm:text-xl font-medium text-gray-700 dark:text-gray-200 mb-2">Drag and drop your sprite images or JSON file here</p>
|
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4 sm:mb-6">or</p>
|
|
||||||
|
|
||||||
<button class="w-full sm:w-auto px-6 py-3 sm:py-2.5 bg-blue-500 hover:bg-blue-600 text-white font-medium rounded-lg transition-colors flex sm:inline-flex items-center justify-center space-x-2 cursor-pointer" data-rybbit-event="select-files">
|
|
||||||
<i class="fas fa-folder-open"></i>
|
|
||||||
<span>Select files</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,48 +1,118 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="p-2 bg-cyan-600 rounded w-full my-4">
|
<Teleport to="body">
|
||||||
<p>Developer's tip: Right click a sprite to open the context menu and add, replace or remove sprites.</p>
|
<div v-if="showContextMenu" class="fixed bg-white dark:bg-gray-800 border-2 border-gray-300 dark:border-gray-600 rounded-xl shadow-2xl z-50 py-2 min-w-[200px] overflow-hidden" :style="{ left: contextMenuX + 'px', top: contextMenuY + 'px' }">
|
||||||
</div>
|
<button @click="addSprite" class="w-full px-5 py-3 text-left hover:bg-blue-50 dark:hover:bg-blue-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-3 transition-colors font-medium">
|
||||||
<div class="space-y-4">
|
<i class="fas fa-plus text-blue-600 dark:text-blue-400"></i>
|
||||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3 sm:gap-0">
|
<span>Add Sprite</span>
|
||||||
<div class="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4 w-full sm:w-auto">
|
</button>
|
||||||
<div class="flex items-center">
|
<button v-if="contextMenuSpriteId" @click="replaceSprite" class="w-full px-5 py-3 text-left hover:bg-purple-50 dark:hover:bg-purple-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-3 transition-colors font-medium">
|
||||||
<input id="pixel-perfect" type="checkbox" v-model="settingsStore.pixelPerfect" class="mr-2 w-4 h-4" @change="drawCanvas" />
|
<i class="fas fa-exchange-alt text-purple-600 dark:text-purple-400"></i>
|
||||||
<label for="pixel-perfect" class="dark:text-gray-200 text-sm sm:text-base">Pixel perfect rendering</label>
|
<span>Replace Sprite</span>
|
||||||
</div>
|
</button>
|
||||||
<div class="flex items-center">
|
<div v-if="contextMenuSpriteId" class="h-px bg-gray-200 dark:bg-gray-600 my-1"></div>
|
||||||
<input id="allow-cell-swap" type="checkbox" v-model="allowCellSwap" class="mr-2 w-4 h-4" />
|
<button v-if="contextMenuSpriteId" @click="removeSprite" class="w-full px-5 py-3 text-left hover:bg-red-50 dark:hover:bg-red-900/30 text-red-600 dark:text-red-400 flex items-center gap-3 transition-colors font-medium">
|
||||||
<label for="allow-cell-swap" class="dark:text-gray-200 text-sm sm:text-base">Allow moving between cells</label>
|
<i class="fas fa-trash"></i>
|
||||||
</div>
|
<span>Remove Sprite</span>
|
||||||
<!-- Add new checkbox for showing all sprites -->
|
</button>
|
||||||
<div class="flex items-center">
|
</div>
|
||||||
<input id="show-all-sprites" type="checkbox" v-model="showAllSprites" class="mr-2" />
|
</Teleport>
|
||||||
<label for="show-all-sprites" class="dark:text-gray-200">Compare sprites</label>
|
|
||||||
</div>
|
<div class="space-y-6 w-full max-w-full overflow-hidden">
|
||||||
<!-- Negative spacing control -->
|
<div class="bg-cyan-50 dark:bg-cyan-900/20 rounded-lg p-3 border border-cyan-100 dark:border-cyan-800/50 flex items-start gap-3">
|
||||||
<div class="flex items-center">
|
<i class="fas fa-info-circle text-cyan-600 dark:text-cyan-400 mt-0.5 flex-shrink-0"></i>
|
||||||
<input id="negative-spacing" type="checkbox" v-model="settingsStore.negativeSpacingEnabled" class="mr-2 w-4 h-4" />
|
<p class="text-sm text-cyan-800 dark:text-cyan-200">
|
||||||
<label for="negative-spacing" class="dark:text-gray-200 text-sm sm:text-base">Negative spacing</label>
|
<span class="font-semibold">Tip:</span> Right-click any sprite to open the context menu for quick actions: add, replace, or remove sprites.
|
||||||
</div>
|
</p>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative border border-gray-300 dark:border-gray-600 rounded-lg overflow-auto">
|
<section class="w-full bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-1 shadow-sm">
|
||||||
<!-- Zoom controls - visible on all screen sizes and positioned outside cells -->
|
<div class="flex flex-wrap items-center gap-1">
|
||||||
<div class="relative flex space-x-2 bg-white/90 dark:bg-gray-800/90 p-2 rounded-lg shadow-md z-20">
|
<!-- Toggles Group -->
|
||||||
<button @click="zoomIn" class="p-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-100 rounded-lg transition-colors">
|
<div class="flex items-center gap-1 p-1">
|
||||||
<i class="fas fa-plus"></i>
|
<label class="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer transition-colors" title="Pixel Perfect">
|
||||||
</button>
|
<input id="pixel-perfect" type="checkbox" v-model="settingsStore.pixelPerfect" class="w-4 h-4 rounded text-blue-600 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600" />
|
||||||
<button @click="zoomOut" class="p-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-100 rounded-lg transition-colors">
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">Pixel Perfect</span>
|
||||||
<i class="fas fa-minus"></i>
|
</label>
|
||||||
</button>
|
<label class="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer transition-colors" title="Cell Swapping">
|
||||||
<button @click="resetZoom" class="p-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-100 rounded-lg transition-colors">
|
<input id="allow-cell-swap" type="checkbox" v-model="allowCellSwap" class="w-4 h-4 rounded text-blue-600 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600" />
|
||||||
<i class="fas fa-expand"></i>
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">Swap</span>
|
||||||
</button>
|
</label>
|
||||||
</div>
|
<label class="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer transition-colors" title="Compare Sprites">
|
||||||
|
<input id="show-all-sprites" type="checkbox" v-model="showAllSprites" class="w-4 h-4 rounded text-blue-600 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600" />
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">Compare</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="canvas-container touch-manipulation relative" :style="{ transform: `scale(${zoom})`, transformOrigin: 'top left' }">
|
<div class="w-px h-6 bg-gray-200 dark:bg-gray-700 mx-1"></div>
|
||||||
<canvas
|
|
||||||
ref="canvasRef"
|
<!-- Spacing & Grid Group -->
|
||||||
|
<div class="flex items-center gap-1 p-1">
|
||||||
|
<label class="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer transition-colors" title="Negative Spacing">
|
||||||
|
<input id="negative-spacing" type="checkbox" v-model="settingsStore.negativeSpacingEnabled" class="w-4 h-4 rounded text-blue-600 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600" />
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">Spacing</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer transition-colors" title="Checkerboard Background">
|
||||||
|
<input id="checkerboard" type="checkbox" v-model="settingsStore.checkerboardEnabled" class="w-4 h-4 rounded text-blue-600 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600" />
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">Grid</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-px h-6 bg-gray-200 dark:bg-gray-700 mx-1"></div>
|
||||||
|
|
||||||
|
<!-- Background Color -->
|
||||||
|
<div class="flex items-center gap-2 px-3 py-2">
|
||||||
|
<label for="bg-color" class="text-sm font-medium text-gray-600 dark:text-gray-400">Bg:</label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<select id="bg-color" v-model="bgSelectValue" class="px-2 py-1 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 dark:text-gray-200 text-sm focus:ring-2 focus:ring-blue-500 outline-none transition-all cursor-pointer hover:border-gray-400 dark:hover:border-gray-500">
|
||||||
|
<option value="transparent">Transparent</option>
|
||||||
|
<option value="#ffffff">White</option>
|
||||||
|
<option value="#000000">Black</option>
|
||||||
|
<option value="#f9fafb">Light Gray</option>
|
||||||
|
<option value="custom">Custom</option>
|
||||||
|
</select>
|
||||||
|
<div v-if="bgSelectValue === 'custom'" class="relative w-6 h-6 rounded-full overflow-hidden border border-gray-300 dark:border-gray-600 shadow-sm">
|
||||||
|
<input type="color" v-model="customColor" @input="settingsStore.setBackgroundColor(customColor)" class="absolute -top-2 -left-2 w-10 h-10 cursor-pointer p-0 border-0" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
|
||||||
|
<!-- Zoom Controls -->
|
||||||
|
<div class="flex items-center bg-gray-100 dark:bg-gray-700 rounded-lg p-1 mr-1">
|
||||||
|
<button @click="zoomOut" class="p-1.5 hover:bg-white dark:hover:bg-gray-600 text-gray-600 dark:text-gray-300 rounded-md transition-all" title="Zoom Out">
|
||||||
|
<i class="fas fa-minus text-xs"></i>
|
||||||
|
</button>
|
||||||
|
<span class="px-2 text-xs font-mono text-gray-600 dark:text-gray-300 min-w-[3ch] text-center">{{ Math.round(zoom * 100) }}%</span>
|
||||||
|
<button @click="zoomIn" class="p-1.5 hover:bg-white dark:hover:bg-gray-600 text-gray-600 dark:text-gray-300 rounded-md transition-all" title="Zoom In">
|
||||||
|
<i class="fas fa-plus text-xs"></i>
|
||||||
|
</button>
|
||||||
|
<div class="w-px h-4 bg-gray-300 dark:bg-gray-600 mx-1"></div>
|
||||||
|
<button @click="resetZoom" class="p-1.5 hover:bg-white dark:hover:bg-gray-600 text-gray-600 dark:text-gray-300 rounded-md transition-all" title="Reset Zoom">
|
||||||
|
<i class="fas fa-expand text-xs"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Offset Labels Toggle -->
|
||||||
|
<label class="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer transition-colors border-l border-gray-100 dark:border-gray-700" title="Show Offset Labels">
|
||||||
|
<input id="show-offset-labels" type="checkbox" v-model="showOffsetLabels" class="w-4 h-4 rounded text-blue-600 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600" />
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">Labels</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="relative bg-white dark:bg-gray-800 border-2 border-gray-200 dark:border-gray-700 rounded-2xl shadow-lg overflow-auto max-h-[calc(100vh-400px)] min-h-[500px] w-full">
|
||||||
|
<div class="canvas-container touch-manipulation relative inline-block min-w-full">
|
||||||
|
<div
|
||||||
|
ref="gridContainerRef"
|
||||||
|
:style="{
|
||||||
|
transform: `scale(${zoom})`,
|
||||||
|
transformOrigin: 'top left',
|
||||||
|
width: `${gridDimensions.width}px`,
|
||||||
|
height: `${gridDimensions.height}px`,
|
||||||
|
position: 'relative',
|
||||||
|
}"
|
||||||
|
class="inline-block"
|
||||||
@mousedown="startDrag"
|
@mousedown="startDrag"
|
||||||
@mousemove="drag"
|
@mousemove="drag"
|
||||||
@mouseup="stopDrag"
|
@mouseup="stopDrag"
|
||||||
@@ -55,20 +125,102 @@
|
|||||||
@dragenter="handleDragEnter"
|
@dragenter="handleDragEnter"
|
||||||
@dragleave="onDragLeave"
|
@dragleave="onDragLeave"
|
||||||
@drop="handleDrop"
|
@drop="handleDrop"
|
||||||
class="w-full transition-all"
|
|
||||||
:class="{ 'ring-4 ring-blue-500 ring-opacity-50': isDragOver }"
|
:class="{ 'ring-4 ring-blue-500 ring-opacity-50': isDragOver }"
|
||||||
:style="settingsStore.pixelPerfect ? { 'image-rendering': 'pixelated' } : {}"
|
>
|
||||||
></canvas>
|
<!-- Grid cells -->
|
||||||
|
|
||||||
<!-- Offset labels in corners -->
|
|
||||||
<div v-if="canvasRef" class="absolute inset-0 pointer-events-none">
|
|
||||||
<div
|
<div
|
||||||
v-for="position in spritePositions"
|
v-for="cellIndex in totalCells"
|
||||||
:key="position.id"
|
:key="`cell-${cellIndex - 1}`"
|
||||||
class="absolute text-[23px] leading-none font-mono text-cyan-600 dark:text-cyan-400 bg-white/90 dark:bg-gray-900/90 px-1 py-0.5 rounded-sm"
|
class="absolute"
|
||||||
:style="{
|
:style="{
|
||||||
left: `calc(${(position.cellX / canvasRef.width) * 100}% + ${(position.maxWidth / canvasRef.width) * 100}% - 2px)`,
|
left: `${getCellPosition(cellIndex - 1).x}px`,
|
||||||
top: `calc(${(position.cellY / canvasRef.height) * 100}% + ${(position.maxHeight / canvasRef.height) * 100}% - 2px)`,
|
top: `${getCellPosition(cellIndex - 1).y}px`,
|
||||||
|
width: `${gridMetrics.maxWidth}px`,
|
||||||
|
height: `${gridMetrics.maxHeight}px`,
|
||||||
|
backgroundColor: getCellBackground(),
|
||||||
|
backgroundImage: getCellBackgroundImage(),
|
||||||
|
backgroundSize: getCellBackgroundSize(),
|
||||||
|
backgroundPosition: getCellBackgroundPosition(),
|
||||||
|
border: `1px solid ${settingsStore.darkMode ? '#4b5563' : '#e5e7eb'}`,
|
||||||
|
}"
|
||||||
|
:class="{
|
||||||
|
'bg-blue-500 bg-opacity-20': highlightCell && highlightCell.col === (cellIndex - 1) % columns && highlightCell.row === Math.floor((cellIndex - 1) / columns),
|
||||||
|
}"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<!-- Background sprites (for compare mode) -->
|
||||||
|
<template v-if="showAllSprites">
|
||||||
|
<template v-for="layer in visibleLayers" :key="`bg-layer-${layer.id}`">
|
||||||
|
<template v-for="(sprite, spriteIndex) in layer.sprites" :key="`bg-${sprite.id}`">
|
||||||
|
<template v-for="cellIndex in totalCells" :key="`bg-${sprite.id}-${cellIndex}`">
|
||||||
|
<img
|
||||||
|
v-if="spriteIndex !== cellIndex - 1 && !(activeSpriteId === sprite.id && ghostSprite)"
|
||||||
|
:src="sprite.url"
|
||||||
|
class="absolute pointer-events-none"
|
||||||
|
:style="{
|
||||||
|
left: `${getCellPosition(cellIndex - 1).x + gridMetrics.negativeSpacing + sprite.x}px`,
|
||||||
|
top: `${getCellPosition(cellIndex - 1).y + gridMetrics.negativeSpacing + sprite.y}px`,
|
||||||
|
width: `${sprite.width}px`,
|
||||||
|
height: `${sprite.height}px`,
|
||||||
|
opacity: '0.25',
|
||||||
|
imageRendering: settingsStore.pixelPerfect ? 'pixelated' : 'auto',
|
||||||
|
}"
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Layer sprites -->
|
||||||
|
<template v-for="layer in layers" :key="layer.id">
|
||||||
|
<template v-if="layer.visible">
|
||||||
|
<template v-for="(sprite, index) in layer.sprites" :key="sprite.id">
|
||||||
|
<img
|
||||||
|
v-if="!(activeSpriteId === sprite.id && ghostSprite)"
|
||||||
|
:src="sprite.url"
|
||||||
|
class="absolute cursor-move"
|
||||||
|
:style="{
|
||||||
|
left: `${getCellPosition(index).x + gridMetrics.negativeSpacing + sprite.x}px`,
|
||||||
|
top: `${getCellPosition(index).y + gridMetrics.negativeSpacing + sprite.y}px`,
|
||||||
|
width: `${sprite.width}px`,
|
||||||
|
height: `${sprite.height}px`,
|
||||||
|
opacity: layer.id === activeLayerId ? '1' : '0.85',
|
||||||
|
imageRendering: settingsStore.pixelPerfect ? 'pixelated' : 'auto',
|
||||||
|
}"
|
||||||
|
:data-sprite-id="sprite.id"
|
||||||
|
:data-layer-id="layer.id"
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Ghost sprite (while dragging) -->
|
||||||
|
<img
|
||||||
|
v-if="ghostSprite && activeSpriteId"
|
||||||
|
:src="activeSpriteSprite?.url"
|
||||||
|
class="absolute pointer-events-none"
|
||||||
|
:style="{
|
||||||
|
left: `${ghostSprite.x}px`,
|
||||||
|
top: `${ghostSprite.y}px`,
|
||||||
|
width: `${activeSpriteSprite?.width}px`,
|
||||||
|
height: `${activeSpriteSprite?.height}px`,
|
||||||
|
opacity: '0.6',
|
||||||
|
imageRendering: settingsStore.pixelPerfect ? 'pixelated' : 'auto',
|
||||||
|
}"
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Offset labels -->
|
||||||
|
<div
|
||||||
|
v-if="showOffsetLabels"
|
||||||
|
v-for="position in spritePositions"
|
||||||
|
:key="`label-${position.id}`"
|
||||||
|
class="absolute text-[23px] leading-none font-mono text-cyan-600 dark:text-cyan-400 bg-white/90 dark:bg-gray-900/90 px-1 py-0.5 rounded-sm pointer-events-none"
|
||||||
|
:style="{
|
||||||
|
left: `${position.cellX + position.maxWidth - 2}px`,
|
||||||
|
top: `${position.cellY + position.maxHeight - 2}px`,
|
||||||
transform: 'translate(-100%, -100%)',
|
transform: 'translate(-100%, -100%)',
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
@@ -78,38 +230,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Context Menu -->
|
|
||||||
<div v-if="showContextMenu" class="fixed bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg z-50 py-1" :style="{ left: contextMenuX + 'px', top: contextMenuY + 'px' }">
|
|
||||||
<button @click="addSprite" class="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-200 flex items-center gap-2">
|
|
||||||
<i class="fas fa-plus"></i>
|
|
||||||
Add sprite
|
|
||||||
</button>
|
|
||||||
<button v-if="contextMenuSpriteId" @click="replaceSprite" class="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-200 flex items-center gap-2">
|
|
||||||
<i class="fas fa-exchange-alt"></i>
|
|
||||||
Replace sprite
|
|
||||||
</button>
|
|
||||||
<button v-if="contextMenuSpriteId" @click="removeSprite" class="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 text-red-600 dark:text-red-400 flex items-center gap-2">
|
|
||||||
<i class="fas fa-trash"></i>
|
|
||||||
Remove sprite
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Hidden file input for replace functionality -->
|
|
||||||
<input ref="fileInput" type="file" accept="image/*" class="hidden" @change="handleFileChange" />
|
<input ref="fileInput" type="file" accept="image/*" class="hidden" @change="handleFileChange" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, watch, onUnmounted, toRef } from 'vue';
|
import { ref, onMounted, watch, onUnmounted, toRef, computed, nextTick } from 'vue';
|
||||||
import { useSettingsStore } from '@/stores/useSettingsStore';
|
import { useSettingsStore } from '@/stores/useSettingsStore';
|
||||||
import type { Sprite } from '@/types/sprites';
|
import type { Sprite } from '@/types/sprites';
|
||||||
import { useCanvas2D } from '@/composables/useCanvas2D';
|
|
||||||
import { useZoom } from '@/composables/useZoom';
|
import { useZoom } from '@/composables/useZoom';
|
||||||
import { useDragSprite } from '@/composables/useDragSprite';
|
import { useDragSprite } from '@/composables/useDragSprite';
|
||||||
import { useFileDrop } from '@/composables/useFileDrop';
|
import { useFileDrop } from '@/composables/useFileDrop';
|
||||||
|
|
||||||
|
import type { Layer } from '@/types/sprites';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
sprites: Sprite[];
|
layers: Layer[];
|
||||||
|
activeLayerId: string;
|
||||||
columns: number;
|
columns: number;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
@@ -125,10 +262,7 @@
|
|||||||
// Get settings from store
|
// Get settings from store
|
||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
|
|
||||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
const gridContainerRef = ref<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
// Initialize composables
|
|
||||||
const canvas2D = useCanvas2D(canvasRef);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
zoom,
|
zoom,
|
||||||
@@ -144,6 +278,18 @@
|
|||||||
|
|
||||||
const allowCellSwap = ref(false);
|
const allowCellSwap = ref(false);
|
||||||
|
|
||||||
|
const getMousePosition = (event: MouseEvent, z?: number) => {
|
||||||
|
if (!gridContainerRef.value) return null;
|
||||||
|
const currentZoom = z ?? zoom.value;
|
||||||
|
const rect = gridContainerRef.value.getBoundingClientRect();
|
||||||
|
const scaleX = gridContainerRef.value.offsetWidth / (rect.width / currentZoom);
|
||||||
|
const scaleY = gridContainerRef.value.offsetHeight / (rect.height / currentZoom);
|
||||||
|
return {
|
||||||
|
x: ((event.clientX - rect.left) / currentZoom) * scaleX,
|
||||||
|
y: ((event.clientY - rect.top) / currentZoom) * scaleY,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isDragging,
|
isDragging,
|
||||||
activeSpriteId,
|
activeSpriteId,
|
||||||
@@ -158,33 +304,149 @@
|
|||||||
findSpriteAtPosition,
|
findSpriteAtPosition,
|
||||||
calculateMaxDimensions,
|
calculateMaxDimensions,
|
||||||
} = useDragSprite({
|
} = useDragSprite({
|
||||||
sprites: toRef(props, 'sprites'),
|
sprites: computed(() => props.layers.find(l => l.id === props.activeLayerId)?.sprites ?? []),
|
||||||
|
layers: toRef(props, 'layers'),
|
||||||
columns: toRef(props, 'columns'),
|
columns: toRef(props, 'columns'),
|
||||||
zoom,
|
zoom,
|
||||||
allowCellSwap,
|
allowCellSwap,
|
||||||
negativeSpacingEnabled: toRef(settingsStore, 'negativeSpacingEnabled'),
|
negativeSpacingEnabled: toRef(settingsStore, 'negativeSpacingEnabled'),
|
||||||
getMousePosition: (event, z) => canvas2D.getMousePosition(event, z),
|
manualCellSizeEnabled: toRef(settingsStore, 'manualCellSizeEnabled'),
|
||||||
|
manualCellWidth: toRef(settingsStore, 'manualCellWidth'),
|
||||||
|
manualCellHeight: toRef(settingsStore, 'manualCellHeight'),
|
||||||
|
getMousePosition,
|
||||||
onUpdateSprite: (id, x, y) => emit('updateSprite', id, x, y),
|
onUpdateSprite: (id, x, y) => emit('updateSprite', id, x, y),
|
||||||
onUpdateSpriteCell: (id, newIndex) => emit('updateSpriteCell', id, newIndex),
|
onUpdateSpriteCell: (id, newIndex) => emit('updateSpriteCell', id, newIndex),
|
||||||
onDraw: drawCanvas,
|
onDraw: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const activeSprites = computed(() => props.layers.find(l => l.id === props.activeLayerId)?.sprites ?? []);
|
||||||
|
const visibleLayers = computed(() => props.layers.filter(l => l.visible));
|
||||||
|
const activeSpriteSprite = computed(() => activeSprites.value.find(s => s.id === activeSpriteId.value));
|
||||||
|
|
||||||
const { isDragOver, handleDragOver, handleDragEnter, handleDragLeave, handleDrop } = useFileDrop({
|
const { isDragOver, handleDragOver, handleDragEnter, handleDragLeave, handleDrop } = useFileDrop({
|
||||||
sprites: props.sprites,
|
sprites: activeSprites,
|
||||||
onAddSprite: file => emit('addSprite', file),
|
onAddSprite: file => emit('addSprite', file),
|
||||||
onAddSpriteWithResize: file => emit('addSpriteWithResize', file),
|
onAddSpriteWithResize: file => emit('addSpriteWithResize', file),
|
||||||
});
|
});
|
||||||
|
|
||||||
const showAllSprites = ref(false);
|
const showAllSprites = ref(false);
|
||||||
|
const showOffsetLabels = ref(false);
|
||||||
const showContextMenu = ref(false);
|
const showContextMenu = ref(false);
|
||||||
const contextMenuX = ref(0);
|
const contextMenuX = ref(0);
|
||||||
const contextMenuY = ref(0);
|
const contextMenuY = ref(0);
|
||||||
const contextMenuSpriteId = ref<string | null>(null);
|
const contextMenuSpriteId = ref<string | null>(null);
|
||||||
const replacingSpriteId = ref<string | null>(null);
|
const replacingSpriteId = ref<string | null>(null);
|
||||||
const fileInput = ref<HTMLInputElement | null>(null);
|
const fileInput = ref<HTMLInputElement | null>(null);
|
||||||
|
const customColor = ref('#ffffff');
|
||||||
|
|
||||||
|
// Grid metrics
|
||||||
|
const gridMetrics = computed(() => calculateMaxDimensions());
|
||||||
|
|
||||||
|
const totalCells = computed(() => {
|
||||||
|
const maxLen = Math.max(1, ...props.layers.map(l => (l.visible ? l.sprites.length : 1)));
|
||||||
|
return Math.max(1, Math.ceil(maxLen / props.columns)) * props.columns;
|
||||||
|
});
|
||||||
|
|
||||||
|
const gridDimensions = computed(() => {
|
||||||
|
const maxLen = Math.max(1, ...props.layers.map(l => (l.visible ? l.sprites.length : 1)));
|
||||||
|
const rows = Math.max(1, Math.ceil(maxLen / props.columns));
|
||||||
|
return {
|
||||||
|
width: gridMetrics.value.maxWidth * props.columns,
|
||||||
|
height: gridMetrics.value.maxHeight * rows,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const getCellPosition = (index: number) => {
|
||||||
|
const col = index % props.columns;
|
||||||
|
const row = Math.floor(index / props.columns);
|
||||||
|
return {
|
||||||
|
x: col * gridMetrics.value.maxWidth,
|
||||||
|
y: row * gridMetrics.value.maxHeight,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCellBackground = () => {
|
||||||
|
const bg = settingsStore.backgroundColor;
|
||||||
|
if (bg === 'transparent') {
|
||||||
|
return 'transparent';
|
||||||
|
}
|
||||||
|
return bg;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCellBackgroundImage = () => {
|
||||||
|
const bg = settingsStore.backgroundColor;
|
||||||
|
if (bg === 'transparent' && settingsStore.checkerboardEnabled) {
|
||||||
|
// Checkerboard pattern for transparent backgrounds (dark mode friendly)
|
||||||
|
const color = settingsStore.darkMode ? '#4b5563' : '#d1d5db';
|
||||||
|
return `linear-gradient(45deg, ${color} 25%, transparent 25%), linear-gradient(-45deg, ${color} 25%, transparent 25%), linear-gradient(45deg, transparent 75%, ${color} 75%), linear-gradient(-45deg, transparent 75%, ${color} 75%)`;
|
||||||
|
}
|
||||||
|
return 'none';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCellBackgroundSize = () => {
|
||||||
|
const bg = settingsStore.backgroundColor;
|
||||||
|
if (bg === 'transparent') {
|
||||||
|
return '20px 20px';
|
||||||
|
}
|
||||||
|
return 'auto';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCellBackgroundPosition = () => {
|
||||||
|
const bg = settingsStore.backgroundColor;
|
||||||
|
if (bg === 'transparent') {
|
||||||
|
return '0 0, 0 10px, 10px -10px, -10px 0px';
|
||||||
|
}
|
||||||
|
return '0 0';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Background select handling
|
||||||
|
const presetBgColors = ['transparent', '#ffffff', '#000000', '#f9fafb'] as const;
|
||||||
|
const isHexColor = (val: string) => /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(val);
|
||||||
|
|
||||||
|
if (isHexColor(settingsStore.backgroundColor) && !presetBgColors.includes(settingsStore.backgroundColor as any)) {
|
||||||
|
customColor.value = settingsStore.backgroundColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCustomMode = ref(isHexColor(settingsStore.backgroundColor) && !presetBgColors.includes(settingsStore.backgroundColor as any));
|
||||||
|
|
||||||
|
const bgSelectValue = computed<string>({
|
||||||
|
get() {
|
||||||
|
if (isCustomMode.value) {
|
||||||
|
const val = settingsStore.backgroundColor;
|
||||||
|
if (isHexColor(val)) {
|
||||||
|
customColor.value = val;
|
||||||
|
}
|
||||||
|
return 'custom';
|
||||||
|
}
|
||||||
|
|
||||||
|
const val = settingsStore.backgroundColor;
|
||||||
|
if (presetBgColors.includes(val as any)) return val;
|
||||||
|
if (isHexColor(val)) {
|
||||||
|
customColor.value = val;
|
||||||
|
isCustomMode.value = true;
|
||||||
|
return 'custom';
|
||||||
|
}
|
||||||
|
return 'transparent';
|
||||||
|
},
|
||||||
|
set(v: string) {
|
||||||
|
if (v === 'custom') {
|
||||||
|
isCustomMode.value = true;
|
||||||
|
const fallback = '#ffffff';
|
||||||
|
const current = settingsStore.backgroundColor;
|
||||||
|
const fromStore = isHexColor(current) ? current : null;
|
||||||
|
const fromLocal = isHexColor(customColor.value) ? customColor.value : null;
|
||||||
|
const color = fromStore || fromLocal || fallback;
|
||||||
|
customColor.value = color;
|
||||||
|
settingsStore.setBackgroundColor(color);
|
||||||
|
} else {
|
||||||
|
isCustomMode.value = false;
|
||||||
|
settingsStore.setBackgroundColor(v);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const startDrag = (event: MouseEvent) => {
|
const startDrag = (event: MouseEvent) => {
|
||||||
if (!canvasRef.value) return;
|
if (!gridContainerRef.value) return;
|
||||||
|
|
||||||
// Hide context menu if open
|
// Hide context menu if open
|
||||||
showContextMenu.value = false;
|
showContextMenu.value = false;
|
||||||
@@ -192,25 +454,26 @@
|
|||||||
// Handle right-click for context menu
|
// Handle right-click for context menu
|
||||||
if ('button' in event && (event as MouseEvent).button === 2) {
|
if ('button' in event && (event as MouseEvent).button === 2) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const pos = canvas2D.getMousePosition(event, zoom.value);
|
const pos = getMousePosition(event, zoom.value);
|
||||||
if (!pos) return;
|
if (!pos) return;
|
||||||
|
|
||||||
const clickedSprite = findSpriteAtPosition(pos.x, pos.y);
|
const clickedSprite = findSpriteAtPosition(pos.x, pos.y);
|
||||||
contextMenuSpriteId.value = clickedSprite?.id || null;
|
contextMenuSpriteId.value = clickedSprite?.id || null;
|
||||||
|
|
||||||
contextMenuX.value = event.clientX;
|
contextMenuX.value = event.clientX;
|
||||||
contextMenuY.value = event.clientY;
|
contextMenuY.value = event.clientY;
|
||||||
|
|
||||||
showContextMenu.value = true;
|
showContextMenu.value = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ignore non-left mouse buttons (but allow touch-generated events without a button prop)
|
// Ignore non-left mouse buttons
|
||||||
if ('button' in event && (event as MouseEvent).button !== 0) return;
|
if ('button' in event && (event as MouseEvent).button !== 0) return;
|
||||||
|
|
||||||
// Delegate to composable for actual drag handling
|
// Delegate to composable for actual drag handling
|
||||||
dragStart(event);
|
dragStart(event);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Wrapper for drag move
|
|
||||||
const drag = (event: MouseEvent) => {
|
const drag = (event: MouseEvent) => {
|
||||||
dragMove(event);
|
dragMove(event);
|
||||||
};
|
};
|
||||||
@@ -225,10 +488,8 @@
|
|||||||
|
|
||||||
const replaceSprite = () => {
|
const replaceSprite = () => {
|
||||||
if (contextMenuSpriteId.value && fileInput.value) {
|
if (contextMenuSpriteId.value && fileInput.value) {
|
||||||
// Store the sprite ID separately so it persists after context menu closes
|
|
||||||
replacingSpriteId.value = contextMenuSpriteId.value;
|
replacingSpriteId.value = contextMenuSpriteId.value;
|
||||||
fileInput.value.click();
|
fileInput.value.click();
|
||||||
// Hide context menu immediately since we've stored the ID
|
|
||||||
showContextMenu.value = false;
|
showContextMenu.value = false;
|
||||||
contextMenuSpriteId.value = null;
|
contextMenuSpriteId.value = null;
|
||||||
}
|
}
|
||||||
@@ -237,7 +498,6 @@
|
|||||||
const addSprite = () => {
|
const addSprite = () => {
|
||||||
if (fileInput.value) {
|
if (fileInput.value) {
|
||||||
fileInput.value.click();
|
fileInput.value.click();
|
||||||
// Hide context menu immediately
|
|
||||||
showContextMenu.value = false;
|
showContextMenu.value = false;
|
||||||
contextMenuSpriteId.value = null;
|
contextMenuSpriteId.value = null;
|
||||||
}
|
}
|
||||||
@@ -252,14 +512,12 @@
|
|||||||
if (replacingSpriteId.value) {
|
if (replacingSpriteId.value) {
|
||||||
emit('replaceSprite', replacingSpriteId.value, file);
|
emit('replaceSprite', replacingSpriteId.value, file);
|
||||||
} else {
|
} else {
|
||||||
// Adding new sprite
|
|
||||||
emit('addSprite', file);
|
emit('addSprite', file);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
alert('Please select an image file.');
|
alert('Please select an image file.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Clean up after file selection
|
|
||||||
replacingSpriteId.value = null;
|
replacingSpriteId.value = null;
|
||||||
input.value = '';
|
input.value = '';
|
||||||
};
|
};
|
||||||
@@ -269,141 +527,26 @@
|
|||||||
contextMenuSpriteId.value = null;
|
contextMenuSpriteId.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Wrapper for drag leave to pass canvasRef
|
|
||||||
const onDragLeave = (event: DragEvent) => {
|
const onDragLeave = (event: DragEvent) => {
|
||||||
handleDragLeave(event, canvasRef.value);
|
handleDragLeave(event, gridContainerRef.value);
|
||||||
};
|
|
||||||
|
|
||||||
function drawCanvas() {
|
|
||||||
if (!canvasRef.value || !canvas2D.ctx.value) return;
|
|
||||||
|
|
||||||
const { maxWidth, maxHeight, negativeSpacing } = calculateMaxDimensions();
|
|
||||||
|
|
||||||
// Set canvas size
|
|
||||||
const rows = Math.max(1, Math.ceil(props.sprites.length / props.columns));
|
|
||||||
canvas2D.setCanvasSize(maxWidth * props.columns, maxHeight * rows);
|
|
||||||
|
|
||||||
// Clear canvas
|
|
||||||
canvas2D.clear();
|
|
||||||
|
|
||||||
// Apply pixel art optimization
|
|
||||||
canvas2D.applySmoothing();
|
|
||||||
|
|
||||||
// Draw background for each cell
|
|
||||||
for (let col = 0; col < props.columns; col++) {
|
|
||||||
for (let row = 0; row < rows; row++) {
|
|
||||||
const cellX = Math.floor(col * maxWidth);
|
|
||||||
const cellY = Math.floor(row * maxHeight);
|
|
||||||
|
|
||||||
// Draw cell background
|
|
||||||
canvas2D.fillCellBackground(cellX, cellY, maxWidth, maxHeight);
|
|
||||||
|
|
||||||
// Highlight the target cell if specified
|
|
||||||
if (highlightCell.value && highlightCell.value.col === col && highlightCell.value.row === row) {
|
|
||||||
canvas2D.fillRect(cellX, cellY, maxWidth, maxHeight, 'rgba(59, 130, 246, 0.2)');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If showing all sprites, draw all sprites with transparency in each cell
|
|
||||||
if (showAllSprites.value) {
|
|
||||||
for (let cellIndex = 0; cellIndex < props.sprites.length; cellIndex++) {
|
|
||||||
const cellCol = cellIndex % props.columns;
|
|
||||||
const cellRow = Math.floor(cellIndex / props.columns);
|
|
||||||
const cellX = Math.floor(cellCol * maxWidth);
|
|
||||||
const cellY = Math.floor(cellRow * maxHeight);
|
|
||||||
|
|
||||||
// Draw all sprites with transparency in this cell
|
|
||||||
// Position at bottom-right with negative spacing offset
|
|
||||||
props.sprites.forEach((sprite, spriteIndex) => {
|
|
||||||
if (spriteIndex !== cellIndex) {
|
|
||||||
canvas2D.drawImage(sprite.img, cellX + negativeSpacing + sprite.x, cellY + negativeSpacing + sprite.y, 0.3);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw sprites normally
|
|
||||||
props.sprites.forEach((sprite, index) => {
|
|
||||||
// Skip the active sprite if we're showing a ghost instead
|
|
||||||
if (activeSpriteId.value === sprite.id && ghostSprite.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const col = index % props.columns;
|
|
||||||
const row = Math.floor(index / props.columns);
|
|
||||||
|
|
||||||
const cellX = Math.floor(col * maxWidth);
|
|
||||||
const cellY = Math.floor(row * maxHeight);
|
|
||||||
|
|
||||||
// Draw sprite with negative spacing offset (bottom-right positioning)
|
|
||||||
canvas2D.drawImage(sprite.img, cellX + negativeSpacing + sprite.x, cellY + negativeSpacing + sprite.y);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Draw ghost sprite if we're dragging between cells
|
|
||||||
if (ghostSprite.value && activeSpriteId.value) {
|
|
||||||
const sprite = props.sprites.find(s => s.id === activeSpriteId.value);
|
|
||||||
if (sprite) {
|
|
||||||
canvas2D.drawImage(sprite.img, ghostSprite.value.x, ghostSprite.value.y, 0.6);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw grid lines on top of everything
|
|
||||||
for (let col = 0; col < props.columns; col++) {
|
|
||||||
for (let row = 0; row < rows; row++) {
|
|
||||||
const cellX = Math.floor(col * maxWidth);
|
|
||||||
const cellY = Math.floor(row * maxHeight);
|
|
||||||
canvas2D.strokeGridCell(cellX, cellY, maxWidth, maxHeight);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track which images already have listeners
|
|
||||||
const imagesWithListeners = new WeakSet<HTMLImageElement>();
|
|
||||||
|
|
||||||
const attachImageListeners = () => {
|
|
||||||
canvas2D.attachImageListeners(props.sprites, handleForceRedraw, imagesWithListeners);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
canvas2D.initContext();
|
|
||||||
drawCanvas();
|
|
||||||
|
|
||||||
// Attach listeners for current sprites
|
|
||||||
attachImageListeners();
|
|
||||||
|
|
||||||
// Listen for forceRedraw event from App.vue
|
|
||||||
window.addEventListener('forceRedraw', handleForceRedraw);
|
|
||||||
|
|
||||||
// Hide context menu when clicking elsewhere
|
// Hide context menu when clicking elsewhere
|
||||||
document.addEventListener('click', hideContextMenu);
|
document.addEventListener('click', hideContextMenu);
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('forceRedraw', handleForceRedraw);
|
|
||||||
document.removeEventListener('click', hideContextMenu);
|
document.removeEventListener('click', hideContextMenu);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handler for force redraw event
|
// Watch for background color changes
|
||||||
const handleForceRedraw = () => {
|
|
||||||
canvas2D.ensureIntegerPositions(props.sprites);
|
|
||||||
canvas2D.applySmoothing();
|
|
||||||
drawCanvas();
|
|
||||||
};
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.sprites,
|
() => settingsStore.backgroundColor,
|
||||||
() => {
|
async () => {
|
||||||
attachImageListeners();
|
await nextTick();
|
||||||
drawCanvas();
|
}
|
||||||
},
|
|
||||||
{ deep: true }
|
|
||||||
);
|
);
|
||||||
watch(() => props.columns, drawCanvas);
|
|
||||||
watch(() => settingsStore.pixelPerfect, drawCanvas);
|
|
||||||
watch(() => settingsStore.darkMode, drawCanvas);
|
|
||||||
watch(() => settingsStore.negativeSpacingEnabled, drawCanvas);
|
|
||||||
watch(showAllSprites, drawCanvas);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|||||||
@@ -1,24 +1,74 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="spritesheet-preview w-full">
|
<div class="spritesheet-preview w-full h-full">
|
||||||
<!-- Main Layout: Canvas Left, Controls Right -->
|
<div class="flex flex-col lg:flex-row gap-4 h-full">
|
||||||
<div class="flex flex-col lg:flex-row gap-4">
|
<div class="flex-1 min-w-0 flex flex-col">
|
||||||
<!-- Canvas Area (Left/Main) -->
|
<div
|
||||||
<div class="flex-1 min-w-0">
|
class="relative bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg overflow-auto flex-1 min-h-[300px] max-h-[calc(100vh-12rem)] shadow-sm hover:shadow-md transition-shadow duration-200"
|
||||||
<div class="relative bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg overflow-auto min-h-[300px] sm:min-h-[520px] shadow-sm hover:shadow-md transition-shadow duration-200">
|
@mousemove="drag"
|
||||||
<canvas
|
@mouseup="stopDrag"
|
||||||
ref="previewCanvasRef"
|
@mouseleave="stopDrag"
|
||||||
@mousedown="startDrag"
|
@touchmove="handleTouchMove"
|
||||||
@mousemove="drag"
|
@touchend="stopDrag"
|
||||||
@mouseup="stopDrag"
|
>
|
||||||
@mouseleave="stopDrag"
|
<div
|
||||||
@touchstart="handleTouchStart"
|
ref="previewContainerRef"
|
||||||
@touchmove="handleTouchMove"
|
class="relative touch-manipulation inline-block"
|
||||||
@touchend="stopDrag"
|
:style="{
|
||||||
class="block touch-manipulation"
|
transform: `scale(${zoom})`,
|
||||||
:class="{ 'cursor-move': isDraggable }"
|
transformOrigin: 'top left',
|
||||||
:style="{ transform: `scale(${zoom})`, transformOrigin: 'top left', ...(settingsStore.pixelPerfect ? { 'image-rendering': 'pixelated' } : {}) }"
|
width: `${cellDimensions.cellWidth}px`,
|
||||||
|
height: `${cellDimensions.cellHeight}px`,
|
||||||
|
backgroundColor: '#f9fafb',
|
||||||
|
backgroundImage: getPreviewBackgroundImage(),
|
||||||
|
backgroundSize: settingsStore.backgroundColor === 'transparent' ? '20px 20px' : 'auto',
|
||||||
|
backgroundPosition: settingsStore.backgroundColor === 'transparent' ? '0 0, 0 10px, 10px -10px, -10px 0px' : '0 0',
|
||||||
|
border: `1px solid ${settingsStore.darkMode ? '#4b5563' : '#e5e7eb'}`,
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
</canvas>
|
<!-- Background sprites (dimmed for comparison) -->
|
||||||
|
<template v-if="showAllSprites">
|
||||||
|
<template v-for="i in maxFrames()" :key="`bg-${i - 1}`">
|
||||||
|
<template v-if="i - 1 !== currentFrameIndex && !hiddenFrames.includes(i - 1)">
|
||||||
|
<template v-for="layer in getVisibleLayers()" :key="`${layer.id}-${i - 1}`">
|
||||||
|
<img
|
||||||
|
v-if="layer.sprites[i - 1]"
|
||||||
|
:src="layer.sprites[i - 1].url"
|
||||||
|
class="absolute pointer-events-none"
|
||||||
|
:style="{
|
||||||
|
left: `${cellDimensions.negativeSpacing + layer.sprites[i - 1].x}px`,
|
||||||
|
top: `${cellDimensions.negativeSpacing + layer.sprites[i - 1].y}px`,
|
||||||
|
width: `${layer.sprites[i - 1].width}px`,
|
||||||
|
height: `${layer.sprites[i - 1].height}px`,
|
||||||
|
opacity: '0.3',
|
||||||
|
imageRendering: settingsStore.pixelPerfect ? 'pixelated' : 'auto',
|
||||||
|
}"
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Current frame sprites -->
|
||||||
|
<template v-for="layer in getVisibleLayers()" :key="layer.id">
|
||||||
|
<img
|
||||||
|
v-if="layer.sprites[currentFrameIndex]"
|
||||||
|
:src="layer.sprites[currentFrameIndex].url"
|
||||||
|
class="absolute"
|
||||||
|
:class="{ 'cursor-move': isDraggable }"
|
||||||
|
:style="{
|
||||||
|
left: `${cellDimensions.negativeSpacing + layer.sprites[currentFrameIndex].x}px`,
|
||||||
|
top: `${cellDimensions.negativeSpacing + layer.sprites[currentFrameIndex].y}px`,
|
||||||
|
width: `${layer.sprites[currentFrameIndex].width}px`,
|
||||||
|
height: `${layer.sprites[currentFrameIndex].height}px`,
|
||||||
|
imageRendering: settingsStore.pixelPerfect ? 'pixelated' : 'auto',
|
||||||
|
}"
|
||||||
|
@mousedown="startDrag($event, layer.sprites[currentFrameIndex], layer.id)"
|
||||||
|
@touchstart="handleTouchStart($event, layer.sprites[currentFrameIndex], layer.id)"
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Mobile zoom controls -->
|
<!-- Mobile zoom controls -->
|
||||||
<div class="absolute bottom-3 right-3 flex space-x-2 lg:hidden bg-white/80 dark:bg-gray-800/80 p-2 rounded-lg shadow-md">
|
<div class="absolute bottom-3 right-3 flex space-x-2 lg:hidden bg-white/80 dark:bg-gray-800/80 p-2 rounded-lg shadow-md">
|
||||||
@@ -32,119 +82,128 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Controls Sidebar (Right) -->
|
|
||||||
<div class="lg:w-80 xl:w-96 flex-shrink-0">
|
<div class="lg:w-80 xl:w-96 flex-shrink-0">
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 space-y-4">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
|
||||||
<!-- Playback Controls -->
|
<!-- Playback Controls -->
|
||||||
<div class="space-y-3">
|
<div class="p-4 border-b border-gray-100 dark:border-gray-700">
|
||||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">Playback</h3>
|
<h3 class="text-xs font-bold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-3">Playback</h3>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button @click="togglePlayback" class="flex items-center justify-center gap-1.5 bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md transition-colors cursor-pointer flex-1">
|
<button @click="togglePlayback" class="flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white px-4 py-2.5 rounded-lg transition-all cursor-pointer flex-1 shadow-sm active:scale-95">
|
||||||
<span v-if="isPlaying" class="flex items-center gap-1.5">
|
<span v-if="isPlaying" class="flex items-center gap-2 font-medium">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
|
<i class="fas fa-pause"></i>
|
||||||
<path fill-rule="evenodd" d="M6.75 5.25a.75.75 0 01.75-.75H9a.75.75 0 01.75.75v13.5a.75.75 0 01-.75.75H7.5a.75.75 0 01-.75-.75V5.25zm7.5 0A.75.75 0 0115 4.5h1.5a.75.75 0 01.75.75v13.5a.75.75 0 01-.75.75H15a.75.75 0 01-.75-.75V5.25z" clip-rule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
Pause
|
Pause
|
||||||
</span>
|
</span>
|
||||||
<span v-else class="flex items-center gap-1.5">
|
<span v-else class="flex items-center gap-2 font-medium">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
|
<i class="fas fa-play"></i>
|
||||||
<path fill-rule="evenodd" d="M4.5 5.653c0-1.426 1.529-2.33 2.779-1.643l11.54 6.348c1.295.712 1.295 2.573 0 3.285L7.28 19.991c-1.25.687-2.779-.217-2.779-1.643V5.653z" clip-rule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
Play
|
Play
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button @click="previousFrame" class="bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 p-2 rounded-md transition-colors duration-200 cursor-pointer" :disabled="isPlaying" :class="{ 'opacity-50 cursor-not-allowed': isPlaying }">
|
<button @click="previousFrame" class="btn btn-secondary btn-icon rounded-lg" :disabled="isPlaying" :class="{ 'opacity-50 cursor-not-allowed': isPlaying }">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5 dark:text-gray-200">
|
<i class="fas fa-step-backward"></i>
|
||||||
<path fill-rule="evenodd" d="M11.78 5.22a.75.75 0 01.75.75v12.06l4.72-4.72a.75.75 0 111.06 1.06l-6 6a.75.75 0 01-1.06 0l-6-6a.75.75 0 011.06-1.06l4.72 4.72V5.97a.75.75 0 01.75-.75z" clip-rule="evenodd" transform="rotate(90 12 12)" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
<button @click="nextFrame" class="bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 p-2 rounded-md transition-colors duration-200 cursor-pointer" :disabled="isPlaying" :class="{ 'opacity-50 cursor-not-allowed': isPlaying }">
|
<button @click="nextFrame" class="btn btn-secondary btn-icon rounded-lg" :disabled="isPlaying" :class="{ 'opacity-50 cursor-not-allowed': isPlaying }">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5 dark:text-gray-200">
|
<i class="fas fa-step-forward"></i>
|
||||||
<path fill-rule="evenodd" d="M11.78 5.22a.75.75 0 01.75.75v12.06l4.72-4.72a.75.75 0 111.06 1.06l-6 6a.75.75 0 01-1.06 0l-6-6a.75.75 0 011.06-1.06l4.72 4.72V5.97a.75.75 0 01.75-.75z" clip-rule="evenodd" transform="rotate(-90 12 12)" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sliders -->
|
<!-- Animation Settings -->
|
||||||
<div class="space-y-3">
|
<div class="p-4 border-b border-gray-100 dark:border-gray-700 space-y-5">
|
||||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">Controls</h3>
|
<h3 class="text-xs font-bold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-1">Animation</h3>
|
||||||
|
|
||||||
<!-- Frame Navigation -->
|
<!-- Frame Navigation -->
|
||||||
<div class="space-y-1">
|
<div class="space-y-2">
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">Frame</span>
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Frame</span>
|
||||||
<span class="text-xs font-mono text-gray-700 dark:text-gray-300">{{ visibleFrameNumber }}/{{ visibleFramesCount }}</span>
|
<span class="text-xs font-mono text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">{{ visibleFrameNumber }} / {{ visibleFramesCount }}</span>
|
||||||
</div>
|
</div>
|
||||||
<input type="range" min="0" :max="visibleFrames.length - 1" :value="visibleFrameIndex" @input="handleSliderInput" class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-500" :disabled="isPlaying" :class="{ 'opacity-50': isPlaying }" />
|
<input type="range" min="0" :max="visibleFrames.length - 1" :value="visibleFrameIndex" @input="handleSliderInput" class="w-full h-1.5 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-600" :disabled="isPlaying" :class="{ 'opacity-50': isPlaying }" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- FPS Control -->
|
<!-- FPS Control -->
|
||||||
<div class="space-y-1">
|
<div class="space-y-2">
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">FPS</span>
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Speed (FPS)</span>
|
||||||
<span class="text-xs font-mono text-gray-700 dark:text-gray-300">{{ fps }}</span>
|
<span class="text-xs font-mono text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">{{ fps }}</span>
|
||||||
</div>
|
</div>
|
||||||
<input type="range" min="1" max="60" v-model.number="fps" class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-500" />
|
<input type="range" min="1" max="60" v-model.number="fps" class="w-full h-1.5 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-600" />
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Zoom Control -->
|
|
||||||
<div class="space-y-1">
|
|
||||||
<div class="flex justify-between items-center">
|
|
||||||
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">Zoom</span>
|
|
||||||
<span class="text-xs font-mono text-gray-700 dark:text-gray-300">{{ Math.round(zoom * 100) }}%</span>
|
|
||||||
</div>
|
|
||||||
<input type="range" min="0.5" max="5" step="0.1" v-model.number="zoom" class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-500" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Options -->
|
<!-- View Options -->
|
||||||
<div class="space-y-3">
|
<div class="p-4 space-y-5">
|
||||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">Options</h3>
|
<h3 class="text-xs font-bold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-1">View Options</h3>
|
||||||
|
|
||||||
|
<!-- Zoom Control -->
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
<div class="flex justify-between items-center">
|
||||||
<input type="checkbox" v-model="isDraggable" class="w-4 h-4 text-blue-500 focus:ring-blue-400 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700" />
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Zoom</span>
|
||||||
<span class="text-sm dark:text-gray-200">Reposition</span>
|
<span class="text-xs font-mono text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">{{ Math.round(zoom * 100) }}%</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" min="0.5" max="5" step="0.1" v-model.number="zoom" class="w-full h-1.5 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-600" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toggles -->
|
||||||
|
<div class="space-y-3 pt-2">
|
||||||
|
<label class="flex items-center justify-between cursor-pointer group">
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100 transition-colors">Pixel perfect</span>
|
||||||
|
<div class="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input type="checkbox" v-model="settingsStore.pixelPerfect" class="sr-only peer">
|
||||||
|
<div class="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||||
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
<label class="flex items-center justify-between cursor-pointer group">
|
||||||
<input type="checkbox" v-model="showAllSprites" class="w-4 h-4 text-blue-500 focus:ring-blue-400 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700" />
|
<span class="text-sm text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100 transition-colors">Reposition mode</span>
|
||||||
<span class="text-sm dark:text-gray-200">Compare sprites</span>
|
<div class="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input type="checkbox" v-model="isDraggable" class="sr-only peer">
|
||||||
|
<div class="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||||
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
<div class="pl-4 border-l-2 border-gray-100 dark:border-gray-700 transition-all" :class="{ 'opacity-50 pointer-events-none': !isDraggable }">
|
||||||
<input type="checkbox" v-model="settingsStore.pixelPerfect" class="w-4 h-4 text-blue-500 focus:ring-blue-400 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700" @change="drawPreviewCanvas" />
|
<label class="flex items-center justify-between cursor-pointer group">
|
||||||
<span class="text-sm dark:text-gray-200">Pixel perfect</span>
|
<span class="text-sm text-gray-600 dark:text-gray-400 group-hover:text-gray-800 dark:group-hover:text-gray-200 transition-colors">Apply to all layers</span>
|
||||||
|
<input type="checkbox" v-model="repositionAllLayers" class="w-4 h-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600" :disabled="!isDraggable" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex items-center justify-between cursor-pointer group">
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100 transition-colors">Compare sprites</span>
|
||||||
|
<div class="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input type="checkbox" v-model="showAllSprites" class="sr-only peer">
|
||||||
|
<div class="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||||
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Current frame offset display -->
|
<!-- Current frame offset display -->
|
||||||
<div v-if="props.sprites[currentFrameIndex]" class="p-2 bg-gray-100 dark:bg-gray-700 rounded-md border border-gray-200 dark:border-gray-600">
|
<div v-if="currentFrameSprite" class="px-4 pb-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-100 dark:border-gray-700 flex items-center justify-between">
|
||||||
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">Offset</span>
|
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Offset</span>
|
||||||
<span class="text-xs font-mono font-semibold text-cyan-600 dark:text-cyan-400">x: {{ props.sprites[currentFrameIndex].x }}, y: {{ props.sprites[currentFrameIndex].y }}</span>
|
<span class="text-xs font-mono font-bold text-gray-700 dark:text-gray-200">X: {{ currentFrameSprite.x }} <span class="text-gray-300 dark:text-gray-600 mx-1">|</span> Y: {{ currentFrameSprite.y }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Frame Selection (when Compare sprites is enabled) -->
|
<!-- Frame Selection (when Compare sprites is enabled) -->
|
||||||
<div v-if="showAllSprites" class="space-y-2">
|
<div v-if="showAllSprites" class="border-t border-gray-100 dark:border-gray-700 p-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between mb-3">
|
||||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">Frames</h3>
|
<h3 class="text-xs font-bold text-gray-400 dark:text-gray-500 uppercase tracking-wider">Visible Frames</h3>
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
<button @click="showAllFrames" class="px-2 py-1 text-xs bg-blue-500 hover:bg-blue-600 text-white rounded transition-colors">All</button>
|
<button @click="showAllFrames" class="px-2 py-1 text-[10px] font-medium bg-blue-50 text-blue-600 hover:bg-blue-100 dark:bg-blue-900/30 dark:text-blue-300 dark:hover:bg-blue-900/50 rounded transition-colors">All</button>
|
||||||
<button @click="hideAllFrames" class="px-2 py-1 text-xs bg-gray-500 hover:bg-gray-600 text-white rounded transition-colors">None</button>
|
<button @click="hideAllFrames" class="px-2 py-1 text-[10px] font-medium bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600 rounded transition-colors">None</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-800">
|
<div class="bg-gray-50 dark:bg-gray-900/50 rounded-lg border border-gray-200 dark:border-gray-700 p-2">
|
||||||
<div class="max-h-[180px] overflow-y-auto">
|
<div class="max-h-[150px] overflow-y-auto pr-1 custom-scrollbar">
|
||||||
<div class="space-y-0.5 p-1">
|
<div class="space-y-1">
|
||||||
<div v-for="(sprite, index) in props.sprites" :key="sprite.id" class="flex items-center gap-2 px-2 py-1.5 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer rounded" @click="toggleHiddenFrame(index)">
|
<div v-for="(sprite, index) in compositeFrames" :key="sprite.id" class="flex items-center gap-3 px-2 py-1.5 hover:bg-white dark:hover:bg-gray-700 cursor-pointer rounded-md transition-colors group" @click="toggleHiddenFrame(index)">
|
||||||
<input type="checkbox" :checked="!hiddenFrames.includes(index)" class="w-3.5 h-3.5 text-blue-500 focus:ring-blue-400 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700" @click.stop @change="toggleHiddenFrame(index)" />
|
<input type="checkbox" :checked="!hiddenFrames.includes(index)" class="w-4 h-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600" @click.stop @change="toggleHiddenFrame(index)" />
|
||||||
<div class="w-8 h-8 bg-gray-100 dark:bg-gray-700 rounded flex items-center justify-center overflow-hidden flex-shrink-0">
|
<div class="w-8 h-8 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded flex items-center justify-center overflow-hidden flex-shrink-0 shadow-sm">
|
||||||
<img :src="sprite.img.src" class="max-w-full max-h-full object-contain" :style="settingsStore.pixelPerfect ? { 'image-rendering': 'pixelated' } : {}" />
|
<img :src="sprite.url" class="max-w-full max-h-full object-contain" :style="settingsStore.pixelPerfect ? { 'image-rendering': 'pixelated' } : {}" />
|
||||||
</div>
|
</div>
|
||||||
<span class="text-xs dark:text-gray-200">{{ index + 1 }}</span>
|
<span class="text-xs font-mono text-gray-600 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100">Frame {{ index + 1 }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -157,31 +216,30 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, watch, onUnmounted } from 'vue';
|
import { ref, onMounted, watch, onUnmounted, computed } from 'vue';
|
||||||
import { useSettingsStore } from '@/stores/useSettingsStore';
|
import { useSettingsStore } from '@/stores/useSettingsStore';
|
||||||
import type { Sprite } from '@/types/sprites';
|
import type { Layer, Sprite } from '@/types/sprites';
|
||||||
import { getMaxDimensions } from '@/composables/useSprites';
|
import { getMaxDimensionsAcrossLayers } from '@/composables/useLayers';
|
||||||
import { useCanvas2D } from '@/composables/useCanvas2D';
|
|
||||||
import { useZoom } from '@/composables/useZoom';
|
import { useZoom } from '@/composables/useZoom';
|
||||||
import { useAnimationFrames } from '@/composables/useAnimationFrames';
|
import { useAnimationFrames } from '@/composables/useAnimationFrames';
|
||||||
|
import { calculateNegativeSpacing } from '@/composables/useNegativeSpacing';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
sprites: Sprite[];
|
layers: Layer[];
|
||||||
|
activeLayerId: string;
|
||||||
columns: number;
|
columns: number;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'updateSprite', id: string, x: number, y: number): void;
|
(e: 'updateSprite', id: string, x: number, y: number): void;
|
||||||
|
(e: 'updateSpriteInLayer', layerId: string, spriteId: string, x: number, y: number): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const previewCanvasRef = ref<HTMLCanvasElement | null>(null);
|
const previewContainerRef = ref<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
// Get settings from store
|
// Get settings from store
|
||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
|
|
||||||
// Initialize composables
|
|
||||||
const canvas2D = useCanvas2D(previewCanvasRef);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
zoom,
|
zoom,
|
||||||
increase: increaseZoom,
|
increase: increaseZoom,
|
||||||
@@ -191,95 +249,121 @@
|
|||||||
initial: 1,
|
initial: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const getVisibleLayers = () => props.layers.filter(l => l.visible);
|
||||||
|
const maxFrames = () => Math.max(0, ...getVisibleLayers().map(l => l.sprites.length));
|
||||||
|
|
||||||
const { currentFrameIndex, isPlaying, fps, hiddenFrames, visibleFrames, visibleFramesCount, visibleFrameIndex, visibleFrameNumber, togglePlayback, nextFrame, previousFrame, handleSliderInput, toggleHiddenFrame, showAllFrames, hideAllFrames, stopAnimation } = useAnimationFrames({
|
const { currentFrameIndex, isPlaying, fps, hiddenFrames, visibleFrames, visibleFramesCount, visibleFrameIndex, visibleFrameNumber, togglePlayback, nextFrame, previousFrame, handleSliderInput, toggleHiddenFrame, showAllFrames, hideAllFrames, stopAnimation } = useAnimationFrames({
|
||||||
sprites: () => props.sprites,
|
sprites: () => {
|
||||||
onDraw: drawPreviewCanvas,
|
const len = maxFrames();
|
||||||
|
const frames: Sprite[] = [];
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
const s = getVisibleLayers().find(l => l.sprites[i])?.sprites[i];
|
||||||
|
if (s) frames.push(s);
|
||||||
|
}
|
||||||
|
return frames;
|
||||||
|
},
|
||||||
|
onDraw: () => {}, // No longer needed for canvas drawing
|
||||||
});
|
});
|
||||||
|
|
||||||
// Preview state
|
// Preview state
|
||||||
const isDraggable = ref(false);
|
const isDraggable = ref(false);
|
||||||
|
const repositionAllLayers = ref(false);
|
||||||
const showAllSprites = ref(false);
|
const showAllSprites = ref(false);
|
||||||
|
|
||||||
|
const compositeFrames = computed<Sprite[]>(() => {
|
||||||
|
// Show frames from the active layer for the thumbnail list
|
||||||
|
const activeLayer = props.layers.find(l => l.id === props.activeLayerId);
|
||||||
|
if (!activeLayer) {
|
||||||
|
// Fallback to first visible layer if no active layer
|
||||||
|
const v = getVisibleLayers();
|
||||||
|
const len = maxFrames();
|
||||||
|
const arr: Sprite[] = [];
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
const s = v.find(l => l.sprites[i])?.sprites[i];
|
||||||
|
if (s) arr.push(s);
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
return activeLayer.sprites;
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentFrameSprite = computed<Sprite | null>(() => {
|
||||||
|
const layer = props.layers.find(l => l.id === props.activeLayerId);
|
||||||
|
if (!layer) return null;
|
||||||
|
return layer.sprites[currentFrameIndex.value] || null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Computed cell dimensions
|
||||||
|
const cellDimensions = computed(() => {
|
||||||
|
const visibleLayers = getVisibleLayers();
|
||||||
|
// If manual cell size is enabled, use manual values
|
||||||
|
if (settingsStore.manualCellSizeEnabled) {
|
||||||
|
return {
|
||||||
|
cellWidth: settingsStore.manualCellWidth,
|
||||||
|
cellHeight: settingsStore.manualCellHeight,
|
||||||
|
negativeSpacing: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, calculate from sprite dimensions
|
||||||
|
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers);
|
||||||
|
const allSprites = visibleLayers.flatMap(l => l.sprites);
|
||||||
|
const negativeSpacing = calculateNegativeSpacing(allSprites, settingsStore.negativeSpacingEnabled);
|
||||||
|
return {
|
||||||
|
cellWidth: maxWidth + negativeSpacing,
|
||||||
|
cellHeight: maxHeight + negativeSpacing,
|
||||||
|
negativeSpacing,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper for background image (dark mode friendly)
|
||||||
|
const getPreviewBackgroundImage = () => {
|
||||||
|
if (settingsStore.backgroundColor === 'transparent' && settingsStore.checkerboardEnabled) {
|
||||||
|
const color = settingsStore.darkMode ? '#4b5563' : '#d1d5db';
|
||||||
|
return `linear-gradient(45deg, ${color} 25%, transparent 25%), linear-gradient(-45deg, ${color} 25%, transparent 25%), linear-gradient(45deg, transparent 75%, ${color} 75%), linear-gradient(-45deg, transparent 75%, ${color} 75%)`;
|
||||||
|
}
|
||||||
|
return 'none';
|
||||||
|
};
|
||||||
|
|
||||||
// Dragging state
|
// Dragging state
|
||||||
const isDragging = ref(false);
|
const isDragging = ref(false);
|
||||||
const activeSpriteId = ref<string | null>(null);
|
const activeSpriteId = ref<string | null>(null);
|
||||||
|
const activeLayerId = ref<string | null>(null);
|
||||||
const dragStartX = ref(0);
|
const dragStartX = ref(0);
|
||||||
const dragStartY = ref(0);
|
const dragStartY = ref(0);
|
||||||
const spritePosBeforeDrag = ref({ x: 0, y: 0 });
|
const spritePosBeforeDrag = ref({ x: 0, y: 0 });
|
||||||
|
const allSpritesPosBeforeDrag = ref<Map<string, { x: number; y: number }>>(new Map());
|
||||||
// Canvas drawing
|
|
||||||
|
|
||||||
// Calculate negative spacing based on sprite dimensions
|
|
||||||
function calculateNegativeSpacing(): number {
|
|
||||||
if (!settingsStore.negativeSpacingEnabled || props.sprites.length === 0) return 0;
|
|
||||||
|
|
||||||
const { maxWidth, maxHeight } = getMaxDimensions(props.sprites);
|
|
||||||
const minWidth = Math.min(...props.sprites.map(s => s.width));
|
|
||||||
const minHeight = Math.min(...props.sprites.map(s => s.height));
|
|
||||||
const widthDiff = maxWidth - minWidth;
|
|
||||||
const heightDiff = maxHeight - minHeight;
|
|
||||||
return Math.max(widthDiff, heightDiff);
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawPreviewCanvas() {
|
|
||||||
if (!previewCanvasRef.value || !canvas2D.ctx.value || props.sprites.length === 0) return;
|
|
||||||
|
|
||||||
const currentSprite = props.sprites[currentFrameIndex.value];
|
|
||||||
if (!currentSprite) return;
|
|
||||||
|
|
||||||
const { maxWidth, maxHeight } = getMaxDimensions(props.sprites);
|
|
||||||
const negativeSpacing = calculateNegativeSpacing();
|
|
||||||
const cellWidth = maxWidth + negativeSpacing;
|
|
||||||
const cellHeight = maxHeight + negativeSpacing;
|
|
||||||
|
|
||||||
// Apply pixel art optimization
|
|
||||||
canvas2D.applySmoothing();
|
|
||||||
|
|
||||||
// Set canvas size to fit one sprite cell (expanded with negative spacing)
|
|
||||||
canvas2D.setCanvasSize(cellWidth, cellHeight);
|
|
||||||
|
|
||||||
// Clear canvas
|
|
||||||
canvas2D.clear();
|
|
||||||
|
|
||||||
// Draw grid background (cell)
|
|
||||||
canvas2D.fillRect(0, 0, cellWidth, cellHeight, '#f9fafb');
|
|
||||||
|
|
||||||
// Draw all sprites with transparency if enabled
|
|
||||||
if (showAllSprites.value && props.sprites.length > 1) {
|
|
||||||
props.sprites.forEach((sprite, index) => {
|
|
||||||
if (index !== currentFrameIndex.value && !hiddenFrames.value.includes(index)) {
|
|
||||||
canvas2D.drawImage(sprite.img, negativeSpacing + sprite.x, negativeSpacing + sprite.y, 0.3);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw current sprite with negative spacing offset
|
|
||||||
canvas2D.drawImage(currentSprite.img, negativeSpacing + currentSprite.x, negativeSpacing + currentSprite.y);
|
|
||||||
|
|
||||||
// Draw cell border
|
|
||||||
canvas2D.strokeRect(0, 0, cellWidth, cellHeight, '#e5e7eb', 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drag functionality
|
// Drag functionality
|
||||||
const startDrag = (event: MouseEvent) => {
|
const startDrag = (event: MouseEvent, sprite: Sprite, layerId: string) => {
|
||||||
if (!isDraggable.value || !previewCanvasRef.value) return;
|
if (!isDraggable.value || !previewContainerRef.value) return;
|
||||||
|
|
||||||
const rect = previewCanvasRef.value.getBoundingClientRect();
|
const rect = previewContainerRef.value.getBoundingClientRect();
|
||||||
const scaleX = previewCanvasRef.value.width / (rect.width / zoom.value);
|
const scaleX = previewContainerRef.value.offsetWidth / (rect.width / zoom.value);
|
||||||
const scaleY = previewCanvasRef.value.height / (rect.height / zoom.value);
|
const scaleY = previewContainerRef.value.offsetHeight / (rect.height / zoom.value);
|
||||||
|
|
||||||
const mouseX = ((event.clientX - rect.left) / zoom.value) * scaleX;
|
const mouseX = ((event.clientX - rect.left) / zoom.value) * scaleX;
|
||||||
const mouseY = ((event.clientY - rect.top) / zoom.value) * scaleY;
|
const mouseY = ((event.clientY - rect.top) / zoom.value) * scaleY;
|
||||||
|
|
||||||
const sprite = props.sprites[currentFrameIndex.value];
|
if (repositionAllLayers.value) {
|
||||||
const negativeSpacing = calculateNegativeSpacing();
|
isDragging.value = true;
|
||||||
|
activeSpriteId.value = 'ALL_LAYERS'; // Special marker for all layers
|
||||||
|
dragStartX.value = mouseX;
|
||||||
|
dragStartY.value = mouseY;
|
||||||
|
|
||||||
// Check if click is on sprite (accounting for negative spacing offset)
|
// Store initial positions for all sprites in this frame from all visible layers
|
||||||
const spriteCanvasX = negativeSpacing + sprite.x;
|
allSpritesPosBeforeDrag.value.clear();
|
||||||
const spriteCanvasY = negativeSpacing + sprite.y;
|
const visibleLayers = getVisibleLayers();
|
||||||
if (sprite && mouseX >= spriteCanvasX && mouseX <= spriteCanvasX + sprite.width && mouseY >= spriteCanvasY && mouseY <= spriteCanvasY + sprite.height) {
|
visibleLayers.forEach(layer => {
|
||||||
|
const s = layer.sprites[currentFrameIndex.value];
|
||||||
|
if (s) {
|
||||||
|
allSpritesPosBeforeDrag.value.set(s.id, { x: s.x, y: s.y });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
isDragging.value = true;
|
isDragging.value = true;
|
||||||
activeSpriteId.value = sprite.id;
|
activeSpriteId.value = sprite.id;
|
||||||
|
activeLayerId.value = layerId;
|
||||||
dragStartX.value = mouseX;
|
dragStartX.value = mouseX;
|
||||||
dragStartY.value = mouseY;
|
dragStartY.value = mouseY;
|
||||||
spritePosBeforeDrag.value = { x: sprite.x, y: sprite.y };
|
spritePosBeforeDrag.value = { x: sprite.x, y: sprite.y };
|
||||||
@@ -287,11 +371,11 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const drag = (event: MouseEvent) => {
|
const drag = (event: MouseEvent) => {
|
||||||
if (!isDragging.value || !activeSpriteId.value || !previewCanvasRef.value) return;
|
if (!isDragging.value || !activeSpriteId.value || !previewContainerRef.value) return;
|
||||||
|
|
||||||
const rect = previewCanvasRef.value.getBoundingClientRect();
|
const rect = previewContainerRef.value.getBoundingClientRect();
|
||||||
const scaleX = previewCanvasRef.value.width / (rect.width / zoom.value);
|
const scaleX = previewContainerRef.value.offsetWidth / (rect.width / zoom.value);
|
||||||
const scaleY = previewCanvasRef.value.height / (rect.height / zoom.value);
|
const scaleY = previewContainerRef.value.offsetHeight / (rect.height / zoom.value);
|
||||||
|
|
||||||
const mouseX = ((event.clientX - rect.left) / zoom.value) * scaleX;
|
const mouseX = ((event.clientX - rect.left) / zoom.value) * scaleX;
|
||||||
const mouseY = ((event.clientY - rect.top) / zoom.value) * scaleY;
|
const mouseY = ((event.clientY - rect.top) / zoom.value) * scaleY;
|
||||||
@@ -299,32 +383,52 @@
|
|||||||
const deltaX = Math.round(mouseX - dragStartX.value);
|
const deltaX = Math.round(mouseX - dragStartX.value);
|
||||||
const deltaY = Math.round(mouseY - dragStartY.value);
|
const deltaY = Math.round(mouseY - dragStartY.value);
|
||||||
|
|
||||||
const sprite = props.sprites[currentFrameIndex.value];
|
const { cellWidth, cellHeight, negativeSpacing } = cellDimensions.value;
|
||||||
if (!sprite || sprite.id !== activeSpriteId.value) return;
|
|
||||||
|
|
||||||
const { maxWidth, maxHeight } = getMaxDimensions(props.sprites);
|
if (activeSpriteId.value === 'ALL_LAYERS') {
|
||||||
const negativeSpacing = calculateNegativeSpacing();
|
// Move all sprites in current frame from all visible layers
|
||||||
const cellWidth = maxWidth + negativeSpacing;
|
const visibleLayers = getVisibleLayers();
|
||||||
const cellHeight = maxHeight + negativeSpacing;
|
visibleLayers.forEach(layer => {
|
||||||
|
const sprite = layer.sprites[currentFrameIndex.value];
|
||||||
|
if (!sprite) return;
|
||||||
|
|
||||||
// Calculate new position with constraints and round to integers
|
const originalPos = allSpritesPosBeforeDrag.value.get(sprite.id);
|
||||||
let newX = Math.round(spritePosBeforeDrag.value.x + deltaX);
|
if (!originalPos) return;
|
||||||
let newY = Math.round(spritePosBeforeDrag.value.y + deltaY);
|
|
||||||
|
|
||||||
// Constrain movement within expanded cell (allow negative values up to -negativeSpacing)
|
// Calculate new position with constraints
|
||||||
newX = Math.max(-negativeSpacing, Math.min(cellWidth - negativeSpacing - sprite.width, newX));
|
let newX = Math.round(originalPos.x + deltaX);
|
||||||
newY = Math.max(-negativeSpacing, Math.min(cellHeight - negativeSpacing - sprite.height, newY));
|
let newY = Math.round(originalPos.y + deltaY);
|
||||||
|
|
||||||
emit('updateSprite', activeSpriteId.value, newX, newY);
|
// Constrain movement within expanded cell
|
||||||
drawPreviewCanvas();
|
newX = Math.max(-negativeSpacing, Math.min(cellWidth - negativeSpacing - sprite.width, newX));
|
||||||
|
newY = Math.max(-negativeSpacing, Math.min(cellHeight - negativeSpacing - sprite.height, newY));
|
||||||
|
|
||||||
|
emit('updateSpriteInLayer', layer.id, sprite.id, newX, newY);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Move only the active layer sprite
|
||||||
|
const activeSprite = props.layers.find(l => l.id === activeLayerId.value)?.sprites[currentFrameIndex.value];
|
||||||
|
if (!activeSprite || activeSprite.id !== activeSpriteId.value) return;
|
||||||
|
|
||||||
|
// Calculate new position with constraints and round to integers
|
||||||
|
let newX = Math.round(spritePosBeforeDrag.value.x + deltaX);
|
||||||
|
let newY = Math.round(spritePosBeforeDrag.value.y + deltaY);
|
||||||
|
|
||||||
|
// Constrain movement within expanded cell (allow negative values up to -negativeSpacing)
|
||||||
|
newX = Math.max(-negativeSpacing, Math.min(cellWidth - negativeSpacing - activeSprite.width, newX));
|
||||||
|
newY = Math.max(-negativeSpacing, Math.min(cellHeight - negativeSpacing - activeSprite.height, newY));
|
||||||
|
|
||||||
|
emit('updateSprite', activeSpriteId.value, newX, newY);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const stopDrag = () => {
|
const stopDrag = () => {
|
||||||
isDragging.value = false;
|
isDragging.value = false;
|
||||||
activeSpriteId.value = null;
|
activeSpriteId.value = null;
|
||||||
|
activeLayerId.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTouchStart = (event: TouchEvent) => {
|
const handleTouchStart = (event: TouchEvent, sprite: Sprite, layerId: string) => {
|
||||||
if (!isDraggable.value) return;
|
if (!isDraggable.value) return;
|
||||||
|
|
||||||
if (event.touches.length === 1) {
|
if (event.touches.length === 1) {
|
||||||
@@ -333,7 +437,7 @@
|
|||||||
clientX: touch.clientX,
|
clientX: touch.clientX,
|
||||||
clientY: touch.clientY,
|
clientY: touch.clientY,
|
||||||
});
|
});
|
||||||
startDrag(mouseEvent);
|
startDrag(mouseEvent, sprite, layerId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -356,39 +460,25 @@
|
|||||||
|
|
||||||
// Lifecycle hooks
|
// Lifecycle hooks
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
canvas2D.initContext();
|
// No longer need to initialize canvas or draw
|
||||||
drawPreviewCanvas();
|
|
||||||
|
|
||||||
// Listen for forceRedraw event from App.vue
|
|
||||||
window.addEventListener('forceRedraw', handleForceRedraw);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
stopAnimation();
|
stopAnimation();
|
||||||
window.removeEventListener('forceRedraw', handleForceRedraw);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handler for force redraw event
|
// Watchers - most canvas-related watchers removed
|
||||||
const handleForceRedraw = () => {
|
// Keep layer watchers to ensure reactivity
|
||||||
canvas2D.ensureIntegerPositions(props.sprites);
|
watch(
|
||||||
canvas2D.applySmoothing();
|
() => props.layers,
|
||||||
drawPreviewCanvas();
|
() => {},
|
||||||
};
|
{ deep: true }
|
||||||
|
);
|
||||||
// Watchers
|
watch(
|
||||||
watch(() => props.sprites, drawPreviewCanvas, { deep: true });
|
() => props.activeLayerId,
|
||||||
watch(currentFrameIndex, drawPreviewCanvas);
|
() => {}
|
||||||
watch(zoom, drawPreviewCanvas);
|
);
|
||||||
watch(isDraggable, drawPreviewCanvas);
|
watch(currentFrameIndex, () => {});
|
||||||
watch(showAllSprites, drawPreviewCanvas);
|
|
||||||
watch(hiddenFrames, drawPreviewCanvas);
|
|
||||||
watch(() => settingsStore.pixelPerfect, drawPreviewCanvas);
|
|
||||||
watch(() => settingsStore.negativeSpacingEnabled, drawPreviewCanvas);
|
|
||||||
|
|
||||||
// Initial draw
|
|
||||||
if (props.sprites.length > 0) {
|
|
||||||
drawPreviewCanvas();
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<button @click="settingsStore.toggleDarkMode()" class="p-2 rounded-lg transition-colors" :class="settingsStore.darkMode ? 'text-yellow-400 hover:bg-gray-700' : 'text-gray-700 hover:bg-gray-100'" aria-label="Toggle dark mode" data-rybbit-event="toggle-dark-mode">
|
<button
|
||||||
<i :class="settingsStore.darkMode ? 'fas fa-sun' : 'fas fa-moon'"></i>
|
@click="settingsStore.toggleDarkMode()"
|
||||||
|
class="btn btn-secondary mr-1"
|
||||||
|
aria-label="Toggle dark mode"
|
||||||
|
data-rybbit-event="toggle-dark-mode"
|
||||||
|
>
|
||||||
|
<i :class="settingsStore.darkMode ? 'fas fa-sun' : 'fas fa-moon'"></i> Theme
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -116,9 +116,10 @@ export function useCanvas2D(canvasRef: Ref<HTMLCanvasElement | null>, options?:
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fill cell background with theme-aware color
|
// Fill cell background with selected color or transparent
|
||||||
const fillCellBackground = (x: number, y: number, width: number, height: number) => {
|
const fillCellBackground = (x: number, y: number, width: number, height: number) => {
|
||||||
const color = settingsStore.darkMode ? '#1F2937' : '#f9fafb';
|
if (settingsStore.backgroundColor === 'transparent') return;
|
||||||
|
const color = settingsStore.backgroundColor === 'custom' ? settingsStore.backgroundColor : settingsStore.backgroundColor;
|
||||||
fillRect(x, y, width, height, color);
|
fillRect(x, y, width, height, color);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ref, computed, type Ref, type ComputedRef } from 'vue';
|
import { ref, computed, type Ref, type ComputedRef } from 'vue';
|
||||||
import type { Sprite } from '@/types/sprites';
|
import type { Sprite, Layer } from '@/types/sprites';
|
||||||
import { getMaxDimensions } from './useSprites';
|
import { getMaxDimensions } from './useSprites';
|
||||||
|
import { calculateNegativeSpacing } from './useNegativeSpacing';
|
||||||
|
|
||||||
export interface CellPosition {
|
export interface CellPosition {
|
||||||
col: number;
|
col: number;
|
||||||
@@ -27,10 +28,14 @@ export interface SpritePosition {
|
|||||||
|
|
||||||
export interface DragSpriteOptions {
|
export interface DragSpriteOptions {
|
||||||
sprites: Ref<Sprite[]> | ComputedRef<Sprite[]> | Sprite[];
|
sprites: Ref<Sprite[]> | ComputedRef<Sprite[]> | Sprite[];
|
||||||
|
layers?: Ref<Layer[]> | ComputedRef<Layer[]> | Layer[];
|
||||||
columns: Ref<number> | number;
|
columns: Ref<number> | number;
|
||||||
zoom?: Ref<number>;
|
zoom?: Ref<number>;
|
||||||
allowCellSwap?: Ref<boolean>;
|
allowCellSwap?: Ref<boolean>;
|
||||||
negativeSpacingEnabled?: Ref<boolean>;
|
negativeSpacingEnabled?: Ref<boolean>;
|
||||||
|
manualCellSizeEnabled?: Ref<boolean>;
|
||||||
|
manualCellWidth?: Ref<number>;
|
||||||
|
manualCellHeight?: Ref<number>;
|
||||||
getMousePosition: (event: MouseEvent, zoom?: number) => { x: number; y: number } | null;
|
getMousePosition: (event: MouseEvent, zoom?: number) => { x: number; y: number } | null;
|
||||||
onUpdateSprite: (id: string, x: number, y: number) => void;
|
onUpdateSprite: (id: string, x: number, y: number) => void;
|
||||||
onUpdateSpriteCell?: (id: string, newIndex: number) => void;
|
onUpdateSpriteCell?: (id: string, newIndex: number) => void;
|
||||||
@@ -42,10 +47,14 @@ export function useDragSprite(options: DragSpriteOptions) {
|
|||||||
|
|
||||||
// Helper to get reactive values
|
// Helper to get reactive values
|
||||||
const getSprites = () => (Array.isArray(options.sprites) ? options.sprites : options.sprites.value);
|
const getSprites = () => (Array.isArray(options.sprites) ? options.sprites : options.sprites.value);
|
||||||
|
const getLayers = () => (options.layers ? (Array.isArray(options.layers) ? options.layers : options.layers.value) : null);
|
||||||
const getColumns = () => (typeof options.columns === 'number' ? options.columns : options.columns.value);
|
const getColumns = () => (typeof options.columns === 'number' ? options.columns : options.columns.value);
|
||||||
const getZoom = () => options.zoom?.value ?? 1;
|
const getZoom = () => options.zoom?.value ?? 1;
|
||||||
const getAllowCellSwap = () => options.allowCellSwap?.value ?? false;
|
const getAllowCellSwap = () => options.allowCellSwap?.value ?? false;
|
||||||
const getNegativeSpacingEnabled = () => options.negativeSpacingEnabled?.value ?? false;
|
const getNegativeSpacingEnabled = () => options.negativeSpacingEnabled?.value ?? false;
|
||||||
|
const getManualCellSizeEnabled = () => options.manualCellSizeEnabled?.value ?? false;
|
||||||
|
const getManualCellWidth = () => options.manualCellWidth?.value ?? 64;
|
||||||
|
const getManualCellHeight = () => options.manualCellHeight?.value ?? 64;
|
||||||
|
|
||||||
// Drag state
|
// Drag state
|
||||||
const isDragging = ref(false);
|
const isDragging = ref(false);
|
||||||
@@ -66,25 +75,34 @@ export function useDragSprite(options: DragSpriteOptions) {
|
|||||||
const lastMaxHeight = ref(1);
|
const lastMaxHeight = ref(1);
|
||||||
|
|
||||||
const calculateMaxDimensions = () => {
|
const calculateMaxDimensions = () => {
|
||||||
const sprites = getSprites();
|
|
||||||
const negativeSpacingEnabled = getNegativeSpacingEnabled();
|
const negativeSpacingEnabled = getNegativeSpacingEnabled();
|
||||||
const base = getMaxDimensions(sprites);
|
const manualCellSizeEnabled = getManualCellSizeEnabled();
|
||||||
const baseMaxWidth = Math.max(1, base.maxWidth, lastMaxWidth.value);
|
|
||||||
const baseMaxHeight = Math.max(1, base.maxHeight, lastMaxHeight.value);
|
// If manual cell size is enabled, use manual dimensions
|
||||||
|
if (manualCellSizeEnabled) {
|
||||||
|
const maxWidth = getManualCellWidth();
|
||||||
|
const maxHeight = getManualCellHeight();
|
||||||
|
// When manual cell size is used, negative spacing is not applied
|
||||||
|
const negativeSpacing = 0;
|
||||||
|
// Don't update lastMaxWidth/lastMaxHeight when in manual mode
|
||||||
|
return { maxWidth, maxHeight, negativeSpacing };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all sprites to calculate dimensions from
|
||||||
|
// If layers are provided, use all visible layers; otherwise use current sprites
|
||||||
|
const layers = getLayers();
|
||||||
|
const spritesToMeasure = layers ? layers.filter(l => l.visible).flatMap(l => l.sprites) : getSprites();
|
||||||
|
|
||||||
|
// Otherwise, calculate based on sprite dimensions across all visible layers
|
||||||
|
const base = getMaxDimensions(spritesToMeasure);
|
||||||
|
// When switching back from manual mode, reset to actual sprite dimensions
|
||||||
|
const baseMaxWidth = Math.max(1, base.maxWidth);
|
||||||
|
const baseMaxHeight = Math.max(1, base.maxHeight);
|
||||||
lastMaxWidth.value = baseMaxWidth;
|
lastMaxWidth.value = baseMaxWidth;
|
||||||
lastMaxHeight.value = baseMaxHeight;
|
lastMaxHeight.value = baseMaxHeight;
|
||||||
|
|
||||||
// Calculate negative spacing based on sprite size differences
|
// Calculate negative spacing using shared composable
|
||||||
let negativeSpacing = 0;
|
const negativeSpacing = calculateNegativeSpacing(spritesToMeasure, negativeSpacingEnabled);
|
||||||
if (negativeSpacingEnabled && sprites.length > 0) {
|
|
||||||
// Find the smallest sprite dimensions
|
|
||||||
const minWidth = Math.min(...sprites.map(s => s.width));
|
|
||||||
const minHeight = Math.min(...sprites.map(s => s.height));
|
|
||||||
// Negative spacing is the difference between max and min dimensions
|
|
||||||
const widthDiff = baseMaxWidth - minWidth;
|
|
||||||
const heightDiff = baseMaxHeight - minHeight;
|
|
||||||
negativeSpacing = Math.max(widthDiff, heightDiff);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add negative spacing to expand each cell
|
// Add negative spacing to expand each cell
|
||||||
const maxWidth = baseMaxWidth + negativeSpacing;
|
const maxWidth = baseMaxWidth + negativeSpacing;
|
||||||
|
|||||||
@@ -4,29 +4,36 @@ import gifWorkerUrl from 'gif.js/dist/gif.worker.js?url';
|
|||||||
import JSZip from 'jszip';
|
import JSZip from 'jszip';
|
||||||
import type { Sprite } from '../types/sprites';
|
import type { Sprite } from '../types/sprites';
|
||||||
import { getMaxDimensions } from './useSprites';
|
import { getMaxDimensions } from './useSprites';
|
||||||
|
import { calculateNegativeSpacing } from './useNegativeSpacing';
|
||||||
|
|
||||||
export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negativeSpacingEnabled: Ref<boolean>) => {
|
export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negativeSpacingEnabled: Ref<boolean>, backgroundColor?: Ref<string>, manualCellSizeEnabled?: Ref<boolean>, manualCellWidth?: Ref<number>, manualCellHeight?: Ref<number>) => {
|
||||||
// Calculate negative spacing based on sprite dimensions
|
const getCellDimensions = () => {
|
||||||
const calculateNegativeSpacing = (): number => {
|
// If manual cell size is enabled, use manual values
|
||||||
if (!negativeSpacingEnabled.value || sprites.value.length === 0) return 0;
|
if (manualCellSizeEnabled?.value) {
|
||||||
|
return {
|
||||||
|
cellWidth: manualCellWidth?.value ?? 64,
|
||||||
|
cellHeight: manualCellHeight?.value ?? 64,
|
||||||
|
negativeSpacing: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, calculate from sprite dimensions
|
||||||
const { maxWidth, maxHeight } = getMaxDimensions(sprites.value);
|
const { maxWidth, maxHeight } = getMaxDimensions(sprites.value);
|
||||||
const minWidth = Math.min(...sprites.value.map(s => s.width));
|
const negativeSpacing = calculateNegativeSpacing(sprites.value, negativeSpacingEnabled.value);
|
||||||
const minHeight = Math.min(...sprites.value.map(s => s.height));
|
return {
|
||||||
const widthDiff = maxWidth - minWidth;
|
cellWidth: maxWidth + negativeSpacing,
|
||||||
const heightDiff = maxHeight - minHeight;
|
cellHeight: maxHeight + negativeSpacing,
|
||||||
return Math.max(widthDiff, heightDiff);
|
negativeSpacing,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadSpritesheet = () => {
|
const downloadSpritesheet = () => {
|
||||||
if (!sprites.value.length) {
|
if (!sprites.value.length) {
|
||||||
alert('Please upload or import sprites before downloading the spritesheet.');
|
alert('Please upload or import sprites before downloading the spritesheet.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { maxWidth, maxHeight } = getMaxDimensions(sprites.value);
|
const { cellWidth, cellHeight, negativeSpacing } = getCellDimensions();
|
||||||
const negativeSpacing = calculateNegativeSpacing();
|
|
||||||
const cellWidth = maxWidth + negativeSpacing;
|
|
||||||
const cellHeight = maxHeight + negativeSpacing;
|
|
||||||
const rows = Math.ceil(sprites.value.length / columns.value);
|
const rows = Math.ceil(sprites.value.length / columns.value);
|
||||||
|
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
@@ -37,6 +44,12 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
|
|||||||
canvas.height = cellHeight * rows;
|
canvas.height = cellHeight * rows;
|
||||||
ctx.imageSmoothingEnabled = false;
|
ctx.imageSmoothingEnabled = false;
|
||||||
|
|
||||||
|
// Apply background color if not transparent
|
||||||
|
if (backgroundColor?.value && backgroundColor.value !== 'transparent') {
|
||||||
|
ctx.fillStyle = backgroundColor.value;
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
}
|
||||||
|
|
||||||
sprites.value.forEach((sprite, index) => {
|
sprites.value.forEach((sprite, index) => {
|
||||||
const col = index % columns.value;
|
const col = index % columns.value;
|
||||||
const row = Math.floor(index / columns.value);
|
const row = Math.floor(index / columns.value);
|
||||||
@@ -78,7 +91,15 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const jsonData = { columns: columns.value, sprites: spritesData.filter(Boolean) };
|
const jsonData = {
|
||||||
|
columns: columns.value,
|
||||||
|
negativeSpacingEnabled: negativeSpacingEnabled.value,
|
||||||
|
backgroundColor: backgroundColor?.value || 'transparent',
|
||||||
|
manualCellSizeEnabled: manualCellSizeEnabled?.value || false,
|
||||||
|
manualCellWidth: manualCellWidth?.value || 64,
|
||||||
|
manualCellHeight: manualCellHeight?.value || 64,
|
||||||
|
sprites: spritesData.filter(Boolean),
|
||||||
|
};
|
||||||
const jsonString = JSON.stringify(jsonData, null, 2);
|
const jsonString = JSON.stringify(jsonData, null, 2);
|
||||||
const blob = new Blob([jsonString], { type: 'application/json' });
|
const blob = new Blob([jsonString], { type: 'application/json' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -96,6 +117,11 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
|
|||||||
if (!jsonData.sprites || !Array.isArray(jsonData.sprites)) throw new Error('Invalid JSON format: missing sprites array');
|
if (!jsonData.sprites || !Array.isArray(jsonData.sprites)) throw new Error('Invalid JSON format: missing sprites array');
|
||||||
|
|
||||||
if (jsonData.columns && typeof jsonData.columns === 'number') columns.value = jsonData.columns;
|
if (jsonData.columns && typeof jsonData.columns === 'number') columns.value = jsonData.columns;
|
||||||
|
if (typeof jsonData.negativeSpacingEnabled === 'boolean') negativeSpacingEnabled.value = jsonData.negativeSpacingEnabled;
|
||||||
|
if (typeof jsonData.backgroundColor === 'string' && backgroundColor) backgroundColor.value = jsonData.backgroundColor;
|
||||||
|
if (typeof jsonData.manualCellSizeEnabled === 'boolean' && manualCellSizeEnabled) manualCellSizeEnabled.value = jsonData.manualCellSizeEnabled;
|
||||||
|
if (typeof jsonData.manualCellWidth === 'number' && manualCellWidth) manualCellWidth.value = jsonData.manualCellWidth;
|
||||||
|
if (typeof jsonData.manualCellHeight === 'number' && manualCellHeight) manualCellHeight.value = jsonData.manualCellHeight;
|
||||||
|
|
||||||
// revoke existing blob urls
|
// revoke existing blob urls
|
||||||
if (sprites.value.length) {
|
if (sprites.value.length) {
|
||||||
@@ -146,10 +172,7 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { maxWidth, maxHeight } = getMaxDimensions(sprites.value);
|
const { cellWidth, cellHeight, negativeSpacing } = getCellDimensions();
|
||||||
const negativeSpacing = calculateNegativeSpacing();
|
|
||||||
const cellWidth = maxWidth + negativeSpacing;
|
|
||||||
const cellHeight = maxHeight + negativeSpacing;
|
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
@@ -161,8 +184,11 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
|
|||||||
|
|
||||||
sprites.value.forEach(sprite => {
|
sprites.value.forEach(sprite => {
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
ctx.fillStyle = '#f9fafb';
|
// Apply background color if not transparent
|
||||||
ctx.fillRect(0, 0, cellWidth, cellHeight);
|
if (backgroundColor?.value && backgroundColor.value !== 'transparent') {
|
||||||
|
ctx.fillStyle = backgroundColor.value;
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
}
|
||||||
ctx.drawImage(sprite.img, Math.floor(negativeSpacing + sprite.x), Math.floor(negativeSpacing + sprite.y));
|
ctx.drawImage(sprite.img, Math.floor(negativeSpacing + sprite.x), Math.floor(negativeSpacing + sprite.y));
|
||||||
gif.addFrame(ctx, { copy: true, delay: 1000 / fps });
|
gif.addFrame(ctx, { copy: true, delay: 1000 / fps });
|
||||||
});
|
});
|
||||||
@@ -186,10 +212,7 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
|
|||||||
}
|
}
|
||||||
|
|
||||||
const zip = new JSZip();
|
const zip = new JSZip();
|
||||||
const { maxWidth, maxHeight } = getMaxDimensions(sprites.value);
|
const { cellWidth, cellHeight, negativeSpacing } = getCellDimensions();
|
||||||
const negativeSpacing = calculateNegativeSpacing();
|
|
||||||
const cellWidth = maxWidth + negativeSpacing;
|
|
||||||
const cellHeight = maxHeight + negativeSpacing;
|
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
@@ -199,8 +222,11 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
|
|||||||
|
|
||||||
sprites.value.forEach((sprite, index) => {
|
sprites.value.forEach((sprite, index) => {
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
ctx.fillStyle = '#f9fafb';
|
// Apply background color if not transparent
|
||||||
ctx.fillRect(0, 0, cellWidth, cellHeight);
|
if (backgroundColor?.value && backgroundColor.value !== 'transparent') {
|
||||||
|
ctx.fillStyle = backgroundColor.value;
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
}
|
||||||
ctx.drawImage(sprite.img, Math.floor(negativeSpacing + sprite.x), Math.floor(negativeSpacing + sprite.y));
|
ctx.drawImage(sprite.img, Math.floor(negativeSpacing + sprite.x), Math.floor(negativeSpacing + sprite.y));
|
||||||
const dataURL = canvas.toDataURL('image/png');
|
const dataURL = canvas.toDataURL('image/png');
|
||||||
const binary = atob(dataURL.split(',')[1]);
|
const binary = atob(dataURL.split(',')[1]);
|
||||||
|
|||||||
304
src/composables/useExportLayers.ts
Normal file
304
src/composables/useExportLayers.ts
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
import type { Ref } from 'vue';
|
||||||
|
import GIF from 'gif.js';
|
||||||
|
import gifWorkerUrl from 'gif.js/dist/gif.worker.js?url';
|
||||||
|
import JSZip from 'jszip';
|
||||||
|
import type { Layer, Sprite } from '@/types/sprites';
|
||||||
|
import { getMaxDimensionsAcrossLayers } from './useLayers';
|
||||||
|
import { calculateNegativeSpacing } from './useNegativeSpacing';
|
||||||
|
|
||||||
|
export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, negativeSpacingEnabled: Ref<boolean>, activeLayerId?: Ref<string>, backgroundColor?: Ref<string>, manualCellSizeEnabled?: Ref<boolean>, manualCellWidth?: Ref<number>, manualCellHeight?: Ref<number>) => {
|
||||||
|
const getVisibleLayers = () => layersRef.value.filter(l => l.visible);
|
||||||
|
const getAllVisibleSprites = () => getVisibleLayers().flatMap(l => l.sprites);
|
||||||
|
|
||||||
|
const getCellDimensions = () => {
|
||||||
|
// If manual cell size is enabled, use manual values
|
||||||
|
if (manualCellSizeEnabled?.value) {
|
||||||
|
return {
|
||||||
|
cellWidth: manualCellWidth?.value ?? 64,
|
||||||
|
cellHeight: manualCellHeight?.value ?? 64,
|
||||||
|
negativeSpacing: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, calculate from sprite dimensions
|
||||||
|
const visibleLayers = getVisibleLayers();
|
||||||
|
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers);
|
||||||
|
const negativeSpacing = calculateNegativeSpacing(getAllVisibleSprites(), negativeSpacingEnabled.value);
|
||||||
|
return {
|
||||||
|
cellWidth: maxWidth + negativeSpacing,
|
||||||
|
cellHeight: maxHeight + negativeSpacing,
|
||||||
|
negativeSpacing,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawCompositeCell = (ctx: CanvasRenderingContext2D, cellIndex: number, cellWidth: number, cellHeight: number, negativeSpacing: number) => {
|
||||||
|
ctx.clearRect(0, 0, cellWidth, cellHeight);
|
||||||
|
// Apply background color if not transparent
|
||||||
|
if (backgroundColor?.value && backgroundColor.value !== 'transparent') {
|
||||||
|
ctx.fillStyle = backgroundColor.value;
|
||||||
|
ctx.fillRect(0, 0, cellWidth, cellHeight);
|
||||||
|
}
|
||||||
|
const vLayers = getVisibleLayers();
|
||||||
|
vLayers.forEach(layer => {
|
||||||
|
const sprite = layer.sprites[cellIndex];
|
||||||
|
if (!sprite) return;
|
||||||
|
ctx.drawImage(sprite.img, Math.floor(negativeSpacing + sprite.x), Math.floor(negativeSpacing + sprite.y));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadSpritesheet = () => {
|
||||||
|
const visibleLayers = getVisibleLayers();
|
||||||
|
if (!visibleLayers.length || !visibleLayers.some(l => l.sprites.length)) {
|
||||||
|
alert('Please upload or import sprites before downloading the spritesheet.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { cellWidth, cellHeight, negativeSpacing } = getCellDimensions();
|
||||||
|
const maxLen = Math.max(...visibleLayers.map(l => l.sprites.length));
|
||||||
|
const rows = Math.ceil(maxLen / columns.value);
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
canvas.width = cellWidth * columns.value;
|
||||||
|
canvas.height = cellHeight * rows;
|
||||||
|
ctx.imageSmoothingEnabled = false;
|
||||||
|
|
||||||
|
// Apply background color to entire canvas if not transparent
|
||||||
|
if (backgroundColor?.value && backgroundColor.value !== 'transparent') {
|
||||||
|
ctx.fillStyle = backgroundColor.value;
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < maxLen; index++) {
|
||||||
|
const col = index % columns.value;
|
||||||
|
const row = Math.floor(index / columns.value);
|
||||||
|
const cellX = Math.floor(col * cellWidth);
|
||||||
|
const cellY = Math.floor(row * cellHeight);
|
||||||
|
|
||||||
|
const cellCanvas = document.createElement('canvas');
|
||||||
|
const cellCtx = cellCanvas.getContext('2d');
|
||||||
|
if (!cellCtx) return;
|
||||||
|
cellCanvas.width = cellWidth;
|
||||||
|
cellCanvas.height = cellHeight;
|
||||||
|
cellCtx.imageSmoothingEnabled = false;
|
||||||
|
drawCompositeCell(cellCtx, index, cellWidth, cellHeight, negativeSpacing);
|
||||||
|
ctx.drawImage(cellCanvas, cellX, cellY);
|
||||||
|
}
|
||||||
|
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.download = 'spritesheet.png';
|
||||||
|
link.href = canvas.toDataURL('image/png', 1.0);
|
||||||
|
link.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportSpritesheetJSON = async () => {
|
||||||
|
const visibleLayers = getVisibleLayers();
|
||||||
|
if (!visibleLayers.length || !visibleLayers.some(l => l.sprites.length)) {
|
||||||
|
alert('Nothing to export. Please add sprites first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const layersData = await Promise.all(
|
||||||
|
layersRef.value.map(async layer => {
|
||||||
|
const sprites = await Promise.all(
|
||||||
|
layer.sprites.map(async sprite => {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return null;
|
||||||
|
canvas.width = sprite.width;
|
||||||
|
canvas.height = sprite.height;
|
||||||
|
ctx.drawImage(sprite.img, 0, 0);
|
||||||
|
const base64 = canvas.toDataURL('image/png');
|
||||||
|
return { id: sprite.id, width: sprite.width, height: sprite.height, x: sprite.x, y: sprite.y, base64, name: sprite.file.name };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return { id: layer.id, name: layer.name, visible: layer.visible, locked: layer.locked, sprites: sprites.filter(Boolean) };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const json = {
|
||||||
|
version: 2,
|
||||||
|
columns: columns.value,
|
||||||
|
negativeSpacingEnabled: negativeSpacingEnabled.value,
|
||||||
|
backgroundColor: backgroundColor?.value || 'transparent',
|
||||||
|
manualCellSizeEnabled: manualCellSizeEnabled?.value || false,
|
||||||
|
manualCellWidth: manualCellWidth?.value || 64,
|
||||||
|
manualCellHeight: manualCellHeight?.value || 64,
|
||||||
|
layers: layersData,
|
||||||
|
};
|
||||||
|
const jsonString = JSON.stringify(json, null, 2);
|
||||||
|
const blob = new Blob([jsonString], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'spritesheet.json';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const importSpritesheetJSON = async (jsonFile: File) => {
|
||||||
|
const text = await jsonFile.text();
|
||||||
|
const data = JSON.parse(text);
|
||||||
|
|
||||||
|
const loadSprite = (spriteData: any) =>
|
||||||
|
new Promise<Sprite>(resolve => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
const byteString = atob(spriteData.base64.split(',')[1]);
|
||||||
|
const mimeType = spriteData.base64.split(',')[0].split(':')[1].split(';')[0];
|
||||||
|
const ab = new ArrayBuffer(byteString.length);
|
||||||
|
const ia = new Uint8Array(ab);
|
||||||
|
for (let i = 0; i < byteString.length; i++) ia[i] = byteString.charCodeAt(i);
|
||||||
|
const blob = new Blob([ab], { type: mimeType });
|
||||||
|
const fileName = spriteData.name || `sprite-${spriteData.id}.png`;
|
||||||
|
const file = new File([blob], fileName, { type: mimeType });
|
||||||
|
resolve({ id: spriteData.id || crypto.randomUUID(), file, img, url: spriteData.base64, width: spriteData.width, height: spriteData.height, x: spriteData.x || 0, y: spriteData.y || 0 });
|
||||||
|
};
|
||||||
|
img.src = spriteData.base64;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof data.columns === 'number') columns.value = data.columns;
|
||||||
|
if (typeof data.negativeSpacingEnabled === 'boolean') negativeSpacingEnabled.value = data.negativeSpacingEnabled;
|
||||||
|
if (typeof data.backgroundColor === 'string' && backgroundColor) backgroundColor.value = data.backgroundColor;
|
||||||
|
if (typeof data.manualCellSizeEnabled === 'boolean' && manualCellSizeEnabled) manualCellSizeEnabled.value = data.manualCellSizeEnabled;
|
||||||
|
if (typeof data.manualCellWidth === 'number' && manualCellWidth) manualCellWidth.value = data.manualCellWidth;
|
||||||
|
if (typeof data.manualCellHeight === 'number' && manualCellHeight) manualCellHeight.value = data.manualCellHeight;
|
||||||
|
|
||||||
|
if (Array.isArray(data.layers)) {
|
||||||
|
const newLayers: Layer[] = [];
|
||||||
|
for (const layerData of data.layers) {
|
||||||
|
const sprites: Sprite[] = await Promise.all(layerData.sprites.map((s: any) => loadSprite(s)));
|
||||||
|
newLayers.push({ id: layerData.id || crypto.randomUUID(), name: layerData.name || 'Layer', visible: layerData.visible !== false, locked: !!layerData.locked, sprites });
|
||||||
|
}
|
||||||
|
// Ensure at least one layer with sprites is visible
|
||||||
|
if (newLayers.length > 0 && !newLayers.some(l => l.visible && l.sprites.length > 0)) {
|
||||||
|
const firstLayerWithSprites = newLayers.find(l => l.sprites.length > 0);
|
||||||
|
if (firstLayerWithSprites) {
|
||||||
|
firstLayerWithSprites.visible = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
layersRef.value = newLayers;
|
||||||
|
// Set active layer to the first layer with sprites
|
||||||
|
if (activeLayerId && newLayers.length > 0) {
|
||||||
|
const firstWithSprites = newLayers.find(l => l.sprites.length > 0);
|
||||||
|
activeLayerId.value = firstWithSprites ? firstWithSprites.id : newLayers[0].id;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(data.sprites)) {
|
||||||
|
const sprites: Sprite[] = await Promise.all(data.sprites.map((s: any) => loadSprite(s)));
|
||||||
|
const baseLayerId = crypto.randomUUID();
|
||||||
|
layersRef.value = [
|
||||||
|
{ id: baseLayerId, name: 'Base', visible: true, locked: false, sprites },
|
||||||
|
{ id: crypto.randomUUID(), name: 'Other', visible: true, locked: false, sprites: [] },
|
||||||
|
];
|
||||||
|
if (activeLayerId) {
|
||||||
|
activeLayerId.value = baseLayerId;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Invalid JSON format');
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadAsGif = (fps: number) => {
|
||||||
|
const visibleLayers = getVisibleLayers();
|
||||||
|
if (!visibleLayers.length || !visibleLayers.some(l => l.sprites.length)) {
|
||||||
|
alert('Please upload or import sprites before generating a GIF.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { cellWidth, cellHeight, negativeSpacing } = getCellDimensions();
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
canvas.width = cellWidth;
|
||||||
|
canvas.height = cellHeight;
|
||||||
|
ctx.imageSmoothingEnabled = false;
|
||||||
|
|
||||||
|
const gif = new GIF({ workers: 2, quality: 10, width: cellWidth, height: cellHeight, workerScript: gifWorkerUrl });
|
||||||
|
const maxLen = Math.max(...visibleLayers.map(l => l.sprites.length));
|
||||||
|
for (let i = 0; i < maxLen; i++) {
|
||||||
|
drawCompositeCell(ctx, i, cellWidth, cellHeight, negativeSpacing);
|
||||||
|
gif.addFrame(ctx, { copy: true, delay: 1000 / fps });
|
||||||
|
}
|
||||||
|
|
||||||
|
gif.on('finished', (blob: Blob) => {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'animation.gif';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
});
|
||||||
|
gif.render();
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadAsZip = async () => {
|
||||||
|
const visibleLayers = getVisibleLayers();
|
||||||
|
if (!visibleLayers.length || !visibleLayers.some(l => l.sprites.length)) {
|
||||||
|
alert('Please upload or import sprites before downloading a ZIP.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const zip = new JSZip();
|
||||||
|
|
||||||
|
const { cellWidth, cellHeight, negativeSpacing } = getCellDimensions();
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
canvas.width = cellWidth;
|
||||||
|
canvas.height = cellHeight;
|
||||||
|
ctx.imageSmoothingEnabled = false;
|
||||||
|
|
||||||
|
const maxLen = Math.max(...visibleLayers.map(l => l.sprites.length));
|
||||||
|
for (let i = 0; i < maxLen; i++) {
|
||||||
|
drawCompositeCell(ctx, i, cellWidth, cellHeight, negativeSpacing);
|
||||||
|
const dataURL = canvas.toDataURL('image/png');
|
||||||
|
const binary = atob(dataURL.split(',')[1]);
|
||||||
|
const buf = new ArrayBuffer(binary.length);
|
||||||
|
const view = new Uint8Array(buf);
|
||||||
|
for (let j = 0; j < binary.length; j++) view[j] = binary.charCodeAt(j);
|
||||||
|
zip.file(`frames/frame_${String(i + 1).padStart(3, '0')}.png`, view);
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonFolder = zip.folder('export')!;
|
||||||
|
const jsonBlobPromise = (async () => {
|
||||||
|
const layersPayload = await Promise.all(
|
||||||
|
layersRef.value.map(async layer => ({
|
||||||
|
id: layer.id,
|
||||||
|
name: layer.name,
|
||||||
|
visible: layer.visible,
|
||||||
|
locked: layer.locked,
|
||||||
|
sprites: await Promise.all(layer.sprites.map(async s => ({ id: s.id, width: s.width, height: s.height, x: s.x, y: s.y, name: s.file.name }))),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const meta = {
|
||||||
|
version: 2,
|
||||||
|
columns: columns.value,
|
||||||
|
negativeSpacingEnabled: negativeSpacingEnabled.value,
|
||||||
|
backgroundColor: backgroundColor?.value || 'transparent',
|
||||||
|
manualCellSizeEnabled: manualCellSizeEnabled?.value || false,
|
||||||
|
manualCellWidth: manualCellWidth?.value || 64,
|
||||||
|
manualCellHeight: manualCellHeight?.value || 64,
|
||||||
|
layers: layersPayload,
|
||||||
|
};
|
||||||
|
const metaStr = JSON.stringify(meta, null, 2);
|
||||||
|
jsonFolder.file('spritesheet.meta.json', metaStr);
|
||||||
|
})();
|
||||||
|
await jsonBlobPromise;
|
||||||
|
|
||||||
|
const content = await zip.generateAsync({ type: 'blob' });
|
||||||
|
const url = URL.createObjectURL(content);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'sprites.zip';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { downloadSpritesheet, exportSpritesheetJSON, importSpritesheetJSON, downloadAsGif, downloadAsZip };
|
||||||
|
};
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { ref, type Ref } from 'vue';
|
import { ref, type Ref, type ComputedRef } from 'vue';
|
||||||
import type { Sprite } from '@/types/sprites';
|
import type { Sprite } from '@/types/sprites';
|
||||||
import { getMaxDimensions } from './useSprites';
|
import { getMaxDimensions } from './useSprites';
|
||||||
|
|
||||||
export interface FileDropOptions {
|
export interface FileDropOptions {
|
||||||
sprites: Ref<Sprite[]> | Sprite[];
|
sprites: Ref<Sprite[]> | ComputedRef<Sprite[]> | Sprite[];
|
||||||
onAddSprite: (file: File) => void;
|
onAddSprite: (file: File) => void;
|
||||||
onAddSpriteWithResize: (file: File) => void;
|
onAddSpriteWithResize: (file: File) => void;
|
||||||
}
|
}
|
||||||
@@ -31,7 +31,7 @@ export function useFileDrop(options: FileDropOptions) {
|
|||||||
isDragOver.value = true;
|
isDragOver.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragLeave = (event: DragEvent, canvasRef?: HTMLCanvasElement | null) => {
|
const handleDragLeave = (event: DragEvent, canvasRef?: HTMLElement | null) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
||||||
|
|||||||
246
src/composables/useLayers.ts
Normal file
246
src/composables/useLayers.ts
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import type { Layer, Sprite } from '@/types/sprites';
|
||||||
|
import { getMaxDimensions as getMaxDimensionsSingle, useSprites as useSpritesSingle } from './useSprites';
|
||||||
|
import { useSettingsStore } from '@/stores/useSettingsStore';
|
||||||
|
|
||||||
|
export const createEmptyLayer = (name: string): Layer => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name,
|
||||||
|
sprites: [],
|
||||||
|
visible: true,
|
||||||
|
locked: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useLayers = () => {
|
||||||
|
const layers = ref<Layer[]>([createEmptyLayer('Base')]);
|
||||||
|
const activeLayerId = ref<string>(layers.value[0].id);
|
||||||
|
const columns = ref(4);
|
||||||
|
const settingsStore = useSettingsStore();
|
||||||
|
|
||||||
|
watch(columns, val => {
|
||||||
|
const num = typeof val === 'number' ? val : parseInt(String(val));
|
||||||
|
const safe = Number.isFinite(num) && num >= 1 ? Math.min(num, 10) : 1;
|
||||||
|
if (safe !== columns.value) columns.value = safe;
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeLayer = computed(() => layers.value.find(l => l.id === activeLayerId.value) || layers.value[0]);
|
||||||
|
|
||||||
|
const getMaxDimensions = (sprites: Sprite[]) => getMaxDimensionsSingle(sprites);
|
||||||
|
|
||||||
|
const updateSpritePosition = (id: string, x: number, y: number) => {
|
||||||
|
const l = activeLayer.value;
|
||||||
|
if (!l) return;
|
||||||
|
const i = l.sprites.findIndex(s => s.id === id);
|
||||||
|
if (i !== -1) {
|
||||||
|
l.sprites[i].x = Math.floor(x);
|
||||||
|
l.sprites[i].y = Math.floor(y);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSpriteInLayer = (layerId: string, spriteId: string, x: number, y: number) => {
|
||||||
|
const l = layers.value.find(layer => layer.id === layerId);
|
||||||
|
if (!l) return;
|
||||||
|
const i = l.sprites.findIndex(s => s.id === spriteId);
|
||||||
|
if (i !== -1) {
|
||||||
|
l.sprites[i].x = Math.floor(x);
|
||||||
|
l.sprites[i].y = Math.floor(y);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const alignSprites = (position: 'left' | 'center' | 'right' | 'top' | 'middle' | 'bottom') => {
|
||||||
|
const l = activeLayer.value;
|
||||||
|
if (!l || !l.sprites.length) return;
|
||||||
|
|
||||||
|
// Determine the cell dimensions to align within
|
||||||
|
let cellWidth: number;
|
||||||
|
let cellHeight: number;
|
||||||
|
|
||||||
|
if (settingsStore.manualCellSizeEnabled) {
|
||||||
|
// Use manual cell size (without negative spacing)
|
||||||
|
cellWidth = settingsStore.manualCellWidth;
|
||||||
|
cellHeight = settingsStore.manualCellHeight;
|
||||||
|
} else {
|
||||||
|
// Use auto-calculated dimensions based on ALL visible layers (not just active layer)
|
||||||
|
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers.value);
|
||||||
|
cellWidth = maxWidth;
|
||||||
|
cellHeight = maxHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
l.sprites = l.sprites.map(sprite => {
|
||||||
|
let x = sprite.x;
|
||||||
|
let y = sprite.y;
|
||||||
|
switch (position) {
|
||||||
|
case 'left':
|
||||||
|
x = 0;
|
||||||
|
break;
|
||||||
|
case 'center':
|
||||||
|
x = Math.floor((cellWidth - sprite.width) / 2);
|
||||||
|
break;
|
||||||
|
case 'right':
|
||||||
|
x = Math.floor(cellWidth - sprite.width);
|
||||||
|
break;
|
||||||
|
case 'top':
|
||||||
|
y = 0;
|
||||||
|
break;
|
||||||
|
case 'middle':
|
||||||
|
y = Math.floor((cellHeight - sprite.height) / 2);
|
||||||
|
break;
|
||||||
|
case 'bottom':
|
||||||
|
y = Math.floor(cellHeight - sprite.height);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return { ...sprite, x: Math.floor(x), y: Math.floor(y) };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSpriteCell = (id: string, newIndex: number) => {
|
||||||
|
const l = activeLayer.value;
|
||||||
|
if (!l) return;
|
||||||
|
const currentIndex = l.sprites.findIndex(s => s.id === id);
|
||||||
|
if (currentIndex === -1 || currentIndex === newIndex) return;
|
||||||
|
const next = [...l.sprites];
|
||||||
|
if (newIndex < next.length) {
|
||||||
|
const moving = { ...next[currentIndex] };
|
||||||
|
const target = { ...next[newIndex] };
|
||||||
|
next[currentIndex] = target;
|
||||||
|
next[newIndex] = moving;
|
||||||
|
} else {
|
||||||
|
const [moved] = next.splice(currentIndex, 1);
|
||||||
|
next.splice(newIndex, 0, moved);
|
||||||
|
}
|
||||||
|
l.sprites = next;
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeSprite = (id: string) => {
|
||||||
|
const l = activeLayer.value;
|
||||||
|
if (!l) return;
|
||||||
|
const i = l.sprites.findIndex(s => s.id === id);
|
||||||
|
if (i === -1) return;
|
||||||
|
const s = l.sprites[i];
|
||||||
|
if (s.url && s.url.startsWith('blob:')) {
|
||||||
|
try {
|
||||||
|
URL.revokeObjectURL(s.url);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
l.sprites.splice(i, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const replaceSprite = (id: string, file: File) => {
|
||||||
|
const l = activeLayer.value;
|
||||||
|
if (!l) return;
|
||||||
|
const i = l.sprites.findIndex(s => s.id === id);
|
||||||
|
if (i === -1) return;
|
||||||
|
const old = l.sprites[i];
|
||||||
|
if (old.url && old.url.startsWith('blob:')) {
|
||||||
|
try {
|
||||||
|
URL.revokeObjectURL(old.url);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = e => {
|
||||||
|
const url = e.target?.result as string;
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
l.sprites[i] = { id: old.id, file, img, url, width: img.width, height: img.height, x: old.x, y: old.y };
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
console.error('Failed to load replacement image:', file.name);
|
||||||
|
};
|
||||||
|
img.src = url;
|
||||||
|
};
|
||||||
|
reader.onerror = () => {
|
||||||
|
console.error('Failed to read replacement image file:', file.name);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addSprite = (file: File) => {
|
||||||
|
const l = activeLayer.value;
|
||||||
|
if (!l) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = e => {
|
||||||
|
const url = e.target?.result as string;
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
const next: Sprite = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
file,
|
||||||
|
img,
|
||||||
|
url,
|
||||||
|
width: img.width,
|
||||||
|
height: img.height,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
};
|
||||||
|
l.sprites = [...l.sprites, next];
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
console.error('Failed to load sprite image:', file.name);
|
||||||
|
};
|
||||||
|
img.src = url;
|
||||||
|
};
|
||||||
|
reader.onerror = () => {
|
||||||
|
console.error('Failed to read sprite image file:', file.name);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const processImageFiles = async (files: File[]) => {
|
||||||
|
for (const f of files) addSprite(f);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addLayer = (name?: string) => {
|
||||||
|
const l = createEmptyLayer(name || `Layer ${layers.value.length + 1}`);
|
||||||
|
layers.value.push(l);
|
||||||
|
activeLayerId.value = l.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeLayer = (id: string) => {
|
||||||
|
if (layers.value.length === 1) return;
|
||||||
|
const idx = layers.value.findIndex(l => l.id === id);
|
||||||
|
if (idx === -1) return;
|
||||||
|
layers.value.splice(idx, 1);
|
||||||
|
if (activeLayerId.value === id) activeLayerId.value = layers.value[0].id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveLayer = (id: string, direction: 'up' | 'down') => {
|
||||||
|
const idx = layers.value.findIndex(l => l.id === id);
|
||||||
|
if (idx === -1) return;
|
||||||
|
if (direction === 'up' && idx > 0) {
|
||||||
|
const [l] = layers.value.splice(idx, 1);
|
||||||
|
layers.value.splice(idx - 1, 0, l);
|
||||||
|
}
|
||||||
|
if (direction === 'down' && idx < layers.value.length - 1) {
|
||||||
|
const [l] = layers.value.splice(idx, 1);
|
||||||
|
layers.value.splice(idx + 1, 0, l);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const visibleLayers = computed(() => layers.value.filter(l => l.visible));
|
||||||
|
|
||||||
|
return {
|
||||||
|
layers,
|
||||||
|
visibleLayers,
|
||||||
|
activeLayerId,
|
||||||
|
activeLayer,
|
||||||
|
columns,
|
||||||
|
getMaxDimensions,
|
||||||
|
updateSpritePosition,
|
||||||
|
updateSpriteInLayer,
|
||||||
|
updateSpriteCell,
|
||||||
|
removeSprite,
|
||||||
|
replaceSprite,
|
||||||
|
addSprite,
|
||||||
|
processImageFiles,
|
||||||
|
alignSprites,
|
||||||
|
addLayer,
|
||||||
|
removeLayer,
|
||||||
|
moveLayer,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMaxDimensionsAcrossLayers = (layers: Layer[]) => {
|
||||||
|
const sprites = layers.flatMap(l => (l.visible ? l.sprites : []));
|
||||||
|
return getMaxDimensionsSingle(sprites);
|
||||||
|
};
|
||||||
21
src/composables/useNegativeSpacing.ts
Normal file
21
src/composables/useNegativeSpacing.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Sprite } from '@/types/sprites';
|
||||||
|
import { getMaxDimensions } from './useSprites';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate negative spacing to add to top-left of cells.
|
||||||
|
* Uses half the available space so spacing is equal on all sides.
|
||||||
|
*/
|
||||||
|
export function calculateNegativeSpacing(sprites: Sprite[], enabled: boolean): number {
|
||||||
|
if (!enabled || sprites.length === 0) return 0;
|
||||||
|
|
||||||
|
const { maxWidth, maxHeight } = getMaxDimensions(sprites);
|
||||||
|
const minWidth = Math.min(...sprites.map(s => s.width));
|
||||||
|
const minHeight = Math.min(...sprites.map(s => s.height));
|
||||||
|
|
||||||
|
// Available space is the gap between cell size and smallest sprite
|
||||||
|
const availableWidth = maxWidth - minWidth;
|
||||||
|
const availableHeight = maxHeight - minHeight;
|
||||||
|
|
||||||
|
// Use half to balance spacing equally on all sides
|
||||||
|
return Math.floor(Math.min(availableWidth, availableHeight) / 2);
|
||||||
|
}
|
||||||
@@ -87,129 +87,157 @@ export const useSprites = () => {
|
|||||||
const old = sprites.value[i];
|
const old = sprites.value[i];
|
||||||
revokeIfBlob(old.url);
|
revokeIfBlob(old.url);
|
||||||
|
|
||||||
const url = URL.createObjectURL(file);
|
const reader = new FileReader();
|
||||||
const img = new Image();
|
reader.onload = e => {
|
||||||
img.onload = () => {
|
const url = e.target?.result as string;
|
||||||
const next: Sprite = {
|
const img = new Image();
|
||||||
id: old.id,
|
img.onload = () => {
|
||||||
file,
|
const next: Sprite = {
|
||||||
img,
|
id: old.id,
|
||||||
url,
|
file,
|
||||||
width: img.width,
|
img,
|
||||||
height: img.height,
|
url,
|
||||||
x: old.x,
|
width: img.width,
|
||||||
y: old.y,
|
height: img.height,
|
||||||
|
x: old.x,
|
||||||
|
y: old.y,
|
||||||
|
};
|
||||||
|
const arr = [...sprites.value];
|
||||||
|
arr[i] = next;
|
||||||
|
sprites.value = arr;
|
||||||
};
|
};
|
||||||
const arr = [...sprites.value];
|
img.onerror = () => {
|
||||||
arr[i] = next;
|
console.error('Failed to load replacement image:', file.name);
|
||||||
sprites.value = arr;
|
};
|
||||||
|
img.src = url;
|
||||||
};
|
};
|
||||||
img.onerror = () => {
|
reader.onerror = () => {
|
||||||
console.error('Failed to load replacement image:', file.name);
|
console.error('Failed to read replacement image file:', file.name);
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
};
|
};
|
||||||
img.src = url;
|
reader.readAsDataURL(file);
|
||||||
};
|
};
|
||||||
|
|
||||||
const addSprite = (file: File) => {
|
const addSprite = (file: File) => {
|
||||||
const url = URL.createObjectURL(file);
|
const reader = new FileReader();
|
||||||
const img = new Image();
|
reader.onload = e => {
|
||||||
img.onload = () => {
|
const url = e.target?.result as string;
|
||||||
const s: Sprite = {
|
const img = new Image();
|
||||||
id: crypto.randomUUID(),
|
img.onload = () => {
|
||||||
file,
|
const s: Sprite = {
|
||||||
img,
|
id: crypto.randomUUID(),
|
||||||
url,
|
file,
|
||||||
width: img.width,
|
img,
|
||||||
height: img.height,
|
url,
|
||||||
x: 0,
|
width: img.width,
|
||||||
y: 0,
|
height: img.height,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
};
|
||||||
|
sprites.value = [...sprites.value, s];
|
||||||
};
|
};
|
||||||
sprites.value = [...sprites.value, s];
|
img.onerror = () => {
|
||||||
|
console.error('Failed to load new sprite image:', file.name);
|
||||||
|
};
|
||||||
|
img.src = url;
|
||||||
};
|
};
|
||||||
img.onerror = () => {
|
reader.onerror = () => {
|
||||||
console.error('Failed to load new sprite image:', file.name);
|
console.error('Failed to read sprite image file:', file.name);
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
};
|
};
|
||||||
img.src = url;
|
reader.readAsDataURL(file);
|
||||||
};
|
};
|
||||||
|
|
||||||
const addSpriteWithResize = (file: File) => {
|
const addSpriteWithResize = (file: File) => {
|
||||||
const url = URL.createObjectURL(file);
|
const reader = new FileReader();
|
||||||
const img = new Image();
|
reader.onload = e => {
|
||||||
img.onload = () => {
|
const url = e.target?.result as string;
|
||||||
const { maxWidth, maxHeight } = getMaxDimensions(sprites.value);
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
const { maxWidth, maxHeight } = getMaxDimensions(sprites.value);
|
||||||
|
|
||||||
const newSprite: Sprite = {
|
const newSprite: Sprite = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
file,
|
file,
|
||||||
img,
|
img,
|
||||||
url,
|
url,
|
||||||
width: img.width,
|
width: img.width,
|
||||||
height: img.height,
|
height: img.height,
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const newMaxWidth = Math.max(maxWidth, img.width);
|
||||||
|
const newMaxHeight = Math.max(maxHeight, img.height);
|
||||||
|
|
||||||
|
if (img.width > maxWidth || img.height > maxHeight) {
|
||||||
|
sprites.value = sprites.value.map(sprite => {
|
||||||
|
let newX = sprite.x;
|
||||||
|
let newY = sprite.y;
|
||||||
|
|
||||||
|
if (img.width > maxWidth) {
|
||||||
|
const relativeX = maxWidth > 0 ? sprite.x / maxWidth : 0;
|
||||||
|
newX = Math.floor(relativeX * newMaxWidth);
|
||||||
|
newX = Math.max(0, Math.min(newX, newMaxWidth - sprite.width));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (img.height > maxHeight) {
|
||||||
|
const relativeY = maxHeight > 0 ? sprite.y / maxHeight : 0;
|
||||||
|
newY = Math.floor(relativeY * newMaxHeight);
|
||||||
|
newY = Math.max(0, Math.min(newY, newMaxHeight - sprite.height));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...sprite, x: newX, y: newY };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
sprites.value = [...sprites.value, newSprite];
|
||||||
|
triggerForceRedraw();
|
||||||
};
|
};
|
||||||
|
img.onerror = () => {
|
||||||
const newMaxWidth = Math.max(maxWidth, img.width);
|
console.error('Failed to load new sprite image:', file.name);
|
||||||
const newMaxHeight = Math.max(maxHeight, img.height);
|
};
|
||||||
|
img.src = url;
|
||||||
if (img.width > maxWidth || img.height > maxHeight) {
|
|
||||||
sprites.value = sprites.value.map(sprite => {
|
|
||||||
let newX = sprite.x;
|
|
||||||
let newY = sprite.y;
|
|
||||||
|
|
||||||
if (img.width > maxWidth) {
|
|
||||||
const relativeX = maxWidth > 0 ? sprite.x / maxWidth : 0;
|
|
||||||
newX = Math.floor(relativeX * newMaxWidth);
|
|
||||||
newX = Math.max(0, Math.min(newX, newMaxWidth - sprite.width));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (img.height > maxHeight) {
|
|
||||||
const relativeY = maxHeight > 0 ? sprite.y / maxHeight : 0;
|
|
||||||
newY = Math.floor(relativeY * newMaxHeight);
|
|
||||||
newY = Math.max(0, Math.min(newY, newMaxHeight - sprite.height));
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...sprite, x: newX, y: newY };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
sprites.value = [...sprites.value, newSprite];
|
|
||||||
triggerForceRedraw();
|
|
||||||
};
|
};
|
||||||
img.onerror = () => {
|
reader.onerror = () => {
|
||||||
console.error('Failed to load new sprite image:', file.name);
|
console.error('Failed to read sprite image file:', file.name);
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
};
|
};
|
||||||
img.src = url;
|
reader.readAsDataURL(file);
|
||||||
};
|
};
|
||||||
|
|
||||||
const processImageFiles = (files: File[]) => {
|
const processImageFiles = (files: File[]) => {
|
||||||
Promise.all(
|
Promise.all(
|
||||||
files.map(
|
files.map(
|
||||||
file =>
|
file =>
|
||||||
new Promise<Sprite>(resolve => {
|
new Promise<Sprite>((resolve, reject) => {
|
||||||
const url = URL.createObjectURL(file);
|
const reader = new FileReader();
|
||||||
const img = new Image();
|
reader.onload = e => {
|
||||||
img.onload = () => {
|
const url = e.target?.result as string;
|
||||||
resolve({
|
const img = new Image();
|
||||||
id: crypto.randomUUID(),
|
img.onload = () => {
|
||||||
file,
|
resolve({
|
||||||
img,
|
id: crypto.randomUUID(),
|
||||||
url,
|
file,
|
||||||
width: img.width,
|
img,
|
||||||
height: img.height,
|
url,
|
||||||
x: 0,
|
width: img.width,
|
||||||
y: 0,
|
height: img.height,
|
||||||
});
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
img.onerror = () => reject(new Error(`Failed to load image: ${file.name}`));
|
||||||
|
img.src = url;
|
||||||
};
|
};
|
||||||
img.src = url;
|
reader.onerror = () => reject(new Error(`Failed to read file: ${file.name}`));
|
||||||
|
reader.readAsDataURL(file);
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
).then(newSprites => {
|
)
|
||||||
sprites.value = [...sprites.value, ...newSprites];
|
.then(newSprites => {
|
||||||
});
|
sprites.value = [...sprites.value, ...newSprites];
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('Error processing image files:', err);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import { ref, watch } from 'vue';
|
|||||||
const pixelPerfect = ref(true);
|
const pixelPerfect = ref(true);
|
||||||
const darkMode = ref(false);
|
const darkMode = ref(false);
|
||||||
const negativeSpacingEnabled = ref(false);
|
const negativeSpacingEnabled = ref(false);
|
||||||
|
const backgroundColor = ref('transparent');
|
||||||
|
const manualCellSizeEnabled = ref(false);
|
||||||
|
const manualCellWidth = ref(64);
|
||||||
|
const manualCellHeight = ref(64);
|
||||||
|
const checkerboardEnabled = ref(false);
|
||||||
|
|
||||||
// Initialize dark mode from localStorage or system preference
|
// Initialize dark mode from localStorage or system preference
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
@@ -56,14 +61,50 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
negativeSpacingEnabled.value = !negativeSpacingEnabled.value;
|
negativeSpacingEnabled.value = !negativeSpacingEnabled.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setBackgroundColor(color: string) {
|
||||||
|
backgroundColor.value = color;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleManualCellSize() {
|
||||||
|
manualCellSizeEnabled.value = !manualCellSizeEnabled.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setManualCellWidth(width: number) {
|
||||||
|
manualCellWidth.value = Math.max(1, Math.floor(width));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setManualCellHeight(height: number) {
|
||||||
|
manualCellHeight.value = Math.max(1, Math.floor(height));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setManualCellSize(width: number, height: number) {
|
||||||
|
setManualCellWidth(width);
|
||||||
|
setManualCellHeight(height);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCheckerboard() {
|
||||||
|
checkerboardEnabled.value = !checkerboardEnabled.value;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pixelPerfect,
|
pixelPerfect,
|
||||||
darkMode,
|
darkMode,
|
||||||
negativeSpacingEnabled,
|
negativeSpacingEnabled,
|
||||||
|
backgroundColor,
|
||||||
|
manualCellSizeEnabled,
|
||||||
|
manualCellWidth,
|
||||||
|
manualCellHeight,
|
||||||
|
checkerboardEnabled,
|
||||||
togglePixelPerfect,
|
togglePixelPerfect,
|
||||||
setPixelPerfect,
|
setPixelPerfect,
|
||||||
toggleDarkMode,
|
toggleDarkMode,
|
||||||
setDarkMode,
|
setDarkMode,
|
||||||
toggleNegativeSpacing,
|
toggleNegativeSpacing,
|
||||||
|
setBackgroundColor,
|
||||||
|
toggleManualCellSize,
|
||||||
|
setManualCellWidth,
|
||||||
|
setManualCellHeight,
|
||||||
|
setManualCellSize,
|
||||||
|
toggleCheckerboard,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,3 +16,11 @@ export interface SpriteFile {
|
|||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Layer {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
sprites: Sprite[];
|
||||||
|
visible: boolean;
|
||||||
|
locked: boolean;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user